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
import (
"context"
"embed"
- "encoding/json"
"flag"
"fmt"
"github.com/jhalter/mobius/hotline"
"io"
"log"
"log/slog"
- "net/http"
"os"
"os/signal"
"path"
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")
}
}
- 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() {
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 {
"log"
"log/slog"
"net"
+ "os"
"strings"
"sync"
"time"
}
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)
+}
--- /dev/null
+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)
+ }
+}