Skip to content
Closed
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
19 changes: 19 additions & 0 deletions docker/dashboard-auth/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
FROM python:3.12-slim

ENV PYTHONUNBUFFERED=1 \
HERMES_DASHBOARD_AUTH_SMOKE=1

WORKDIR /app

RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates nodejs npm \
&& rm -rf /var/lib/apt/lists/*

COPY . /app

RUN python -m pip install --upgrade pip \
&& python -m pip install -e '.[web,pty]'

RUN cd web && npm ci && npm run build

EXPOSE 9119
88 changes: 88 additions & 0 deletions docker/dashboard-auth/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
services:
hermes-none:
build:
context: ../..
dockerfile: docker/dashboard-auth/Dockerfile
command: hermes dashboard --host 0.0.0.0 --port 9119 --no-open --auth none --insecure
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9119/api/auth/status"]
interval: 2s
timeout: 2s
retries: 30

hermes-token:
build:
context: ../..
dockerfile: docker/dashboard-auth/Dockerfile
command: hermes dashboard --host 0.0.0.0 --port 9119 --no-open --auth token --auth-token docker-token
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9119/api/auth/status"]
interval: 2s
timeout: 2s
retries: 30

hermes-password:
build:
context: ../..
dockerfile: docker/dashboard-auth/Dockerfile
command: hermes dashboard --host 0.0.0.0 --port 9119 --no-open --auth password --auth-password docker-password
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9119/api/auth/status"]
interval: 2s
timeout: 2s
retries: 30

hermes-trusted-proxy:
build:
context: ../..
dockerfile: docker/dashboard-auth/Dockerfile
command: hermes dashboard --host 0.0.0.0 --port 9119 --no-open --auth trusted-proxy --trusted-proxy-user-header X-Forwarded-User
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9119/api/auth/status"]
interval: 2s
timeout: 2s
retries: 30

hermes-tailscale:
build:
context: ../..
dockerfile: docker/dashboard-auth/Dockerfile
command: hermes dashboard --host 0.0.0.0 --port 9119 --no-open --auth tailscale --tailscale-user-header Tailscale-User-Login
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:9119/api/auth/status"]
interval: 2s
timeout: 2s
retries: 30

trusted-proxy:
image: nginx:1.27-alpine
volumes:
- ./nginx-trusted-proxy.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
hermes-trusted-proxy:
condition: service_healthy

tailscale-mock:
image: nginx:1.27-alpine
volumes:
- ./nginx-tailscale-mock.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
hermes-tailscale:
condition: service_healthy

test-runner:
build:
context: ../..
dockerfile: docker/dashboard-auth/Dockerfile
command: python docker/dashboard-auth/smoke.py
depends_on:
hermes-none:
condition: service_healthy
hermes-token:
condition: service_healthy
hermes-password:
condition: service_healthy
trusted-proxy:
condition: service_started
tailscale-mock:
condition: service_started
9 changes: 9 additions & 0 deletions docker/dashboard-auth/nginx-tailscale-mock.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
server {
listen 8080;
location / {
proxy_set_header Host $host;
proxy_set_header Tailscale-User-Login docker-user@example.test;
proxy_set_header Tailscale-User-Name "Docker User";
proxy_pass http://hermes-tailscale:9119;
}
}
9 changes: 9 additions & 0 deletions docker/dashboard-auth/nginx-trusted-proxy.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
server {
listen 8080;
location / {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-User docker-user;
proxy_set_header X-Forwarded-Email docker-user@example.test;
proxy_pass http://hermes-trusted-proxy:9119;
}
}
98 changes: 98 additions & 0 deletions docker/dashboard-auth/smoke.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
from __future__ import annotations

import json
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse


DOCKER_TOKEN = "docker-token" # nosec B105
DOCKER_PASSWORD = "docker-password" # nosec B105


def require(condition: bool, detail: object) -> None:
if not condition:
raise RuntimeError(str(detail))


def request(method: str, url: str, body: dict | None = None, headers: dict | None = None) -> tuple[int, dict]:
parsed = urlparse(url)
if parsed.scheme != "http" or not parsed.netloc:
raise ValueError(f"unexpected smoke-test URL: {url}")
data = None if body is None else json.dumps(body).encode()
req = urllib.request.Request(url, data=data, method=method, headers=headers or {})
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=5) as res: # nosec B310
return res.status, json.loads(res.read().decode() or "{}")
except urllib.error.HTTPError as exc:
try:
payload = json.loads(exc.read().decode() or "{}")
except Exception:
payload = {"detail": str(exc)}
return exc.code, payload


def wait_status(url: str) -> dict:
last_error: object = None
for _ in range(60):
try:
code, data = request("GET", url)
if code == 200:
return data
last_error = (code, data)
except urllib.error.URLError as exc:
last_error = exc
time.sleep(1)
raise RuntimeError(f"not healthy: {url}: {last_error}")


def assert_status_auth(url: str, mode: str, required: bool):
data = wait_status(f"{url}/api/auth/status")
require(data["mode"] == mode, data)
require(data["required"] is required, data)


def main() -> None:
assert_status_auth("http://hermes-none:9119", "none", False)
code, _ = request("GET", "http://hermes-none:9119/api/sessions")
require(code == 200, code)

code, token_status = request("GET", "http://hermes-token:9119/api/auth/status")
require(code == 200 and token_status["mode"] == "token" and token_status["required"] is True, (code, token_status))
code, _ = request("GET", "http://hermes-token:9119/api/sessions")
require(code == 401, code)
code, bad_login = request("POST", "http://hermes-token:9119/api/auth/login", {"token": "wrong-token"})
require(code == 401 and not bad_login.get("session_token"), (code, bad_login))
code, login = request("POST", "http://hermes-token:9119/api/auth/login", {"token": DOCKER_TOKEN})
require(code == 200 and bool(login.get("session_token")), (code, login))
code, _ = request("GET", "http://hermes-token:9119/api/sessions", headers={"X-Hermes-Dashboard-Session": login["session_token"]})
require(code == 200, code)
code, _ = request("POST", "http://hermes-token:9119/api/auth/logout", headers={"X-Hermes-Dashboard-Session": login["session_token"]})
require(code == 200, code)
code, _ = request("GET", "http://hermes-token:9119/api/sessions", headers={"X-Hermes-Dashboard-Session": login["session_token"]})
require(code == 401, code)

code, password_status = request("GET", "http://hermes-password:9119/api/auth/status")
require(code == 200 and password_status["mode"] == "password" and password_status["required"] is True, (code, password_status))
code, bad_login = request("POST", "http://hermes-password:9119/api/auth/login", {"password": "wrong-password"})
require(code == 401 and not bad_login.get("session_token"), (code, bad_login))
code, login = request("POST", "http://hermes-password:9119/api/auth/login", {"password": DOCKER_PASSWORD})
require(code == 200 and bool(login.get("session_token")), (code, login))

code, direct_proxy_status = request("GET", "http://hermes-trusted-proxy:9119/api/auth/status")
require(code == 200 and direct_proxy_status["authenticated"] is False, (code, direct_proxy_status))
code, status = request("GET", "http://trusted-proxy:8080/api/auth/status")
require(code == 200 and status["authenticated"] is True and status["identity"]["user"] == "docker-user", (code, status))

code, direct_tailscale_status = request("GET", "http://hermes-tailscale:9119/api/auth/status")
require(code == 200 and direct_tailscale_status["authenticated"] is False, (code, direct_tailscale_status))
code, status = request("GET", "http://tailscale-mock:8080/api/auth/status")
require(code == 200 and status["authenticated"] is True and status["identity"]["user"] == "docker-user@example.test", (code, status))
print("dashboard auth docker smoke passed")


if __name__ == "__main__":
main()
27 changes: 27 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -958,6 +958,33 @@ def _ensure_hermes_home_managed(home: Path):
# Web dashboard settings
"dashboard": {
"theme": "default", # Dashboard visual theme: "default", "midnight", "ember", "mono", "cyberpunk", "rose"
"auth": {
"mode": "none", # none | token | password | trusted-proxy | tailscale
"token": None,
"password_hash": None,
"password": None,
"session_ttl_seconds": 28800,
"allow_public_without_auth": False,
"rate_limit": {
"max_attempts": 10,
"window_seconds": 60,
"lockout_seconds": 300,
},
"trusted_proxy": {
"user_header": "X-Forwarded-User",
"email_header": "X-Forwarded-Email",
"name_header": "X-Forwarded-Name",
"allowed_users": [],
"allowed_emails": [],
"require_identity": True,
},
"tailscale": {
"user_header": "Tailscale-User-Login",
"name_header": "Tailscale-User-Name",
"profile_pic_header": "Tailscale-User-Profile-Pic",
"allowed_users": [],
},
},
},

# Privacy settings
Expand Down
Loading