diff options
| -rw-r--r-- | README.md | 87 | ||||
| -rw-r--r-- | api.yaml | 278 | ||||
| -rw-r--r-- | internal/mobius/api.go | 28 |
3 files changed, 355 insertions, 38 deletions
@@ -170,62 +170,73 @@ 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 +## HTTP API -The Mobius server includes an optional HTTP API to perform out-of-band administrative functions. +The Mobius server includes an optional HTTP API for server administration and user management. -To enable it, include the `--api-addr` flag with a string defining the IP and port to listen on in the form of `<ip>:<port>`. +### Configuration -Example: `--api-addr=127.0.0.1:5503` +To enable the API, use the `--api-addr` flag with an IP and port: -⚠️ The API has no authentication, so binding it to localhost is a good idea! +```bash +mobius-hotline-server --api-addr=127.0.0.1:5503 +``` -#### GET /api/v1/stats +### Authentication -The stats endpoint returns server runtime statistics and counters. +The API supports optional authentication via API key. Set the `--api-key` flag: +```bash +mobius-hotline-server --api-addr=127.0.0.1:5503 --api-key=your-secret-key ``` -❯ curl -s localhost:5503/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 -} + +Include the API key in the `X-API-Key` header: + +```bash +curl -H "X-API-Key: your-secret-key" localhost:5503/api/v1/stats ``` -#### GET /api/v1/reload +### API Documentation -The reload endpoint reloads the following configuration files from disk: +Complete API documentation is available in the [OpenAPI specification](api.yaml). -* Agreement.txt -* News.txt -* Users/*.yaml -* ThreadedNews.yaml -* banner.jpg +### Endpoints -Example: +#### User Management -``` -❯ curl -s localhost:5503/api/v1/reload | jq . -{ - "msg": "config reloaded" -} -``` +- `GET /api/v1/online` - List currently online users +- `POST /api/v1/ban` - Ban a user by username, nickname, or IP +- `POST /api/v1/unban` - Remove a ban +- `GET /api/v1/banned/ips` - List banned IP addresses +- `GET /api/v1/banned/usernames` - List banned usernames +- `GET /api/v1/banned/nicknames` - List banned nicknames -#### POST /api/v1/shutdown +#### Server Administration -The shutdown endpoint accepts a shutdown message from POST payload, sends it to to all connected Hotline clients, then gracefully shuts down the server. +- `GET /api/v1/stats` - Get server statistics +- `POST /api/v1/reload` - Reload configuration +- `POST /api/v1/shutdown` - Shutdown server -Example: +### Examples + +**Get online users:** +```bash +curl localhost:5503/api/v1/online +``` + +**Ban a user:** +```bash +curl -X POST -H "Content-Type: application/json" \ + -d '{"username":"baduser"}' \ + localhost:5503/api/v1/ban +``` +**Get server stats:** +```bash +curl localhost:5503/api/v1/stats | jq . ``` -❯ curl -d 'Server rebooting' localhost:5503/api/v1/shutdown -{ "msg": "server shutting down" } +**Shutdown server:** +```bash +curl -X POST -d 'Server maintenance' localhost:5503/api/v1/shutdown ``` diff --git a/api.yaml b/api.yaml new file mode 100644 index 0000000..fe6060d --- /dev/null +++ b/api.yaml @@ -0,0 +1,278 @@ +openapi: 3.0.3 +info: + title: Mobius API + description: REST API for managing a Hotline server + version: 1.0.0 + contact: + name: Mobius + url: https://github.com/jhalter/mobius +servers: + - url: http://localhost:5603 + description: Local development server +security: + - ApiKeyAuth: [] +paths: + /api/v1/online: + get: + summary: Get online users + description: Returns a list of currently online users with their login, nickname, and IP address + responses: + '200': + description: List of online users + content: + application/json: + schema: + type: array + items: + type: object + properties: + login: + type: string + description: User's login name + nickname: + type: string + description: User's display nickname + ip: + type: string + description: User's IP address + example: + login: "admin" + nickname: "Administrator" + ip: "192.168.1.100" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/ban: + post: + summary: Ban a user + description: Ban a user by username, nickname, or IP address. At least one field must be provided. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username to ban + nickname: + type: string + description: Nickname to ban + ip: + type: string + description: IP address to ban + example: + username: "baduser" + responses: + '200': + description: User banned successfully + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "banned" + '400': + description: Bad request - missing required fields + content: + text/plain: + schema: + type: string + example: "username, nickname, or ip required" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/unban: + post: + summary: Unban a user + description: Remove a ban for a user by username, nickname, or IP address. At least one field must be provided. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + username: + type: string + description: Username to unban + nickname: + type: string + description: Nickname to unban + ip: + type: string + description: IP address to unban + example: + username: "baduser" + responses: + '200': + description: User unbanned successfully + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "unbanned" + '400': + description: Bad request - missing required fields + content: + text/plain: + schema: + type: string + example: "username, nickname, or ip required" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/banned/ips: + get: + summary: List banned IP addresses + description: Returns a list of all banned IP addresses + responses: + '200': + description: List of banned IP addresses + content: + application/json: + schema: + type: array + items: + type: string + example: ["192.168.1.100", "10.0.0.5"] + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to fetch banned IPs" + /api/v1/banned/usernames: + get: + summary: List banned usernames + description: Returns a list of all banned usernames + responses: + '200': + description: List of banned usernames + content: + application/json: + schema: + type: array + items: + type: string + example: ["baduser", "spammer"] + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to fetch banned usernames" + /api/v1/banned/nicknames: + get: + summary: List banned nicknames + description: Returns a list of all banned nicknames + responses: + '200': + description: List of banned nicknames + content: + application/json: + schema: + type: array + items: + type: string + example: ["BadNick", "Spammer123"] + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to fetch banned nicknames" + /api/v1/reload: + post: + summary: Reload server configuration + description: Triggers a reload of the server configuration + responses: + '200': + description: Configuration reloaded successfully + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "config reloaded" + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/shutdown: + post: + summary: Shutdown server + description: Gracefully shutdown the server with a message + requestBody: + required: true + content: + text/plain: + schema: + type: string + example: "Server maintenance" + responses: + '200': + description: Server shutting down + content: + application/json: + schema: + type: object + properties: + msg: + type: string + example: "server shutting down" + '400': + description: Bad request - missing shutdown message + '401': + $ref: '#/components/responses/Unauthorized' + /api/v1/stats: + get: + summary: Get server statistics + description: Returns current server statistics and metrics + responses: + '200': + description: Server statistics + content: + application/json: + schema: + type: object + description: Server statistics object (structure depends on implementation) + '401': + $ref: '#/components/responses/Unauthorized' + '500': + description: Internal server error + content: + text/plain: + schema: + type: string + example: "failed to marshal stats" +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-API-Key + description: API key for authentication + responses: + Unauthorized: + description: Authentication required + content: + application/json: + schema: + type: object + properties: + error: + type: string + example: "unauthorized"
\ No newline at end of file diff --git a/internal/mobius/api.go b/internal/mobius/api.go index f912f60..2bce8f8 100644 --- a/internal/mobius/api.go +++ b/internal/mobius/api.go @@ -34,6 +34,8 @@ func (lrw *logResponseWriter) Write(b []byte) (int, error) { return lrw.ResponseWriter.Write(b) } +// APIServer provides REST API endpoints for managing a Hotline server. +// It supports user management, banning operations, and server administration. type APIServer struct { hlServer *hotline.Server logger *slog.Logger @@ -61,6 +63,8 @@ func (srv *APIServer) logMiddleware(next http.Handler) http.Handler { }) } +// NewAPIServer creates a new APIServer instance with the specified configuration. +// It sets up all API routes and middleware, and optionally connects to Redis for persistent storage. func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logger, apiKey string, redisAddr string, redisPassword string, redisDB int) *APIServer { srv := APIServer{ hlServer: hlServer, @@ -98,6 +102,8 @@ func NewAPIServer(hlServer *hotline.Server, reloadFunc func(), logger *slog.Logg return &srv } +// OnlineHandler returns a list of currently online users with their login, nickname, and IP address. +// GET /api/v1/online func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) { var users []map[string]string @@ -128,12 +134,17 @@ func (srv *APIServer) OnlineHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(users) } +// BanRequest represents a ban/unban request payload. +// At least one field (Username, Nickname, or IP) must be provided. type BanRequest struct { Username string `json:"username,omitempty"` Nickname string `json:"nickname,omitempty"` IP string `json:"ip,omitempty"` } +// BanHandler bans a user by username, nickname, or IP address. +// The user will be disconnected if currently online and added to the ban list. +// POST /api/v1/ban func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) { var req BanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -172,6 +183,8 @@ func (srv *APIServer) BanHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"msg":"banned"}`)) } +// UnbanHandler removes a ban for a user by username, nickname, or IP address. +// POST /api/v1/unban func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { var req BanRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -201,6 +214,8 @@ func (srv *APIServer) UnbanHandler(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(`{"msg":"unbanned"}`)) } +// ListBannedIPsHandler returns a list of all banned IP addresses. +// GET /api/v1/banned/ips func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Request) { if srv.redis != nil { ips, err := srv.redis.SMembers(r.Context(), "mobius:banned:ips").Result() @@ -214,6 +229,8 @@ func (srv *APIServer) ListBannedIPsHandler(w http.ResponseWriter, r *http.Reques } } +// ListBannedUsernamesHandler returns a list of all banned usernames. +// GET /api/v1/banned/usernames func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http.Request) { if srv.redis != nil { users, err := srv.redis.SMembers(r.Context(), "mobius:banned:users").Result() @@ -227,6 +244,8 @@ func (srv *APIServer) ListBannedUsernamesHandler(w http.ResponseWriter, r *http. } } +// ListBannedNicknamesHandler returns a list of all banned nicknames. +// GET /api/v1/banned/nicknames func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http.Request) { if srv.redis != nil { nicks, err := srv.redis.SMembers(r.Context(), "mobius:banned:nicknames").Result() @@ -240,6 +259,9 @@ func (srv *APIServer) ListBannedNicknamesHandler(w http.ResponseWriter, r *http. } } +// ShutdownHandler gracefully shuts down the server with a custom message. +// The message is sent to all connected clients before shutdown. +// POST /api/v1/shutdown func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) { msg, err := io.ReadAll(r.Body) if err != nil || len(msg) == 0 { @@ -252,6 +274,8 @@ func (srv *APIServer) ShutdownHandler(w http.ResponseWriter, r *http.Request) { _, _ = io.WriteString(w, `{ "msg": "server shutting down" }`) } +// ReloadHandler triggers a reload of the server configuration. +// POST /api/v1/reload func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWriter, _ *http.Request) { return func(w http.ResponseWriter, _ *http.Request) { reloadFunc() @@ -260,6 +284,8 @@ func (srv *APIServer) ReloadHandler(reloadFunc func()) func(w http.ResponseWrite } } +// RenderStats returns current server statistics and metrics in JSON format. +// GET /api/v1/stats func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) { u, err := json.Marshal(srv.hlServer.CurrentStats()) if err != nil { @@ -270,6 +296,8 @@ func (srv *APIServer) RenderStats(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(u) } +// Serve starts the API server on the specified port. +// This is a blocking call that will run until the server is shut down. func (srv *APIServer) Serve(port string) { err := http.ListenAndServe(port, srv.mux) if err != nil { |