Skip to content
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
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,54 @@ Shows:
- Temperature history graph with threshold lines
- Active workload hints

## API Security

The API binds `0.0.0.0` by default (required for container/bridge networking), so
the dashboard and API are reachable from every host on your LAN. Fan control is
protected by a **bearer token**, not by the bind address.

- **Mutating endpoints** — `POST`/`DELETE /api/override` and `POST /api/hint`,
`DELETE /api/hint/:source` — require the token.
- **Read-only endpoints** — `/api/status`, `/api/history`, `/api/config`, and the
dashboard — stay open.

Set the token via `api.token` in the config (or the `API_TOKEN` env var), then
send it as an `Authorization: Bearer <token>` header:

```bash
curl -X POST http://localhost:8086/api/override \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"speed": 50, "duration": 300, "reason": "testing"}'
```

`scripts/hint-client.sh` reads `API_TOKEN` from the environment and adds the
header automatically.

**If no token is configured**, mutating endpoints are accepted **only from the
local host (loopback)** and a warning is logged at startup. This keeps a
single-host setup convenient without silently exposing fan control to the LAN.
Loopback is determined from the real connection peer — a spoofed
`X-Forwarded-For` cannot bypass it.

There is no built-in TLS. To expose the controller beyond a trusted LAN, run it
behind a reverse proxy (nginx / Caddy / Traefik) that terminates TLS.

> **Upgrading from an earlier version?** No breaking changes: add `api.token` to
> your config (or set `API_TOKEN`) to enable off-host control. Existing
> docker-compose / Unraid deployments keep working unchanged — without a token
> they simply restrict control to loopback.

Other safety behavior worth knowing:

- Manual overrides are **clamped** to the configured `min_speed`/`max_speed`
band and **capped at 24 hours** (an override with `duration: 0` is treated as
24h, never truly indefinite). A critical temperature still ramps fans to max,
overriding any manual override.
- Hint `source`/`type` are restricted to `[A-Za-z0-9_.-]` (max 64 chars),
`intensity` to `low`/`medium`/`high`, and `action` to `start`/`stop`;
everything else is rejected with `400`.

## API Endpoints

### GET /api/status
Expand Down Expand Up @@ -205,6 +253,7 @@ All config options can be overridden via environment variables:
| `FAN_GPU_THRESHOLD` | GPU temp threshold (°C) | 60 |
| `CHECK_INTERVAL` | Seconds between checks | 10 |
| `API_PORT` | API/Dashboard port | 8086 |
| `API_TOKEN` | Bearer token for mutating endpoints | - (loopback-only) |

## Unraid Installation

Expand Down
5 changes: 5 additions & 0 deletions cmd/controller/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@ func applyEnvOverrides(cfg *config.Config) {
cfg.API.Port = i
}
}

// API bearer token for mutating endpoints
if v := os.Getenv("API_TOKEN"); v != "" {
cfg.API.Token = v
}
}

func main() {
Expand Down
15 changes: 15 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,23 @@ fan_control:
cooldown_delay: 60 # Seconds below threshold before ramping down

api:
# host 0.0.0.0 binds every interface — REQUIRED for container/bridge
# networking, but it means the API/dashboard is reachable from every host on
# your LAN. The control surface is protected by the token below, NOT by the
# bind address. There is no built-in TLS; if you expose this beyond a trusted
# LAN, put it behind a reverse proxy (nginx/Caddy/Traefik) that terminates TLS.
host: "0.0.0.0"
port: 8086
# Bearer token required on mutating endpoints (POST/DELETE /api/override and
# /api/hint). Read-only endpoints (/api/status, /api/history, /api/config) and
# the dashboard stay open. Env override: API_TOKEN.
#
# If left empty, mutating endpoints are accepted ONLY from the local host
# (loopback) and a warning is logged at startup — convenient for a single-host
# setup, but it means no other LAN host can change fan speed. Set a token to
# control fans from another machine (and pass it to scripts/hint-client.sh via
# the API_TOKEN env var).
token: ""

dashboard:
enabled: true
Expand Down
152 changes: 141 additions & 11 deletions internal/api/server.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
package api

import (
"crypto/subtle"
"embed"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"regexp"
"strconv"
"strings"
"time"

"github.com/gin-gonic/gin"
Expand All @@ -14,6 +19,21 @@ import (
"github.com/sethpjohnson/only-fan-controller/internal/storage"
)

// maxHintFieldLen bounds free-form hint identifiers (source/type). Kept small:
// these are process names, not prose.
const maxHintFieldLen = 64

// hintFieldPattern is the allowed charset for hint source/type. Restricting to
// this set means no server-derived hint string can carry HTML/script even if a
// dashboard interpolation is ever missed.
var hintFieldPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)

// allowedIntensities is the closed set of intensity values AddHint understands.
var allowedIntensities = map[string]bool{"": true, "low": true, "medium": true, "high": true}

// allowedHintActions is the closed set of hint actions the controller acts on.
var allowedHintActions = map[string]bool{"start": true, "stop": true}

//go:embed static/*
var staticFiles embed.FS

Expand Down Expand Up @@ -50,6 +70,11 @@ func NewServer(cfg *config.Config, ctrl *controller.FanController, store *storag
router: router,
}

if cfg.API.Token == "" {
log.Println("WARNING: no api.token configured (env API_TOKEN); mutating endpoints " +
"(override/hint) are restricted to loopback only. Set a token to control fans from other LAN hosts.")
}

s.setupRoutes()
return s
}
Expand All @@ -58,13 +83,20 @@ func (s *Server) setupRoutes() {
// API routes
api := s.router.Group("/api")
{
// Read-only endpoints stay open: they expose no control surface.
api.GET("/status", s.handleStatus)
api.GET("/history", s.handleHistory)
api.POST("/hint", s.handleHint)
api.DELETE("/hint/:source", s.handleRemoveHint)
api.POST("/override", s.handleOverride)
api.DELETE("/override", s.handleClearOverride)
api.GET("/config", s.handleGetConfig)

// Mutating endpoints are gated by requireAuth (bearer token, or loopback
// when no token is configured).
mutate := api.Group("", s.requireAuth())
{
mutate.POST("/hint", s.handleHint)
mutate.DELETE("/hint/:source", s.handleRemoveHint)
mutate.POST("/override", s.handleOverride)
mutate.DELETE("/override", s.handleClearOverride)
}
}

// Dashboard static files
Expand All @@ -84,6 +116,99 @@ func (s *Server) Run() error {
return s.router.Run(addr)
}

// requireAuth guards the mutating endpoints.
//
// - When a token is configured, the request must carry a matching
// "Authorization: Bearer <token>" header (compared in constant time). Any
// other case is 401.
// - When no token is configured, only requests whose connection peer is a
// loopback address are accepted (403 otherwise). This preserves single-user,
// same-host convenience without silently exposing fan control to the LAN.
//
// Loopback is decided from the real connection peer (c.Request.RemoteAddr), NOT
// from X-Forwarded-For / X-Real-IP: those are client-supplied and trivially
// spoofable, so trusting them would defeat the check.
func (s *Server) requireAuth() gin.HandlerFunc {
return func(c *gin.Context) {
token := s.cfg.API.Token

if token == "" {
if !isLoopbackAddr(c.Request.RemoteAddr) {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "mutating endpoints require a loopback connection or a configured api.token",
})
return
}
c.Next()
return
}

if !bearerTokenMatches(c.GetHeader("Authorization"), token) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "missing or invalid bearer token",
})
return
}
c.Next()
}
}

