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
7 changes: 6 additions & 1 deletion apps/rotom-ng-ui/src/app/app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import "react-toastify/dist/ReactToastify.css";

import {
AuthGate,
ControllersPage,
createAppQueryClient,
DevicePage,
Expand Down Expand Up @@ -63,7 +64,11 @@ function AppContent() {
export function App() {
return (
<QueryClientProvider client={queryClient}>
<AppContent />
{/* Outside AppContent so its polling queries never mount — and never
fire a burst of 401s — before there is a session. */}
<AuthGate appName="RotomNG" appIcon={rotomNgIcon}>
<AppContent />
</AuthGate>
<ToastContainer theme="dark" />
</QueryClientProvider>
);
Expand Down
2 changes: 2 additions & 0 deletions apps/rotom-ng/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,7 @@ func (a *App) Init() error {
}

a.httpAuthMiddleware = auth.NewMiddleware(a.cfg.HTTPListener.Secret)
a.httpAuthMiddleware.SetSessionTTL(a.cfg.HTTPListener.UISessionTTL)
a.apiHandlerConfig = handlers.APIHandlerConfig[*Controller, *MITMWorker]{
Logger: a.logger.With(slog.String("component", "api")),
ConnectionManager: a.connectionManager,
Expand Down Expand Up @@ -525,6 +526,7 @@ func (a *App) reload() error {
a.controllerAuthMiddleware.SetSecret(cfg.ControllerListener.Secret)
a.deviceAuthMiddleware.SetSecret(cfg.DeviceListener.Secret)
a.httpAuthMiddleware.SetSecret(cfg.HTTPListener.Secret)
a.httpAuthMiddleware.SetSessionTTL(cfg.HTTPListener.UISessionTTL)

a.setShutdownTimeout(cfg.ShutdownTimeout)

Expand Down
12 changes: 12 additions & 0 deletions apps/rotom-ng/app/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ const (
// controller connection that receives no data message within this period is
// considered dead, independent of ping/pong keep-alive activity.
DefaultControllerDataTimeout = 2 * time.Minute

// DefaultUISessionTTL is how long a web UI login lasts when
// http_listener.ui_session_ttl is not set: one day. Written as 24h because
// Go duration syntax has no day unit.
DefaultUISessionTTL = 24 * time.Hour
)

// DeviceListener holds configuration for the device WebSocket listener.
Expand Down Expand Up @@ -71,6 +76,10 @@ type HTTPListener struct {
Address string `koanf:"address"`
Listener net.Listener `koanf:"-"`
Secret string `koanf:"secret"`
// UISessionTTL is how long a web UI login stays valid (e.g. "30m", "12h").
// Defaults to DefaultUISessionTTL when unset or <= 0. Only relevant when
// Secret is set, since without a secret the UI never logs in.
UISessionTTL time.Duration `koanf:"ui_session_ttl"`
}

// Tuning holds performance tuning options.
Expand Down Expand Up @@ -201,6 +210,9 @@ func (cfg *Config) SetDefaults() {
if cfg.HTTPListener.Address == "" {
cfg.HTTPListener.Address = DefaultHTTPAddress
}
if cfg.HTTPListener.UISessionTTL <= 0 {
cfg.HTTPListener.UISessionTTL = DefaultUISessionTTL
}

// Initialize logging config if nil
if cfg.Logging == nil {
Expand Down
10 changes: 10 additions & 0 deletions apps/rotom-ng/app/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ secret = "test-controller-secret"
[http_listener]
address = ":8082"
secret = "test-api-secret"
ui_session_ttl = "90m"

[logging]
level = "debug"
Expand Down Expand Up @@ -76,6 +77,9 @@ compress = true
if cfg.HTTPListener.Secret != "test-api-secret" {
t.Errorf("Expected HTTPListener.Secret to be 'test-api-secret', got %s", cfg.HTTPListener.Secret)
}
if cfg.HTTPListener.UISessionTTL != 90*time.Minute {
t.Errorf("Expected HTTPListener.UISessionTTL to be 90m, got %v", cfg.HTTPListener.UISessionTTL)
}

// Verify global shutdown timeout
expectedTimeout := 45 * time.Second
Expand Down Expand Up @@ -335,6 +339,12 @@ func TestConfigSetDefaults(t *testing.T) {
if cfg.HTTPListener.Address != DefaultHTTPAddress {
t.Errorf("Expected default HTTPListener.Address to be '%s', got %s", DefaultHTTPAddress, cfg.HTTPListener.Address)
}
if cfg.HTTPListener.UISessionTTL != DefaultUISessionTTL {
t.Errorf("Expected default HTTPListener.UISessionTTL to be %v, got %v", DefaultUISessionTTL, cfg.HTTPListener.UISessionTTL)
}
if cfg.HTTPListener.UISessionTTL != 24*time.Hour {
t.Errorf("Expected the default UI session TTL to be one day, got %v", cfg.HTTPListener.UISessionTTL)
}

// Verify default shutdown timeout
if cfg.ShutdownTimeout != DefaultShutdownTimeout {
Expand Down
9 changes: 9 additions & 0 deletions configs/rotom-ng.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,15 @@ address = ":7071" # Default: ":7071"
[http_listener]
address = ":7072" # Default: ":7072"
# secret = "your-api-secret-here" # Optional authentication secret for API access
# API clients send it as the X-Rotom-Secret header.
# The web UI prompts for it and exchanges it for a
# session cookie, so the UI keeps working when set.
# Changing it signs out every active UI session.
# ui_session_ttl = "24h" # Default: "24h" (one day). How long a web UI login
# lasts before the operator must sign in again.
# Note: Go duration syntax has no day unit -- write
# "24h", not "1d". Applies to new logins only;
# shortening it does not cut short existing sessions.

# Rate limiting configuration (optional)
# Controls how frequently a single device's workers can be selected
Expand Down
84 changes: 84 additions & 0 deletions docs/RotomNG-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,90 @@ The HTTP API supports header-based authentication when configured. When a `secre
**Authentication Responses**:
- `401 Unauthorized`: Missing or invalid authentication header when authentication is required

A request is accepted if it carries **any one** of the following credentials:

| Credential | Intended for |
| --- | --- |
| `X-Rotom-Secret: <secret>` | Machine clients (Dragonite, scripts, Prometheus) |
| `Authorization: Bearer <token>` | Clients that prefer a short-lived token to a static secret |
| Session cookie + `X-Rotom-Session: 1` | The web UI |

#### Session Endpoints

These three endpoints are **not** gated by the auth middleware — they are how a
browser obtains a credential in the first place.

```http
GET /api/auth/me
POST /api/auth/login
POST /api/auth/logout
```

`GET /api/auth/me` reports what the caller needs to do:

```json
{ "status": "ok", "auth_required": true, "authenticated": false }
```

`POST /api/auth/login` exchanges the configured secret for a session:

```http
POST /api/auth/login
Content-Type: application/json

{ "secret": "your-api-secret-here" }
```

On success the response sets an `HttpOnly`, `SameSite=Strict` cookie holding a
signed HS256 token. The cookie is also flagged `Secure` when the request
arrives over TLS (directly, or via a proxy sending `X-Forwarded-Proto: https`).
A wrong secret returns `401`; if no secret is configured at all, login returns
`400`, since there is no session to create.

Notes on session tokens:

- **Rotation is revocation.** The token signing key is derived from the secret,
so changing `http_listener.secret` — including via config reload —
invalidates every outstanding session immediately. There is no other
revocation mechanism; tokens are stateless.
- **Session lifetime** defaults to one day and is set by
`http_listener.ui_session_ttl`:

```toml
[http_listener]
secret = "your-api-secret-here"
ui_session_ttl = "12h" # default "24h"
```

Go duration syntax has no day unit, so write `"24h"` rather than `"1d"` —
the latter fails to parse at startup. The value applies to sessions minted
after it takes effect: a token's expiry is signed into its claims, so
shortening the TTL does not retroactively end sessions already issued.
Rotating the secret is what does that.
- **The `X-Rotom-Session` header is required** on cookie-authenticated
requests. The cookie alone is deliberately not sufficient: requiring a custom
header means a cross-site form post cannot ride a logged-in operator's
session. Requests authenticated by `X-Rotom-Secret` or `Authorization` do not
need it.
- **Bearer tokens** are the same tokens the cookie carries, so a client can
call `/api/auth/login` and use the returned cookie value as a bearer token if
it would rather not hold the long-lived secret.
- **Login is not rate limited.** It is an unauthenticated endpoint, so use a
high-entropy secret, and put the listener behind a proxy that throttles if it
is exposed to untrusted networks. Failed attempts are logged at `WARN` with
the client IP.

#### Web UI

The UI signs in through these endpoints. When a secret is configured it shows a
sign-in form; when one is not, it loads straight through as before. Because the
token lives in an `HttpOnly` cookie, the secret is never stored anywhere page
JavaScript can read it.

Operators fronting Rotom with a reverse proxy can skip the UI login entirely by
having the proxy inject `X-Rotom-Secret` on `/api`, and handle authentication
themselves.

### Configuration Endpoints

#### Get Configuration
Expand Down
132 changes: 119 additions & 13 deletions libs/auth/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,40 +4,146 @@ package auth
import (
"crypto/subtle"
"net/http"
"strings"
"sync/atomic"
"time"

"github.com/gin-gonic/gin"
)

const bearerPrefix = "Bearer "

// Middleware validates requests using a shared secret header.
type Middleware struct {
secret atomic.Pointer[string]
// sessionTTL is stored as an int64 nanosecond count so config reload can
// change it without racing an in-flight login. Zero means DefaultSessionTTL.
sessionTTLNanos atomic.Int64
}

// NewMiddleware creates a gin middleware that checks for authentication
// via the X-Rotom-Secret header.
// Returns 401 Unauthorized if the header doesn't match the expected secret value.
// NewMiddleware creates a gin middleware that checks for authentication.
//
// A request is accepted when it carries any one of:
// - the X-Rotom-Secret header matching the configured secret (machine clients)
// - an Authorization: Bearer header holding a valid session token
// - the session cookie holding a valid token, plus the X-Rotom-Session header
//
// Returns 401 Unauthorized otherwise. When no secret is configured, every
// request is allowed through.
func NewMiddleware(expectedSecret string) *Middleware {
mw := &Middleware{}
mw.secret.Store(&expectedSecret)
return mw
}

// Handler is a gin middleware that checks the X-Rotom-Secret header.
// Handler is a gin middleware that authenticates the request.
func (mw *Middleware) Handler(ginContext *gin.Context) {
expectedSecret := mw.secret.Load()
if expectedSecret != nil && *expectedSecret != "" {
providedSecret := ginContext.GetHeader("X-Rotom-Secret")
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(*expectedSecret)) != 1 {
ginContext.Status(http.StatusUnauthorized)
ginContext.Abort()
return
}
secret := mw.currentSecret()
if secret == "" {
ginContext.Next()
return
}
if !mw.authenticate(ginContext, secret) {
ginContext.Status(http.StatusUnauthorized)
ginContext.Abort()
return
}
ginContext.Next()
}

// SetSecret updates the expected secret value atomically.
// SetSessionTTL sets how long newly minted UI sessions stay valid. Values <= 0
// select DefaultSessionTTL.
//
// Changing this affects only sessions minted afterwards: a token's expiry is
// baked into its signed claims, so shortening the TTL does not retroactively
// cut short sessions already issued. Rotating the secret is what ends those.
func (mw *Middleware) SetSessionTTL(ttl time.Duration) {
if ttl < 0 {
ttl = 0
}
mw.sessionTTLNanos.Store(int64(ttl))
}

// SessionTTL returns the lifetime applied to newly minted sessions.
func (mw *Middleware) SessionTTL() time.Duration {
if ttl := time.Duration(mw.sessionTTLNanos.Load()); ttl > 0 {
return ttl
}
return DefaultSessionTTL
}

// SetSecret updates the expected secret value atomically. Rotating the secret
// also invalidates every outstanding session token, since the token signing
// key is derived from it.
func (mw *Middleware) SetSecret(secret string) {
mw.secret.Store(&secret)
}

// Enabled reports whether a secret is configured, and therefore whether
// clients need to authenticate at all.
func (mw *Middleware) Enabled() bool {
return mw.currentSecret() != ""
}

// CheckSecret reports whether provided matches the configured secret. It
// returns false when no secret is configured, so a login attempt cannot mint a
// token on an unauthenticated instance.
func (mw *Middleware) CheckSecret(provided string) bool {
secret := mw.currentSecret()
if secret == "" {
return false
}
return secretsEqual(provided, secret)
}

// MintSessionToken issues a session token bound to the current secret.
func (mw *Middleware) MintSessionToken(ttl time.Duration) (string, error) {
return MintToken(mw.currentSecret(), time.Now(), ttl)
}

// VerifySessionToken checks a token against the current secret.
func (mw *Middleware) VerifySessionToken(token string) error {
_, err := VerifyToken(mw.currentSecret(), token, time.Now())
return err
}

// authenticate reports whether the request carries any acceptable credential.
func (mw *Middleware) authenticate(ginContext *gin.Context, secret string) bool {
if secretsEqual(ginContext.GetHeader("X-Rotom-Secret"), secret) {
return true
}

authorization := ginContext.GetHeader("Authorization")
if token, found := strings.CutPrefix(authorization, bearerPrefix); found {
if _, err := VerifyToken(secret, token, time.Now()); err == nil {
return true
}
}

// Cookie credentials are only honoured on requests carrying the session
// header. The browser attaches the cookie automatically, so without this
// check a cross-site form post would ride an operator's live session; a
// custom header cannot be set cross-origin without a preflight we never
// answer.
if ginContext.GetHeader(SessionRequestHeader) == "" {
return false
}
cookie, err := ginContext.Cookie(SessionCookieName)
if err != nil || cookie == "" {
return false
}
_, err = VerifyToken(secret, cookie, time.Now())
return err == nil
}

func (mw *Middleware) currentSecret() string {
secret := mw.secret.Load()
if secret == nil {
return ""
}
return *secret
}

func secretsEqual(provided, expected string) bool {
return subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
}
Loading
Loading