Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial HTTP API endpoints #143

Merged
merged 1 commit into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}
Loading