From: Jeff Halter Date: Thu, 18 Jul 2024 23:15:08 +0000 (-0700) Subject: Add initial HTTP API endpoints X-Git-Url: https://git.r.bdr.sh/rbdr/mobius/commitdiff_plain/b6e3be945680d017874967ae72ef86ee4235dcc2 Add initial HTTP API endpoints --- diff --git a/README.md b/README.md index 5f2c7b4..a5df525 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,62 @@ Usage of mobius-hotline-server: To run as a systemd service, refer to this sample unit file: [mobius-hotline-server.service](https://github.com/jhalter/mobius/blob/master/cmd/mobius-hotline-server/mobius-hotline-server.service) +## (Optional) HTTP API + +The Mobius server includes an optional HTTP API to perform out-of-band administrative functions. + +To enable it, include the `--api-port` flag with a string defining the IP and port to listen on in the form of `:`. + +Example: `--api-port=127.0.0.1:5503` + +⚠️ The API has no authentication, so binding it to localhost is a good idea! + +#### GET /api/v1/stats + +The stats endpoint returns server runtime statistics and counters. + +``` +❯ curl -s localhost:5603/api/v1/stats | jq . +{ + "ConnectionCounter": 0, + "ConnectionPeak": 0, + "CurrentlyConnected": 0, + "DownloadCounter": 0, + "DownloadsInProgress": 0, + "Since": "2024-07-18T15:36:42.426156-07:00", + "UploadCounter": 0, + "UploadsInProgress": 0, + "WaitingDownloads": 0 +} +``` + +#### GET /api/v1/reload + +The reload endpoint reloads the following configuration files from disk: + +* Agreement.txt +* News.txt +* Users/*.yaml +* ThreadedNews.yaml +* banner.jpg + +Example: + +``` +❯ curl -s localhost:5603/api/v1/reload | jq . +{ + "msg": "config reloaded" +} +``` + +#### POST /api/v1/shutdown + +The shutdown endpoint accepts a shutdown message from POST payload, sends it to to all connected Hotline clients, then gracefully shuts down the server. + +Example: + +``` +❯ curl -d 'Server rebooting' localhost:5603/api/v1/shutdown + +{ "msg": "server shutting down" } +``` \ No newline at end of file diff --git a/cmd/mobius-hotline-server/main.go b/cmd/mobius-hotline-server/main.go index 04b3c57..33ba7c2 100644 --- a/cmd/mobius-hotline-server/main.go +++ b/cmd/mobius-hotline-server/main.go @@ -3,7 +3,6 @@ package main import ( "context" "embed" - "encoding/json" "flag" "fmt" "github.com/jhalter/mobius/hotline" @@ -12,7 +11,6 @@ import ( "io" "log" "log/slog" - "net/http" "os" "os/signal" "path" @@ -44,7 +42,7 @@ func main() { netInterface := flag.String("interface", "", "IP addr of interface to listen on. Defaults to all interfaces.") basePort := flag.Int("bind", 5500, "Base Hotline server port. File transfer port is base port + 1.") - statsPort := flag.String("stats-port", "", "Enable stats HTTP endpoint on address and port") + apiAddr := flag.String("api-addr", "", "Enable HTTP API endpoint on address and port") configDir := flag.String("config", configSearchPaths(), "Path to config root") printVersion := flag.Bool("version", false, "Print version and exit") logLevel := flag.String("log-level", "info", "Log level") @@ -161,26 +159,9 @@ func main() { } } - reloadHandler := func(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) { - return func(w http.ResponseWriter, _ *http.Request) { - reloadFunc() - - _, _ = io.WriteString(w, `{ "msg": "config reloaded" }`) - } - } - - sh := APIHandler{hlServer: srv} - if *statsPort != "" { - http.HandleFunc("/", sh.RenderStats) - http.HandleFunc("/api/v1/stats", sh.RenderStats) - http.HandleFunc("/api/v1/reload", reloadHandler(reloadFunc)) - - go func(srv *hotline.Server) { - err = http.ListenAndServe(":"+*statsPort, nil) - if err != nil { - log.Fatal(err) - } - }(srv) + if *apiAddr != "" { + sh := mobius.NewAPIServer(srv, reloadFunc, slogger) + go sh.Serve(*apiAddr) } go func() { @@ -214,19 +195,6 @@ func main() { log.Fatal(srv.ListenAndServe(ctx)) } -type APIHandler struct { - hlServer *hotline.Server -} - -func (sh *APIHandler) RenderStats(w http.ResponseWriter, _ *http.Request) { - u, err := json.Marshal(sh.hlServer.CurrentStats()) - if err != nil { - panic(err) - } - - _, _ = io.WriteString(w, string(u)) -} - func configSearchPaths() string { for _, cfgPath := range mobius.ConfigSearchOrder { if _, err := os.Stat(cfgPath); err == nil { diff --git a/hotline/server.go b/hotline/server.go index 58a9209..cfdb6b3 100644 --- a/hotline/server.go +++ b/hotline/server.go @@ -13,6 +13,7 @@ import ( "log" "log/slog" "net" + "os" "strings" "sync" "time" @@ -586,3 +587,18 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro } return nil } + +func (s *Server) SendAll(t TranType, fields ...Field) { + for _, c := range s.ClientMgr.List() { + s.outbox <- NewTransaction(t, c.ID, fields...) + } +} + +func (s *Server) Shutdown(msg []byte) { + s.Logger.Info("Shutdown signal received") + s.SendAll(TranDisconnectMsg, NewField(FieldData, msg)) + + time.Sleep(3 * time.Second) + + os.Exit(0) +} diff --git a/internal/mobius/api.go b/internal/mobius/api.go new file mode 100644 index 0000000..31755b8 --- /dev/null +++ b/internal/mobius/api.go @@ -0,0 +1,96 @@ +package mobius + +import ( + "bytes" + "encoding/json" + "github.com/jhalter/mobius/hotline" + "io" + "log" + "log/slog" + "net/http" +) + +type logResponseWriter struct { + http.ResponseWriter + statusCode int + buf bytes.Buffer +} + +func NewLogResponseWriter(w http.ResponseWriter) *logResponseWriter { + return &logResponseWriter{w, http.StatusOK, bytes.Buffer{}} +} + +func (lrw *logResponseWriter) WriteHeader(code int) { + lrw.statusCode = code + lrw.ResponseWriter.WriteHeader(code) +} + +func (lrw *logResponseWriter) Write(b []byte) (int, error) { + lrw.buf.Write(b) + return lrw.ResponseWriter.Write(b) +} + +type APIServer struct { + hlServer *hotline.Server + logger *slog.Logger + mux *http.ServeMux +} + +func (srv *APIServer) logMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + lrw := NewLogResponseWriter(w) + next.ServeHTTP(lrw, r) + + srv.logger.Info("req", "method", r.Method, "url", r.URL.Path, "remoteAddr", r.RemoteAddr, "response_code", lrw.statusCode) + }) +} + +func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger) *APIServer { + srv := APIServer{ + hlServer: hlServer, + logger: logger, + mux: http.NewServeMux(), + } + + srv.mux.Handle("/api/v1/reload", srv.logMiddleware(http.HandlerFunc(srv.ReloadHandler(reloadFunc)))) + srv.mux.Handle("/api/v1/shutdown", srv.logMiddleware(http.HandlerFunc(srv.ShutdownHandler))) + srv.mux.Handle("/api/v1/stats", srv.logMiddleware(http.HandlerFunc(srv.RenderStats))) + + return &srv +} + +func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) { + msg, err := io.ReadAll(r.Body) + if err != nil || len(msg) == 0 { + w.WriteHeader(http.StatusBadRequest) + return + } + + go srv.hlServer.Shutdown(msg) + + _, _ = io.WriteString(w, `{ "msg": "server shutting down" }`) +} + +func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) { + return func(w http.ResponseWriter, _ *http.Request) { + reloadFunc() + + _, _ = io.WriteString(w, `{ "msg": "config reloaded" }`) + } +} + +func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) { + u, err := json.Marshal(srv.hlServer.CurrentStats()) + if err != nil { + panic(err) + } + + _, _ = io.WriteString(w, string(u)) +} + +func (srv *APIServer) Serve(port string) { + err := http.ListenAndServe(port, srv.mux) + if err != nil { + log.Fatal(err) + } +}