Skip to content

Commit

Permalink
Add initial HTTP API endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
jhalter committed Jul 18, 2024
1 parent fd740bc commit b6e3be9
Show file tree
Hide file tree
Showing 4 changed files with 175 additions and 36 deletions.
59 changes: 59 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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" }
```
40 changes: 4 additions & 36 deletions cmd/mobius-hotline-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package main
import (
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"github.com/jhalter/mobius/hotline"
Expand All @@ -12,7 +11,6 @@ import (
"io"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
"path"
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions hotline/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"log"
"log/slog"
"net"
"os"
"strings"
"sync"
"time"
Expand Down Expand Up @@ -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)
}
96 changes: 96 additions & 0 deletions internal/mobius/api.go
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit b6e3be9

Please sign in to comment.