diff --git a/internal/api/handler.go b/internal/api/handler.go index 7e05682..3766f39 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -8,6 +8,7 @@ import ( v1 "github.com/yusing/go-proxy/internal/api/v1" "github.com/yusing/go-proxy/internal/api/v1/auth" "github.com/yusing/go-proxy/internal/api/v1/certapi" + "github.com/yusing/go-proxy/internal/api/v1/dockerapi" "github.com/yusing/go-proxy/internal/api/v1/favicon" "github.com/yusing/go-proxy/internal/common" config "github.com/yusing/go-proxy/internal/config/types" @@ -88,6 +89,9 @@ func NewHandler(cfg config.ConfigInstance) http.Handler { mux.HandleFunc("GET", "/v1/metrics/uptime", uptime.Poller.ServeHTTP, true) mux.HandleFunc("GET", "/v1/cert/info", certapi.GetCertInfo, true) mux.HandleFunc("", "/v1/cert/renew", certapi.RenewCert, true) + mux.HandleFunc("GET", "/v1/docker/info", dockerapi.Info, true) + mux.HandleFunc("GET", "/v1/docker/logs/{server}/{container}", dockerapi.Logs, true) + mux.HandleFunc("GET", "/v1/docker/containers", dockerapi.Containers, true) if common.PrometheusEnabled { mux.Handle("GET /v1/metrics", promhttp.Handler()) diff --git a/internal/api/v1/dockerapi/common.go b/internal/api/v1/dockerapi/common.go new file mode 100644 index 0000000..9beb128 --- /dev/null +++ b/internal/api/v1/dockerapi/common.go @@ -0,0 +1,5 @@ +package dockerapi + +import "time" + +const reqTimeout = 10 * time.Second diff --git a/internal/api/v1/dockerapi/containers.go b/internal/api/v1/dockerapi/containers.go new file mode 100644 index 0000000..6419ce0 --- /dev/null +++ b/internal/api/v1/dockerapi/containers.go @@ -0,0 +1,79 @@ +package dockerapi + +import ( + "context" + "net/http" + "sort" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/docker/docker/api/types/container" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" + "github.com/yusing/go-proxy/internal/net/gphttp/httpheaders" +) + +type Container struct { + Server string `json:"server"` + Name string `json:"name"` + ID string `json:"id"` + Image string `json:"image"` + State string `json:"state"` +} + +func Containers(w http.ResponseWriter, r *http.Request) { + if httpheaders.IsWebsocket(r.Header) { + gpwebsocket.Periodic(w, r, 5*time.Second, func(conn *websocket.Conn) error { + containers, err := listContainers(r.Context()) + if err != nil { + return err + } + return wsjson.Write(r.Context(), conn, containers) + }) + } else { + containers, err := listContainers(r.Context()) + handleResult(w, err, containers) + } +} + +func listContainers(ctx context.Context) ([]Container, error) { + ctx, cancel := context.WithTimeout(ctx, reqTimeout) + defer cancel() + + dockerClients, err := getDockerClients() + if err != nil { + return nil, err + } + defer closeAllClients(dockerClients) + + errs := gperr.NewBuilder("failed to get containers") + containers := make([]Container, 0) + for server, dockerClient := range dockerClients { + conts, err := dockerClient.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + errs.Add(err) + continue + } + for _, cont := range conts { + containers = append(containers, Container{ + Server: server, + Name: cont.Names[0], + ID: cont.ID, + Image: cont.Image, + State: cont.State, + }) + } + } + sort.Slice(containers, func(i, j int) bool { + return containers[i].Name < containers[j].Name + }) + if err := errs.Error(); err != nil { + gperr.LogError("failed to get containers", err) + if len(containers) == 0 { + return nil, err + } + return containers, nil + } + return containers, nil +} diff --git a/internal/api/v1/dockerapi/info.go b/internal/api/v1/dockerapi/info.go new file mode 100644 index 0000000..f380584 --- /dev/null +++ b/internal/api/v1/dockerapi/info.go @@ -0,0 +1,57 @@ +package dockerapi + +import ( + "context" + "encoding/json" + "net/http" + + dockerSystem "github.com/docker/docker/api/types/system" + "github.com/yusing/go-proxy/internal/gperr" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +type DockerInfo dockerSystem.Info + +func (d *DockerInfo) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "host": d.Name, + "containers": map[string]int{ + "total": d.Containers, + "running": d.ContainersRunning, + "paused": d.ContainersPaused, + "stopped": d.ContainersStopped, + }, + "images": d.Images, + "n_cpu": d.NCPU, + "memory": strutils.FormatByteSizeWithUnit(d.MemTotal), + "version": d.ServerVersion, + }) +} + +func Info(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), reqTimeout) + defer cancel() + + dockerClients, ok := getDockerClientsWithErrHandling(w) + if !ok { + return + } + defer closeAllClients(dockerClients) + + errs := gperr.NewBuilder("failed to get docker info") + + dockerInfos := make([]DockerInfo, len(dockerClients)) + i := 0 + for name, dockerClient := range dockerClients { + info, err := dockerClient.Info(ctx) + if err != nil { + errs.Add(err) + continue + } + info.Name = name + dockerInfos[i] = DockerInfo(info) + i++ + } + + handleResult(w, errs.Error(), dockerInfos) +} diff --git a/internal/api/v1/dockerapi/logs.go b/internal/api/v1/dockerapi/logs.go new file mode 100644 index 0000000..19d735d --- /dev/null +++ b/internal/api/v1/dockerapi/logs.go @@ -0,0 +1,60 @@ +package dockerapi + +import ( + "net/http" + + "github.com/coder/websocket" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/pkg/stdcopy" + "github.com/yusing/go-proxy/internal/net/gphttp" + "github.com/yusing/go-proxy/internal/net/gphttp/gpwebsocket" + "github.com/yusing/go-proxy/internal/utils/strutils" +) + +func Logs(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + server := r.PathValue("server") + containerID := r.PathValue("container") + stdout := strutils.ParseBool(query.Get("stdout")) + stderr := strutils.ParseBool(query.Get("stderr")) + since := query.Get("from") + until := query.Get("to") + levels := query.Get("levels") // TODO: implement levels + + dockerClient, found, err := getDockerClient(w, server) + if err != nil { + gphttp.BadRequest(w, err.Error()) + return + } + if !found { + gphttp.NotFound(w, "server not found") + return + } + + opts := container.LogsOptions{ + ShowStdout: stdout, + ShowStderr: stderr, + Since: since, + Until: until, + Timestamps: true, + Follow: true, + Tail: "100", + } + if levels != "" { + opts.Details = true + } + + logs, err := dockerClient.ContainerLogs(r.Context(), containerID, opts) + if err != nil { + gphttp.BadRequest(w, err.Error()) + return + } + defer logs.Close() + + conn, err := gpwebsocket.Initiate(w, r) + if err != nil { + return + } + writer := gpwebsocket.NewWriter(r.Context(), conn, websocket.MessageText) + stdcopy.StdCopy(writer, writer, logs) //de-multiplex logs +} diff --git a/internal/api/v1/dockerapi/utils.go b/internal/api/v1/dockerapi/utils.go new file mode 100644 index 0000000..9742b9d --- /dev/null +++ b/internal/api/v1/dockerapi/utils.go @@ -0,0 +1,104 @@ +package dockerapi + +import ( + "encoding/json" + "net/http" + + config "github.com/yusing/go-proxy/internal/config/types" + "github.com/yusing/go-proxy/internal/docker" + "github.com/yusing/go-proxy/internal/gperr" +) + +// getDockerClients returns a map of docker clients for the current config. +// +// Returns a map of docker clients by server name and an error if any. +// +// Even if there are errors, the map of docker clients might not be empty. +func getDockerClients() (map[string]*docker.SharedClient, gperr.Error) { + cfg := config.GetInstance() + + dockerHosts := cfg.Value().Providers.Docker + dockerClients := make(map[string]*docker.SharedClient) + + connErrs := gperr.NewBuilder("failed to connect to docker") + + for name, host := range dockerHosts { + dockerClient, err := docker.ConnectClient(host) + if err != nil { + connErrs.Add(err) + continue + } + dockerClients[name] = dockerClient + } + + for _, agent := range cfg.ListAgents() { + dockerClient, err := docker.ConnectClient(agent.FakeDockerHost()) + if err != nil { + connErrs.Add(err) + continue + } + dockerClients[agent.Name()] = dockerClient + } + + return dockerClients, connErrs.Error() +} + +// getDockerClientsWithErrHandling returns a map of docker clients for the current config. +// +// Returns a map of docker clients by server name and a boolean indicating if http handler should stop/ +func getDockerClientsWithErrHandling(w http.ResponseWriter) (map[string]*docker.SharedClient, bool) { + dockerClients, err := getDockerClients() + if err != nil { + gperr.LogError("failed to get docker clients", err) + if len(dockerClients) == 0 { + http.Error(w, "no docker hosts connected successfully", http.StatusInternalServerError) + return nil, false + } + } + return dockerClients, true +} + +func getDockerClient(w http.ResponseWriter, server string) (*docker.SharedClient, bool, error) { + cfg := config.GetInstance() + var host string + for name, h := range cfg.Value().Providers.Docker { + if name == server { + host = h + break + } + } + for _, agent := range cfg.ListAgents() { + if agent.Name() == server { + host = agent.FakeDockerHost() + break + } + } + if host == "" { + return nil, false, nil + } + dockerClient, err := docker.ConnectClient(host) + if err != nil { + return nil, false, err + } + return dockerClient, true, nil +} + +// closeAllClients closes all docker clients after a delay. +// +// This is used to ensure that all docker clients are closed after the http handler returns. +func closeAllClients(dockerClients map[string]*docker.SharedClient) { + for _, dockerClient := range dockerClients { + dockerClient.Close() + } +} + +func handleResult[T any](w http.ResponseWriter, errs error, result []T) { + if errs != nil { + gperr.LogError("docker errors", errs) + if len(result) == 0 { + http.Error(w, "docker errors", http.StatusInternalServerError) + return + } + } + json.NewEncoder(w).Encode(result) +} diff --git a/internal/net/gphttp/gpwebsocket/writer.go b/internal/net/gphttp/gpwebsocket/writer.go new file mode 100644 index 0000000..3e3998f --- /dev/null +++ b/internal/net/gphttp/gpwebsocket/writer.go @@ -0,0 +1,29 @@ +package gpwebsocket + +import ( + "context" + + "github.com/coder/websocket" +) + +type Writer struct { + conn *websocket.Conn + msgType websocket.MessageType + ctx context.Context +} + +func NewWriter(ctx context.Context, conn *websocket.Conn, msgType websocket.MessageType) *Writer { + return &Writer{ + ctx: ctx, + conn: conn, + msgType: msgType, + } +} + +func (w *Writer) Write(p []byte) (int, error) { + return len(p), w.conn.Write(w.ctx, w.msgType, p) +} + +func (w *Writer) Close() error { + return w.conn.CloseNow() +} diff --git a/internal/utils/strutils/format.go b/internal/utils/strutils/format.go index 59b3097..626b701 100644 --- a/internal/utils/strutils/format.go +++ b/internal/utils/strutils/format.go @@ -74,7 +74,7 @@ func formatFloat(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } -func FormatByteSize[T ~uint64 | ~float64](size T) (value, unit string) { +func FormatByteSize[T ~int64 | ~uint64 | ~float64](size T) (value, unit string) { const ( _ = (1 << (10 * iota)) kb @@ -99,7 +99,7 @@ func FormatByteSize[T ~uint64 | ~float64](size T) (value, unit string) { } } -func FormatByteSizeWithUnit[T ~uint64 | ~float64](size T) string { +func FormatByteSizeWithUnit[T ~int64 | ~uint64 | ~float64](size T) string { value, unit := FormatByteSize(size) return value + " " + unit }