aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md87
-rw-r--r--api.yaml278
-rw-r--r--internal/mobius/api.go28
3 files changed, 355 insertions, 38 deletions
diff --git a/README.md b/README.md
index 2c9772b..9e7e46f 100644
--- a/README.md
+++ b/README.md
@@ -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 {