// isLoopbackAddr reports whether a net.Conn RemoteAddr string ("host:port")
// refers to a loopback address.
func isLoopbackAddr(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
// RemoteAddr is normally host:port; fall back to treating the whole
// string as a bare host so an unexpected format fails closed via ParseIP.
host = remoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}

// bearerTokenMatches reports whether an Authorization header carries a bearer
// token equal to expected. The comparison is constant-time to avoid leaking the
// token via response timing.
func bearerTokenMatches(header, expected string) bool {
got, ok := strings.CutPrefix(header, "Bearer ")
if !ok {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(expected)) == 1
}

// validateHintRequest enforces length, charset, and closed-set bounds on the
// client-controlled hint fields before they are stored or echoed back. This is
// the server-side half of the XSS defense (the dashboard escapes on render).
func validateHintRequest(req *HintRequest) error {
if err := validateHintField("source", req.Source); err != nil {
return err
}
if err := validateHintField("type", req.Type); err != nil {
return err
}
if !allowedHintActions[req.Action] {
return fmt.Errorf("action must be one of start, stop")
}
if !allowedIntensities[req.Intensity] {
return fmt.Errorf("intensity must be one of low, medium, high")
}
if req.DurationEstimate < 0 {
return fmt.Errorf("duration_estimate must not be negative")
}
return nil
}

func validateHintField(name, value string) error {
if len(value) > maxHintFieldLen {
return fmt.Errorf("%s exceeds %d characters", name, maxHintFieldLen)
}
if !hintFieldPattern.MatchString(value) {
return fmt.Errorf("%s must match [A-Za-z0-9_.-]", name)
}
return nil
}

// GET /api/status
func (s *Server) handleStatus(c *gin.Context) {
status := s.ctrl.GetStatus()
Expand Down Expand Up @@ -119,6 +244,11 @@ func (s *Server) handleHint(c *gin.Context) {
return
}

if err := validateHintRequest(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if req.Action == "stop" {
s.ctrl.RemoveHint(req.Source)
c.JSON(http.StatusOK, gin.H{"status": "hint removed", "source": req.Source})
Expand Down Expand Up @@ -162,7 +292,7 @@ func (s *Server) handleOverride(c *gin.Context) {

duration := time.Duration(req.Duration) * time.Second
s.ctrl.SetOverride(req.Speed, duration, req.Reason)

c.JSON(http.StatusOK, gin.H{
"status": "override set",
"speed": req.Speed,
Expand All @@ -180,11 +310,11 @@ func (s *Server) handleClearOverride(c *gin.Context) {
func (s *Server) handleGetConfig(c *gin.Context) {
// Return sanitized config (no passwords)
c.JSON(http.StatusOK, gin.H{
"idrac_host": s.cfg.IDRAC.Host,
"gpu_enabled": s.cfg.GPU.Enabled,
"interval": s.cfg.Monitoring.Interval,
"zones": s.cfg.Zones,
"fan_control": s.cfg.FanControl,
"api_port": s.cfg.API.Port,
"idrac_host": s.cfg.IDRAC.Host,
"gpu_enabled": s.cfg.GPU.Enabled,
"interval": s.cfg.Monitoring.Interval,
"zones": s.cfg.Zones,
"fan_control": s.cfg.FanControl,
"api_port": s.cfg.API.Port,
})
}
Loading