]> git.r.bdr.sh - rbdr/mobius/commitdiff
Add initial HTTP API endpoints
authorJeff Halter <redacted>
Thu, 18 Jul 2024 23:15:08 +0000 (16:15 -0700)
committerJeff Halter <redacted>
Thu, 18 Jul 2024 23:21:19 +0000 (16:21 -0700)
README.md
cmd/mobius-hotline-server/main.go
hotline/server.go
internal/mobius/api.go [new file with mode: 0644]

index 5f2c7b4c11c8bd9c124a2de870d834662d445a2b..a5df52544efc1a34f1cdcf50546aca40a085cbce 100644 (file)
--- 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)
 
 
 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 `<ip>:<port>`.
+
+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
index 04b3c57a3597920b5a15a06df7b4d2c4748d6908..33ba7c2125155d511b507d1a34aa86674310db79 100644 (file)
@@ -3,7 +3,6 @@ package main
 import (
        "context"
        "embed"
 import (
        "context"
        "embed"
-       "encoding/json"
        "flag"
        "fmt"
        "github.com/jhalter/mobius/hotline"
        "flag"
        "fmt"
        "github.com/jhalter/mobius/hotline"
@@ -12,7 +11,6 @@ import (
        "io"
        "log"
        "log/slog"
        "io"
        "log"
        "log/slog"
-       "net/http"
        "os"
        "os/signal"
        "path"
        "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.")
 
        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")
        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() {
        }
 
        go func() {
@@ -214,19 +195,6 @@ func main() {
        log.Fatal(srv.ListenAndServe(ctx))
 }
 
        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 {
 func configSearchPaths() string {
        for _, cfgPath := range mobius.ConfigSearchOrder {
                if _, err := os.Stat(cfgPath); err == nil {
index 58a9209f1574d90387cc4389f21ea569be14e9c9..cfdb6b3a4ce52709c8ca28b1c5bf77e0b32c7f66 100644 (file)
@@ -13,6 +13,7 @@ import (
        "log"
        "log/slog"
        "net"
        "log"
        "log/slog"
        "net"
+       "os"
        "strings"
        "sync"
        "time"
        "strings"
        "sync"
        "time"
@@ -586,3 +587,18 @@ func (s *Server) handleFileTransfer(ctx context.Context, rwc io.ReadWriter) erro
        }
        return nil
 }
        }
        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 (file)
index 0000000..31755b8
--- /dev/null
@@ -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)
+       }
+}