diff --git a/CLAUDE.md b/CLAUDE.md index 23cdcb3..bebbbfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -139,8 +139,9 @@ The local `docker-compose.yml` is for development only. Production uses Quadlet | `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM | **ENV vars (all services read from docker-compose / Quadlet):** -- `API_URL`, `API_USER`, `API_PASS` — Shiny apps -- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH`, `INITIAL_USERS` — API +- `API_URL` — Shiny apps (no credentials needed — auth is per-session via cookie, see below) +- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH` — API +- `ENV`, `COOKIE_SECURE`, `PUBLIC_BASE_URL`, `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `SESSION_JWT_SECRET`, `ALLOWED_USERS` — API auth (see FastAPI section) - `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`) - `PORT` — listkeeper only (default 3838) @@ -150,7 +151,7 @@ The local `docker-compose.yml` is for development only. Production uses Quadlet Python 3.12, FastAPI + SQLAlchemy (raw SQL via `session.execute(text(...))`). No ORM queries — complex joins written by hand. -**Auth:** HTTP Basic. Users stored in `auth.users` (bcrypt). Seeded from `INITIAL_USERS` env var (`"user1:pass1,user2:pass2"`) on first startup. +**Auth:** Keycloak (running on the VM for the unrelated `gcnm` app too, exposed on its own subdomain `auth.friessn.de` with a dedicated `homekeeper` realm — see `infrastructure/group_vars/all.yml`) acts as OIDC identity provider. Login happens once, on the landing page (`/`), via `GET /v1/auth/login` → Keycloak OAuth2 → `GET /v1/auth/callback`. FastAPI verifies Keycloak's `id_token` (RS256, via its JWKS — everything driven off the standard `.well-known/openid-configuration` discovery doc, so the code isn't actually Keycloak-specific), checks the username against the `ALLOWED_USERS` allowlist (comma-separated env var — no DB table, stateless), then mints its own signed session JWT (`SESSION_JWT_SECRET`, HS256, 30-day expiry) and sets it as an `hk_session` cookie on the whole domain. Beekeeper/Listkeeper read that cookie from `session$request` and forward it as `Authorization: Bearer ` on every API call (see `get_session_token()` in each package's `api_client.R`). `get_current_user()` in `app/auth.py` accepts either the Bearer header or the cookie directly, so `Depends(get_current_user)` in routers is unchanged either way. Local dev has no real OIDC app to test against — `GET /v1/auth/dev-login?username=...` mints a session directly, but only when `ENV=development` (404s otherwise). **Adding an endpoint:** add a file to `app/routers/`, include it in `main.py`. All routes return `dict(r._mapping)` from raw SQL rows. @@ -180,20 +181,23 @@ Plain Shiny — no golem, no rhino. Each package exports one function: `run_app( ```r run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) { log_init() - api <- list( - base_url = Sys.getenv("API_URL", "http://localhost:8000"), - user = Sys.getenv("API_USER", "homestead"), - pass = Sys.getenv("API_PASS", "homestead") - ) server_fn <- function(input, output, session) { log_info("session_start", session_id = session$token) session$onSessionEnded(function() log_info("session_end", session_id = session$token)) + # api/db is built per-session (not module-level) — the auth token comes + # from the browser's hk_session cookie, so it differs per visitor. + api <- list( + base_url = Sys.getenv("API_URL", "http://localhost:8000"), + token = get_session_token(session) + ) server(input, output, session, db = api) } shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE) } ``` +If `db$token` is `NULL` (no cookie — user never logged in via the landing page), `server()` should render a "please log in" screen instead of the normal nav/content (see `server.R` in either package). + **Module pattern:** ```r screen_example_ui <- function(id) { @@ -303,6 +307,6 @@ Colony log: API endpoint `GET /v1/colonies/{id}/log` — UNION ALL across all ev List endpoint returns `item_count` and `checked_count` as aggregated columns (not from a separate query). -### `auth` — API users +### Auth -**`auth.users`**: `id, username, hashed_password, is_active, created_at` — bcrypt, seeded from `INITIAL_USERS` env var. +No DB table — auth is stateless. Identity comes from Keycloak (OIDC), authorization is an `ALLOWED_USERS` env var allowlist, and sessions are self-contained signed JWTs (`hk_session` cookie). See the FastAPI Auth section above. diff --git a/api/app/auth.py b/api/app/auth.py index 2ee7613..51deef6 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -1,67 +1,77 @@ import os -from fastapi import Depends, HTTPException, status -from fastapi.security import HTTPBasic, HTTPBasicCredentials -from passlib.context import CryptContext -from sqlalchemy import text +import time +from functools import lru_cache -security = HTTPBasic() -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +import httpx +from fastapi import HTTPException, Request, status +from jose import JWTError, jwt + +COOKIE_NAME = "hk_session" +SESSION_JWT_ALG = "HS256" +SESSION_TTL_SECONDS = 30 * 24 * 3600 # 30 days — low-traffic personal app, optimize for staying logged in + +SESSION_JWT_SECRET = os.getenv("SESSION_JWT_SECRET", "") +# Full issuer URL including realm, e.g. https://auth.friessn.de/realms/homekeeper (Keycloak) +OIDC_ISSUER_URL = os.getenv("OIDC_ISSUER_URL", "").rstrip("/") +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "") +OIDC_CLIENT_SECRET = os.getenv("OIDC_CLIENT_SECRET", "") -def verify_password(plain: str, hashed: str) -> bool: - return pwd_context.verify(plain, hashed) +def _allowed_users() -> set[str]: + return {u.strip() for u in os.getenv("ALLOWED_USERS", "").split(",") if u.strip()} -def hash_password(plain: str) -> str: - return pwd_context.hash(plain) +def is_allowed_user(username: str) -> bool: + return username in _allowed_users() -def get_current_user(credentials: HTTPBasicCredentials = Depends(security)): - from app.database import engine +def mint_session_jwt(username: str) -> str: + now = int(time.time()) + payload = {"sub": username, "iat": now, "exp": now + SESSION_TTL_SECONDS} + return jwt.encode(payload, SESSION_JWT_SECRET, algorithm=SESSION_JWT_ALG) - with engine.connect() as conn: - row = conn.execute( - text("SELECT hashed_password FROM auth.users WHERE username = :u"), - {"u": credentials.username}, - ).fetchone() - if row is None or not verify_password(credentials.password, row[0]): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid credentials", - headers={"WWW-Authenticate": "Basic"}, +def get_current_user(request: Request) -> str: + """Accepts either 'Authorization: Bearer ' (used by the Shiny apps) + or the hk_session cookie (used by same-origin browser fetches from the + landing page).""" + token = None + auth_header = request.headers.get("authorization", "") + if auth_header.lower().startswith("bearer "): + token = auth_header[7:] + if not token: + token = request.cookies.get(COOKIE_NAME) + if not token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + try: + payload = jwt.decode(token, SESSION_JWT_SECRET, algorithms=[SESSION_JWT_ALG]) + except JWTError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired session") + return payload["sub"] + + +@lru_cache(maxsize=1) +def oidc_config() -> dict: + resp = httpx.get(f"{OIDC_ISSUER_URL}/.well-known/openid-configuration", timeout=10) + resp.raise_for_status() + return resp.json() + + +@lru_cache(maxsize=1) +def _oidc_jwks() -> dict: + resp = httpx.get(oidc_config()["jwks_uri"], timeout=10) + resp.raise_for_status() + return resp.json() + + +def verify_oidc_id_token(id_token: str) -> dict: + try: + return jwt.decode( + id_token, + _oidc_jwks(), + algorithms=["RS256"], + audience=OIDC_CLIENT_ID, ) - return credentials.username - - -def seed_users(): - """Seed initial users from INITIAL_USERS env var if users table is empty.""" - from app.database import engine - - initial_users_env = os.getenv("INITIAL_USERS", "") - if not initial_users_env: - return - - with engine.connect() as conn: - count = conn.execute(text("SELECT COUNT(*) FROM auth.users")).scalar() - if count and count > 0: - return - - for pair in initial_users_env.split(","): - pair = pair.strip() - if ":" not in pair: - continue - username, password = pair.split(":", 1) - username = username.strip() - password = password.strip() - if not username or not password: - continue - hashed = hash_password(password) - conn.execute( - text( - "INSERT INTO auth.users (username, hashed_password) " - "VALUES (:u, :h) ON CONFLICT (username) DO NOTHING" - ), - {"u": username, "h": hashed}, - ) - conn.commit() + except JWTError as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid OIDC token: {e}") diff --git a/api/app/database.py b/api/app/database.py index f328568..1860218 100644 --- a/api/app/database.py +++ b/api/app/database.py @@ -21,20 +21,8 @@ def get_session(): def init_db(): - """Create schemas and tables, then seed initial users.""" - from app.auth import seed_users - + """Create schemas and tables.""" with engine.connect() as conn: - # Create auth schema and users table - conn.execute(text("CREATE SCHEMA IF NOT EXISTS auth")) - conn.execute(text(""" - CREATE TABLE IF NOT EXISTS auth.users ( - id SERIAL PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - hashed_password TEXT NOT NULL - ) - """)) - # Create lk schema and tables conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk")) conn.execute(text(""" @@ -239,5 +227,3 @@ def init_db(): pass conn.commit() - - seed_users() diff --git a/api/app/main.py b/api/app/main.py index 02f4141..cac36b5 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -4,6 +4,7 @@ from fastapi import FastAPI from app.routers import ( apiaries, + auth, colonies, inspections, varroa, @@ -29,6 +30,7 @@ app = FastAPI( lifespan=lifespan, ) +app.include_router(auth.router, prefix="/v1/auth", tags=["auth"]) app.include_router(apiaries.router, prefix="/v1/apiaries", tags=["apiaries"]) app.include_router(colonies.router, prefix="/v1/colonies", tags=["colonies"]) app.include_router(inspections.router, prefix="/v1/inspections", tags=["inspections"]) diff --git a/api/app/routers/auth.py b/api/app/routers/auth.py new file mode 100644 index 0000000..01ee3b7 --- /dev/null +++ b/api/app/routers/auth.py @@ -0,0 +1,116 @@ +import os +import secrets +from urllib.parse import urlencode + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import RedirectResponse + +from app.auth import ( + COOKIE_NAME, + OIDC_CLIENT_ID, + OIDC_CLIENT_SECRET, + SESSION_TTL_SECONDS, + get_current_user, + is_allowed_user, + mint_session_jwt, + oidc_config, + verify_oidc_id_token, +) + +router = APIRouter() + +ENV = os.getenv("ENV", "production") +COOKIE_SECURE = os.getenv("COOKIE_SECURE", "true").lower() == "true" +PUBLIC_BASE_URL = os.getenv("PUBLIC_BASE_URL", "http://localhost").rstrip("/") +STATE_COOKIE = "hk_oauth_state" +CALLBACK_PATH = "/api/v1/auth/callback" + + +def _set_session_cookie(resp, token: str): + resp.set_cookie( + key=COOKIE_NAME, + value=token, + max_age=SESSION_TTL_SECONDS, + path="/", + httponly=True, + secure=COOKIE_SECURE, + samesite="lax", + ) + + +@router.get("/login") +def login(): + config = oidc_config() + state = secrets.token_urlsafe(24) + params = { + "client_id": OIDC_CLIENT_ID, + "redirect_uri": f"{PUBLIC_BASE_URL}{CALLBACK_PATH}", + "response_type": "code", + "scope": "openid profile", + "state": state, + } + resp = RedirectResponse(f"{config['authorization_endpoint']}?{urlencode(params)}") + resp.set_cookie(STATE_COOKIE, state, max_age=600, path="/", httponly=True, secure=COOKIE_SECURE, samesite="lax") + return resp + + +@router.get("/callback") +def callback(request: Request, code: str | None = Query(None), state: str | None = Query(None), error: str | None = Query(None)): + if error: + raise HTTPException(status_code=400, detail=f"OIDC error: {error}") + + expected_state = request.cookies.get(STATE_COOKIE) + if not code or not state or not expected_state or state != expected_state: + raise HTTPException(status_code=400, detail="Invalid OAuth state") + + config = oidc_config() + token_resp = httpx.post( + config["token_endpoint"], + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": f"{PUBLIC_BASE_URL}{CALLBACK_PATH}", + "client_id": OIDC_CLIENT_ID, + "client_secret": OIDC_CLIENT_SECRET, + }, + timeout=10, + ) + token_resp.raise_for_status() + id_token = token_resp.json()["id_token"] + claims = verify_oidc_id_token(id_token) + username = claims.get("preferred_username") or claims.get("name") + + if not username or not is_allowed_user(username): + raise HTTPException(status_code=403, detail=f"User '{username}' is not allowed to access Homekeeper") + + resp = RedirectResponse("/") + _set_session_cookie(resp, mint_session_jwt(username)) + resp.delete_cookie(STATE_COOKIE, path="/") + return resp + + +@router.get("/logout") +def logout(): + resp = RedirectResponse("/") + resp.delete_cookie(COOKIE_NAME, path="/") + return resp + + +@router.get("/me") +def me(username: str = Depends(get_current_user)): + return {"username": username} + + +@router.get("/dev-login") +def dev_login(username: str = Query(...)): + """Local-dev-only bypass: mints a session for `username` without touching + the OIDC provider. Only reachable when ENV=development.""" + if ENV != "development": + raise HTTPException(status_code=404) + if not is_allowed_user(username): + raise HTTPException(status_code=403, detail=f"User '{username}' is not in ALLOWED_USERS") + + resp = RedirectResponse("/") + _set_session_cookie(resp, mint_session_jwt(username)) + return resp diff --git a/api/requirements.txt b/api/requirements.txt index cc60fc1..b4d03fe 100644 --- a/api/requirements.txt +++ b/api/requirements.txt @@ -2,6 +2,6 @@ fastapi>=0.115 uvicorn[standard]>=0.30 sqlmodel>=0.0.21 psycopg2-binary>=2.9 -passlib[bcrypt]>=1.7 -bcrypt>=4.0,<5.0 +python-jose[cryptography]>=3.3 +httpx>=0.27 python-multipart>=0.0.9 diff --git a/beekeeper/Dockerfile b/beekeeper/Dockerfile index 7ab245f..da4e091 100644 --- a/beekeeper/Dockerfile +++ b/beekeeper/Dockerfile @@ -1,4 +1,4 @@ -FROM rocker/tidyverse:4.4.2 +FROM docker.io/rocker/tidyverse:4.4.2 RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev \ diff --git a/beekeeper/R/api_client.R b/beekeeper/R/api_client.R index 626fbc4..7f34f81 100644 --- a/beekeeper/R/api_client.R +++ b/beekeeper/R/api_client.R @@ -1,6 +1,20 @@ .api_req <- function(api, path) { httr2::request(paste0(api$base_url, path)) |> - httr2::req_auth_basic(api$user, api$pass) + httr2::req_auth_bearer_token(api$token) +} + +# Extracts the hk_session cookie value from the raw Cookie header Shiny +# exposes on session$request (Rook-style request env). +get_session_token <- function(session) { + cookie_header <- session$request$HTTP_COOKIE + if (is.null(cookie_header) || !nzchar(cookie_header)) return(NULL) + for (pair in strsplit(cookie_header, ";\\s*")[[1]]) { + kv <- strsplit(pair, "=", fixed = TRUE)[[1]] + if (length(kv) >= 2 && trimws(kv[1]) == "hk_session") { + return(trimws(paste(kv[-1], collapse = "="))) + } + } + NULL } .api_check <- function(resp, path) { diff --git a/beekeeper/R/app.R b/beekeeper/R/app.R index 2009d12..66bf0e3 100644 --- a/beekeeper/R/app.R +++ b/beekeeper/R/app.R @@ -2,17 +2,15 @@ run_app <- function(host = "0.0.0.0", port = 3838) { log_init() - api <- list( - base_url = Sys.getenv("API_URL", "http://localhost:8000"), - user = Sys.getenv("API_USER", "homestead"), - pass = Sys.getenv("API_PASS", "homestead") - ) - server_fn <- function(input, output, session) { log_info("session_start", session_id = session$token) session$onSessionEnded(function() log_info("session_end", session_id = session$token) ) + api <- list( + base_url = Sys.getenv("API_URL", "http://localhost:8000"), + token = get_session_token(session) + ) server(input, output, session, db = api) } diff --git a/beekeeper/R/server.R b/beekeeper/R/server.R index fa2c4d3..dbc744a 100644 --- a/beekeeper/R/server.R +++ b/beekeeper/R/server.R @@ -1,5 +1,19 @@ server <- function(input, output, session, db) { + if (is.null(db$token)) { + output$nav_header <- renderUI(shiny::tags$h4("Beekeeper")) + output$screen_content <- renderUI( + section_card( + shiny::div( + class = "text-center py-5", + shiny::tags$p(class = "mb-3", "Bitte zuerst anmelden."), + shiny::tags$a(href = "/", class = "btn btn-primary", "Zur Anmeldung") + ) + ) + ) + return() + } + nav <- reactiveValues( screen = "apiaries", apiary_id = NULL, diff --git a/docker-compose.yml b/docker-compose.yml index e8915f7..316a8f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,7 +28,14 @@ services: DB_USER: homestead DB_PASSWORD: homestead ROOT_PATH: /api - INITIAL_USERS: "homestead:homestead" + ENV: development + COOKIE_SECURE: "false" + PUBLIC_BASE_URL: http://localhost + SESSION_JWT_SECRET: dev-only-secret-do-not-use-in-prod + ALLOWED_USERS: "nico" + OIDC_ISSUER_URL: "" + OIDC_CLIENT_ID: "" + OIDC_CLIENT_SECRET: "" restart: unless-stopped nginx: @@ -50,8 +57,6 @@ services: condition: service_started environment: API_URL: http://api:8000 - API_USER: homestead - API_PASS: homestead LOG_DIR: /logs volumes: - ./listkeeper/logs:/logs @@ -64,8 +69,6 @@ services: condition: service_started environment: API_URL: http://api:8000 - API_USER: homestead - API_PASS: homestead LOG_DIR: /logs volumes: - ./beekeeper/logs:/logs diff --git a/infrastructure/README.md b/infrastructure/README.md index 98ba006..acad99e 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -1,24 +1,28 @@ # Homekeeper Infrastructure -Ansible setup for a Hetzner VM (Ubuntu 24.04) running Homekeeper via Podman. +Ansible setup for a Hetzner VM (Ubuntu 24.04) running Homekeeper via rootless Podman. ## Architecture ``` -nginx (system) ← HTTPS, routes by path -├── / ← static landing page (/var/www/html) -├── /gitea/ → Gitea on :3000 (git + container registry) -├── /api/ → homekeeper-api on :8000 -├── /beekeeper/ → homekeeper-beekeeper on :3838 -└── /listkeeper/ → homekeeper-listkeeper on :3839 +nginx (system) ← HTTPS +├── home.friessn.de ← landing page (/var/www/html) + path routing +│ ├── / → static landing page +│ ├── /api/ → homekeeper-api on 127.0.0.1:8000 +│ ├── /beekeeper/ → homekeeper-beekeeper on 127.0.0.1:3838 +│ └── /listkeeper/ → homekeeper-listkeeper on 127.0.0.1:3839 +└── git.friessn.de → Gitea on 127.0.0.1:3000 (git + container registry) + own site file, managed outside this role -Podman (rootful, Quadlets → systemd units) -├── homekeeper-db postgres:17, data at /opt/homekeeper/pg_data +Podman (rootless, user Quadlets under {{ deploy_user }} → systemd --user units) +├── homekeeper-db postgres:17, data in named volume {{ homekeeper_db_volume }} ├── homekeeper-api from Gitea registry, auto-update enabled ├── homekeeper-beekeeper from Gitea registry, auto-update enabled -└── homekeeper-listkeeper from Gitea registry, auto-update enabled +├── homekeeper-listkeeper from Gitea registry, auto-update enabled +└── gitea from docker.io, data in named volume {{ gitea_data_volume }} -gitea container from docker.io, auto-update disabled +All containers run as {{ deploy_user }} (not root) — `loginctl enable-linger` +keeps the user systemd instance (and its containers) running after logout/reboot. ``` ## First-time setup @@ -54,16 +58,16 @@ On your local machine, build and push the three custom images: ```bash # Login to Gitea registry -docker login git.friessn.de -u nico +docker login git.friessn.de -u friessn # Build and push -docker build -t git.friessn.de/nico/homekeeper-api:latest ./api -docker build -t git.friessn.de/nico/homekeeper-beekeeper:latest ./beekeeper -docker build -t git.friessn.de/nico/homekeeper-listkeeper:latest ./listkeeper +docker build -t git.friessn.de/friessn/homekeeper-api:latest ./api +docker build -t git.friessn.de/friessn/homekeeper-beekeeper:latest ./beekeeper +docker build -t git.friessn.de/friessn/homekeeper-listkeeper:latest ./listkeeper -docker push git.friessn.de/nico/homekeeper-api:latest -docker push git.friessn.de/nico/homekeeper-beekeeper:latest -docker push git.friessn.de/nico/homekeeper-listkeeper:latest +docker push git.friessn.de/friessn/homekeeper-api:latest +docker push git.friessn.de/friessn/homekeeper-beekeeper:latest +docker push git.friessn.de/friessn/homekeeper-listkeeper:latest ``` ### 6. Full playbook run @@ -76,9 +80,9 @@ ansible-playbook -i inventory/hosts.yml site.yml After pushing a new image to the Gitea registry, `podman auto-update` picks it up automatically within `autoupdate_schedule` (default: every 15 minutes). -For an immediate deploy: +For an immediate deploy (containers run rootless as `deploy_user`, not root): ```bash -ssh root@YOUR_IP podman auto-update +ssh nico@YOUR_IP podman auto-update ``` ## Secrets management diff --git a/infrastructure/group_vars/all.yml b/infrastructure/group_vars/all.yml index 1ea173a..1c2dc44 100644 --- a/infrastructure/group_vars/all.yml +++ b/infrastructure/group_vars/all.yml @@ -2,28 +2,71 @@ # Main domain (apps + landing page) domain: home.friessn.de +# Everything runs rootless — one Linux user owns all Podman Quadlets +# (systemd --user units under ~/.config/containers/systemd), started via +# `loginctl enable-linger` so they survive logout/reboot without root. +deploy_user: nico +deploy_uid: 1000 + # Gitea on a subdomain — cleaner than a subpath, required for container registry gitea_domain: git.friessn.de -gitea_data_dir: /opt/gitea +gitea_data_volume: gitea-data # named Podman volume, not a bind mount (avoids rootless UID mapping issues) gitea_http_port: 3000 # internal port, nginx proxies HTTPS → here -gitea_admin_user: nico +gitea_ssh_port: 2222 # published directly (nginx can't proxy SSH) +gitea_admin_user: friessn # actual Gitea account created during the install wizard gitea_admin_password: CHANGE_ME # replace — set via ansible-vault in production gitea_admin_email: nico.friess@googlemail.com -# Container registry — Gitea's built-in OCI registry, same host as Gitea -# Images: git.friessn.de/nico/homekeeper-api:latest +# Container registry — Gitea's built-in OCI registry, same host as Gitea, over HTTPS (443) +# Images: git.friessn.de/friessn/homekeeper-api:latest registry_host: "{{ gitea_domain }}" registry_user: "{{ gitea_admin_user }}" -registry_token: CHANGE_ME # Gitea API token with package:write — set after first Gitea start +registry_token: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 33626439663535643963613436373132336333633839613965396539653834313831383039323961 + 3339613430323237636532663665313331303735623137660a613032306362343435633034383965 + 61383131393939393561366335376339343432636431663033363036346337633865363561303238 + 3339313363656137380a663264346666346166663362656638343838643333626438646162643835 + 39656564316130383766656364303035343061653164356664643433336531363334396530376432 + 3162326539326134323639363661363165323632383932623434 # Homekeeper app -homekeeper_data_dir: /opt/homekeeper +homekeeper_db_volume: homekeeper-db-data # named Podman volume db_name: homestead db_user: homestead db_password: CHANGE_ME # replace — set via ansible-vault in production -api_user: homestead -api_pass: CHANGE_ME # replace -initial_users: "nico:CHANGE_ME" # user:password pairs for API auth -# Auto-update timer interval (OnCalendar syntax) +# Auth — Keycloak (already running on this VM for the unrelated gcnm app, +# published on 127.0.0.1:8080) acts as OIDC identity provider, exposed here +# on its own subdomain with its own realm so it's cleanly separated from +# gcnm's realm. FastAPI verifies the Keycloak login once via the "homekeeper" +# realm, then mints its own session JWT — see api/app/auth.py. +# NOTE: the Keycloak container itself is not managed by this repo/role — only +# the nginx site + the "homekeeper" realm/client (created manually in the +# Keycloak admin console) belong to this setup. +keycloak_domain: auth.friessn.de +keycloak_realm: homekeeper +# This Keycloak instance serves under a /auth path prefix (KC_HTTP_RELATIVE_PATH=/auth +# in gcnm's docker-compose setup) — not at root. +oidc_issuer_url: "https://{{ keycloak_domain }}/auth/realms/{{ keycloak_realm }}" +oidc_client_id: homekeeper +oidc_client_secret: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 66323965333830653838356233623461323835303663353530396166653330303130616231323262 + 3233626366633532643434326338313835646533343934640a333037646138393531613730616633 + 64653330623639376164353033663061636436323465646662333934306337353765343739313561 + 3631333636643965370a663464333430306539396164363466633636643466383461636463626631 + 30343732363664356165363565393565346439646235313637346164376265326635613132663132 + 6232663636656233653466393338333532383764616633653234 +session_jwt_secret: !vault | + $ANSIBLE_VAULT;1.1;AES256 + 64613365363266313061396438313965646462313630636631353236353032383532376631303138 + 6663626664346538363464386333383136306165343163320a653763633839353637613136333464 + 62373231363664633935373632346161613865623930613436306661323439656462323538623864 + 6136343462376436320a666463346530653062323431626135666665373135393262633766353264 + 65303164373366393635653964316664633031313536383965353330343936623532 +allowed_users: "nico" # comma-separated Keycloak usernames allowed to log in + +# Auto-update timer interval (OnCalendar syntax) — rootless uses podman-auto-update.timer +# in the user systemd instance, same schedule override mechanism as the system one. autoupdate_schedule: "*:*:0/10" # every 10 seconds diff --git a/infrastructure/inventory/hosts.yml b/infrastructure/inventory/hosts.yml index fde1dd7..5f39070 100644 --- a/infrastructure/inventory/hosts.yml +++ b/infrastructure/inventory/hosts.yml @@ -2,6 +2,5 @@ all: hosts: homekeeper: - ansible_host: YOUR_HETZNER_IP # replace with actual IP - ansible_user: root - # ansible_ssh_private_key_file: ~/.ssh/id_ed25519 + ansible_host: localhost + ansible_connection: local # this repo is normally run from the target VM itself diff --git a/infrastructure/roles/gitea/handlers/main.yml b/infrastructure/roles/gitea/handlers/main.yml new file mode 100644 index 0000000..cfa0e12 --- /dev/null +++ b/infrastructure/roles/gitea/handlers/main.yml @@ -0,0 +1,18 @@ +--- +- name: Reload gitea user systemd + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" + systemd: + daemon_reload: true + scope: user + +- name: Restart gitea + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" + systemd: + name: gitea + state: restarted + enabled: true + scope: user diff --git a/infrastructure/roles/gitea/tasks/main.yml b/infrastructure/roles/gitea/tasks/main.yml index 0f8e1fc..b6fdd8d 100644 --- a/infrastructure/roles/gitea/tasks/main.yml +++ b/infrastructure/roles/gitea/tasks/main.yml @@ -1,26 +1,24 @@ --- -- name: Create Gitea quadlet directory +# Gitea runs rootless as {{ deploy_user }} — see group_vars/all.yml. +- name: Enable linger for {{ deploy_user }} (user services survive logout/reboot) + command: "loginctl enable-linger {{ deploy_user }}" + changed_when: false + +- name: Create user Quadlet directory file: - path: /etc/containers/systemd + path: "/home/{{ deploy_user }}/.config/containers/systemd" state: directory mode: "0755" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" - name: Deploy Gitea container quadlet template: src: gitea.container.j2 - dest: /etc/containers/systemd/gitea.container + dest: "/home/{{ deploy_user }}/.config/containers/systemd/gitea.container" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" mode: "0644" notify: - - Reload systemd + - Reload gitea user systemd - Restart gitea - -handlers: - - name: Reload systemd - systemd: - daemon_reload: true - - - name: Restart gitea - systemd: - name: gitea - state: restarted - enabled: true diff --git a/infrastructure/roles/gitea/templates/gitea.container.j2 b/infrastructure/roles/gitea/templates/gitea.container.j2 index e8c722c..ceeba1e 100644 --- a/infrastructure/roles/gitea/templates/gitea.container.j2 +++ b/infrastructure/roles/gitea/templates/gitea.container.j2 @@ -6,11 +6,13 @@ After=network-online.target Image=docker.io/gitea/gitea:latest ContainerName=gitea PublishPort=127.0.0.1:{{ gitea_http_port }}:3000 -Volume={{ gitea_data_dir }}:/data:Z -Environment=USER_UID=1000 -Environment=USER_GID=1000 +PublishPort={{ gitea_ssh_port }}:22 +Volume={{ gitea_data_volume }}:/data +Environment=USER_UID={{ deploy_uid }} +Environment=USER_GID={{ deploy_uid }} Environment=GITEA__server__DOMAIN={{ gitea_domain }} Environment=GITEA__server__ROOT_URL=https://{{ gitea_domain }}/ +Environment=GITEA__server__SSH_DOMAIN={{ gitea_domain }} Environment=GITEA__server__HTTP_PORT=3000 Environment=GITEA__packages__ENABLED=true Environment=GITEA__packages__CHUNKED_UPLOAD_PATH=/data/tmp/package-upload @@ -20,4 +22,4 @@ Restart=always TimeoutStartSec=120 [Install] -WantedBy=multi-user.target default.target +WantedBy=default.target diff --git a/infrastructure/roles/homekeeper/handlers/main.yml b/infrastructure/roles/homekeeper/handlers/main.yml new file mode 100644 index 0000000..40c9d67 --- /dev/null +++ b/infrastructure/roles/homekeeper/handlers/main.yml @@ -0,0 +1,23 @@ +--- +- name: Reload homekeeper user systemd + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" + systemd: + daemon_reload: true + scope: user + +- name: Restart homekeeper + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" + systemd: + name: "{{ item }}" + state: restarted + enabled: true + scope: user + loop: + - homekeeper-db + - homekeeper-api + - homekeeper-beekeeper + - homekeeper-listkeeper diff --git a/infrastructure/roles/homekeeper/tasks/main.yml b/infrastructure/roles/homekeeper/tasks/main.yml index ef5be0c..beda5bf 100644 --- a/infrastructure/roles/homekeeper/tasks/main.yml +++ b/infrastructure/roles/homekeeper/tasks/main.yml @@ -1,21 +1,28 @@ --- -- name: Create quadlet directory +# Homekeeper containers run rootless as {{ deploy_user }}, same pattern as the gitea role. +- name: Create user Quadlet directory file: - path: /etc/containers/systemd + path: "/home/{{ deploy_user }}/.config/containers/systemd" state: directory mode: "0755" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" - name: Deploy homekeeper network quadlet template: src: homekeeper.network.j2 - dest: /etc/containers/systemd/homekeeper.network + dest: "/home/{{ deploy_user }}/.config/containers/systemd/homekeeper.network" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" mode: "0644" - notify: Reload systemd + notify: Reload homekeeper user systemd - name: Deploy container quadlets template: src: "{{ item }}.j2" - dest: "/etc/containers/systemd/{{ item }}" + dest: "/home/{{ deploy_user }}/.config/containers/systemd/{{ item }}" + owner: "{{ deploy_user }}" + group: "{{ deploy_user }}" mode: "0644" loop: - homekeeper-db.container @@ -23,21 +30,5 @@ - homekeeper-beekeeper.container - homekeeper-listkeeper.container notify: - - Reload systemd + - Reload homekeeper user systemd - Restart homekeeper - -handlers: - - name: Reload systemd - systemd: - daemon_reload: true - - - name: Restart homekeeper - systemd: - name: "{{ item }}" - state: restarted - enabled: true - loop: - - homekeeper-db - - homekeeper-api - - homekeeper-beekeeper - - homekeeper-listkeeper diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 index 4035402..489a892 100644 --- a/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 +++ b/infrastructure/roles/homekeeper/templates/homekeeper-api.container.j2 @@ -13,7 +13,14 @@ Environment=DB_NAME={{ db_name }} Environment=DB_USER={{ db_user }} Environment=DB_PASSWORD={{ db_password }} Environment=ROOT_PATH=/api -Environment=INITIAL_USERS={{ initial_users }} +Environment=ENV=production +Environment=COOKIE_SECURE=true +Environment=PUBLIC_BASE_URL=https://{{ domain }} +Environment=OIDC_ISSUER_URL={{ oidc_issuer_url }} +Environment=OIDC_CLIENT_ID={{ oidc_client_id }} +Environment=OIDC_CLIENT_SECRET={{ oidc_client_secret }} +Environment=SESSION_JWT_SECRET={{ session_jwt_secret }} +Environment=ALLOWED_USERS={{ allowed_users }} AutoUpdate=registry Label=io.containers.autoupdate=registry @@ -22,4 +29,4 @@ Restart=always TimeoutStartSec=120 [Install] -WantedBy=multi-user.target default.target +WantedBy=default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 index c5b5960..e1ecfd2 100644 --- a/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 +++ b/infrastructure/roles/homekeeper/templates/homekeeper-beekeeper.container.j2 @@ -8,8 +8,6 @@ ContainerName=homekeeper-beekeeper Network=homekeeper.network PublishPort=127.0.0.1:3838:3838 Environment=API_URL=http://homekeeper-api:8000 -Environment=API_USER={{ api_user }} -Environment=API_PASS={{ api_pass }} AutoUpdate=registry Label=io.containers.autoupdate=registry @@ -18,4 +16,4 @@ Restart=always TimeoutStartSec=120 [Install] -WantedBy=multi-user.target default.target +WantedBy=default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 index 69807ed..fcc2887 100644 --- a/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 +++ b/infrastructure/roles/homekeeper/templates/homekeeper-db.container.j2 @@ -6,7 +6,7 @@ After=network-online.target Image=docker.io/library/postgres:17 ContainerName=homekeeper-db Network=homekeeper.network -Volume={{ homekeeper_data_dir }}/pg_data:/var/lib/postgresql/data:Z +Volume={{ homekeeper_db_volume }}:/var/lib/postgresql/data Environment=POSTGRES_DB={{ db_name }} Environment=POSTGRES_USER={{ db_user }} Environment=POSTGRES_PASSWORD={{ db_password }} @@ -20,4 +20,4 @@ Restart=always TimeoutStartSec=120 [Install] -WantedBy=multi-user.target default.target +WantedBy=default.target diff --git a/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 b/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 index 1c4de88..824dc37 100644 --- a/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 +++ b/infrastructure/roles/homekeeper/templates/homekeeper-listkeeper.container.j2 @@ -9,8 +9,6 @@ Network=homekeeper.network PublishPort=127.0.0.1:3839:3839 Environment=PORT=3839 Environment=API_URL=http://homekeeper-api:8000 -Environment=API_USER={{ api_user }} -Environment=API_PASS={{ api_pass }} AutoUpdate=registry Label=io.containers.autoupdate=registry @@ -19,4 +17,4 @@ Restart=always TimeoutStartSec=120 [Install] -WantedBy=multi-user.target default.target +WantedBy=default.target diff --git a/infrastructure/roles/nginx/handlers/main.yml b/infrastructure/roles/nginx/handlers/main.yml new file mode 100644 index 0000000..eb0e7f1 --- /dev/null +++ b/infrastructure/roles/nginx/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Reload nginx + service: + name: nginx + state: reloaded diff --git a/infrastructure/roles/nginx/tasks/main.yml b/infrastructure/roles/nginx/tasks/main.yml index 17c90ed..77d6712 100644 --- a/infrastructure/roles/nginx/tasks/main.yml +++ b/infrastructure/roles/nginx/tasks/main.yml @@ -14,37 +14,63 @@ state: absent notify: Reload nginx -- name: Deploy homekeeper nginx config +# git.friessn.de already has its own site file (deployed manually before this +# role existed, same one-file-per-domain convention as the other sites on this +# box) — this role only manages home.friessn.de. +- name: Deploy home.friessn.de nginx config template: - src: homekeeper.conf.j2 - dest: /etc/nginx/sites-available/homekeeper.conf + src: home.friessn.de.conf.j2 + dest: /etc/nginx/sites-available/home.friessn.de mode: "0644" notify: Reload nginx -- name: Enable homekeeper nginx site +- name: Enable home.friessn.de nginx site file: - src: /etc/nginx/sites-available/homekeeper.conf - dest: /etc/nginx/sites-enabled/homekeeper.conf + src: /etc/nginx/sites-available/home.friessn.de + dest: /etc/nginx/sites-enabled/home.friessn.de state: link notify: Reload nginx -- name: Obtain Let's Encrypt certificates +- name: Obtain Let's Encrypt certificate for {{ domain }} command: > - certbot --nginx -d {{ domain }} -d {{ gitea_domain }} + certbot --nginx -d {{ domain }} --non-interactive --agree-tos -m {{ gitea_admin_email }} --redirect args: creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem notify: Reload nginx +# Keycloak itself runs as a plain Docker container for the unrelated gcnm +# app (not managed by this role) — it's already published on +# 127.0.0.1:8080, this just fronts it with TLS on its own subdomain so +# Homekeeper (and anything else on the box) can treat it as a normal OIDC +# provider. Requires a DNS record for {{ keycloak_domain }} pointing at this +# VM before the certbot step below can succeed. +- name: Deploy auth.friessn.de nginx config + template: + src: auth.friessn.de.conf.j2 + dest: /etc/nginx/sites-available/{{ keycloak_domain }} + mode: "0644" + notify: Reload nginx + +- name: Enable auth.friessn.de nginx site + file: + src: /etc/nginx/sites-available/{{ keycloak_domain }} + dest: /etc/nginx/sites-enabled/{{ keycloak_domain }} + state: link + notify: Reload nginx + +- name: Obtain Let's Encrypt certificate for {{ keycloak_domain }} + command: > + certbot --nginx -d {{ keycloak_domain }} + --non-interactive --agree-tos -m {{ gitea_admin_email }} + --redirect + args: + creates: /etc/letsencrypt/live/{{ keycloak_domain }}/fullchain.pem + notify: Reload nginx + - name: Enable nginx service: name: nginx enabled: true state: started - -handlers: - - name: Reload nginx - service: - name: nginx - state: reloaded diff --git a/infrastructure/roles/nginx/templates/auth.friessn.de.conf.j2 b/infrastructure/roles/nginx/templates/auth.friessn.de.conf.j2 new file mode 100644 index 0000000..c28ea31 --- /dev/null +++ b/infrastructure/roles/nginx/templates/auth.friessn.de.conf.j2 @@ -0,0 +1,17 @@ +# ── Keycloak (shared identity provider — homekeeper realm lives here, +# the gcnm app has its own separate realm): {{ keycloak_domain }} ───────── +server { + listen 80; + server_name {{ keycloak_domain }}; + # certbot --nginx adds SSL redirect + listen 443 block here + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 300s; + } +} diff --git a/infrastructure/roles/nginx/templates/homekeeper.conf.j2 b/infrastructure/roles/nginx/templates/home.friessn.de.conf.j2 similarity index 69% rename from infrastructure/roles/nginx/templates/homekeeper.conf.j2 rename to infrastructure/roles/nginx/templates/home.friessn.de.conf.j2 index 6406792..c6c1186 100644 --- a/infrastructure/roles/nginx/templates/homekeeper.conf.j2 +++ b/infrastructure/roles/nginx/templates/home.friessn.de.conf.j2 @@ -3,7 +3,7 @@ map $http_upgrade $connection_upgrade { '' close; } -# ── Main app: friessn.de ──────────────────────────────────────────────────── +# ── Main app: {{ domain }} ─────────────────────────────────────────────────── server { listen 80; server_name {{ domain }}; @@ -57,21 +57,3 @@ server { proxy_send_timeout 86400s; } } - -# ── Gitea: git.friessn.de ─────────────────────────────────────────────────── -server { - listen 80; - server_name {{ gitea_domain }}; - # certbot --nginx adds SSL redirect + listen 443 block here - - client_max_body_size 512m; - - location / { - proxy_pass http://127.0.0.1:{{ gitea_http_port }}; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/infrastructure/roles/podman/handlers/main.yml b/infrastructure/roles/podman/handlers/main.yml new file mode 100644 index 0000000..a7250c6 --- /dev/null +++ b/infrastructure/roles/podman/handlers/main.yml @@ -0,0 +1,15 @@ +--- +- name: Reload systemd + systemd: + daemon_reload: true + +- name: Restart podman services + command: systemctl daemon-reload + +- name: Reload user systemd + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" + systemd: + daemon_reload: true + scope: user diff --git a/infrastructure/roles/podman/tasks/main.yml b/infrastructure/roles/podman/tasks/main.yml index c6cff62..69483fc 100644 --- a/infrastructure/roles/podman/tasks/main.yml +++ b/infrastructure/roles/podman/tasks/main.yml @@ -4,18 +4,14 @@ name: - podman - podman-compose # for ad-hoc use; production uses quadlets + - slirp4netns # rootless networking / port publishing + - uidmap # rootless subuid/subgid mapping state: present update_cache: true -- name: Create homekeeper data directories - file: - path: "{{ item }}" - state: directory - mode: "0750" - loop: - - "{{ homekeeper_data_dir }}" - - "{{ homekeeper_data_dir }}/pg_data" - - "{{ gitea_data_dir }}" +# All app data lives in named Podman volumes (created implicitly on first +# `podman run`/Quadlet start), not host bind mounts — avoids rootless UID +# mapping headaches. Nothing to pre-create here. - name: Configure Gitea as additional registry template: @@ -25,35 +21,46 @@ notify: Restart podman services - name: Login to Gitea container registry + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" command: > - podman login {{ registry_host }}:{{ registry_port }} + podman login {{ registry_host }} -u {{ registry_user }} -p {{ registry_token }} register: login_result changed_when: "'Login Succeeded' in login_result.stdout" # Run this after Gitea is up and registry_token is set -- name: Enable podman-auto-update timer +- name: Enable linger for {{ deploy_user }} (user services survive logout/reboot) + command: "loginctl enable-linger {{ deploy_user }}" + changed_when: false + +- name: Enable podman-auto-update timer (user scope) + become_user: "{{ deploy_user }}" + environment: + XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}" systemd: name: podman-auto-update.timer enabled: true state: started + scope: user daemon_reload: true -- name: Override auto-update timer schedule +- name: Override auto-update timer schedule (user scope) + become_user: "{{ deploy_user }}" + file: + path: "/home/{{ deploy_user }}/.config/systemd/user/podman-auto-update.timer.d" + state: directory + mode: "0755" + +- name: Deploy auto-update timer schedule override + become_user: "{{ deploy_user }}" copy: - dest: /etc/systemd/system/podman-auto-update.timer.d/override.conf + dest: "/home/{{ deploy_user }}/.config/systemd/user/podman-auto-update.timer.d/override.conf" content: | [Timer] OnCalendar= OnCalendar={{ autoupdate_schedule }} AccuracySec=1s mode: "0644" - notify: Reload systemd - -handlers: - - name: Reload systemd - systemd: - daemon_reload: true - - - name: Restart podman services - command: systemctl daemon-reload + notify: Reload user systemd diff --git a/infrastructure/roles/podman/templates/registries.conf.j2 b/infrastructure/roles/podman/templates/registries.conf.j2 index d0a9743..e5bd12f 100644 --- a/infrastructure/roles/podman/templates/registries.conf.j2 +++ b/infrastructure/roles/podman/templates/registries.conf.j2 @@ -1,3 +1,3 @@ [[registry]] -location = "{{ registry_host }}:{{ registry_port }}" +location = "{{ registry_host }}" insecure = false diff --git a/listkeeper/Dockerfile b/listkeeper/Dockerfile index 41799a8..b2614d8 100644 --- a/listkeeper/Dockerfile +++ b/listkeeper/Dockerfile @@ -1,4 +1,4 @@ -FROM rocker/tidyverse:4.4.2 +FROM docker.io/rocker/tidyverse:4.4.2 RUN apt-get update && apt-get install -y --no-install-recommends \ libpq-dev \ diff --git a/listkeeper/R/api_client.R b/listkeeper/R/api_client.R index 626fbc4..7f34f81 100644 --- a/listkeeper/R/api_client.R +++ b/listkeeper/R/api_client.R @@ -1,6 +1,20 @@ .api_req <- function(api, path) { httr2::request(paste0(api$base_url, path)) |> - httr2::req_auth_basic(api$user, api$pass) + httr2::req_auth_bearer_token(api$token) +} + +# Extracts the hk_session cookie value from the raw Cookie header Shiny +# exposes on session$request (Rook-style request env). +get_session_token <- function(session) { + cookie_header <- session$request$HTTP_COOKIE + if (is.null(cookie_header) || !nzchar(cookie_header)) return(NULL) + for (pair in strsplit(cookie_header, ";\\s*")[[1]]) { + kv <- strsplit(pair, "=", fixed = TRUE)[[1]] + if (length(kv) >= 2 && trimws(kv[1]) == "hk_session") { + return(trimws(paste(kv[-1], collapse = "="))) + } + } + NULL } .api_check <- function(resp, path) { diff --git a/listkeeper/R/app.R b/listkeeper/R/app.R index aba9dba..e07b2ed 100644 --- a/listkeeper/R/app.R +++ b/listkeeper/R/app.R @@ -2,17 +2,15 @@ run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) { log_init() - api <- list( - base_url = Sys.getenv("API_URL", "http://localhost:8000"), - user = Sys.getenv("API_USER", "homestead"), - pass = Sys.getenv("API_PASS", "homestead") - ) - server_fn <- function(input, output, session) { log_info("session_start", session_id = session$token) session$onSessionEnded(function() log_info("session_end", session_id = session$token) ) + api <- list( + base_url = Sys.getenv("API_URL", "http://localhost:8000"), + token = get_session_token(session) + ) server(input, output, session, db = api) } diff --git a/listkeeper/R/server.R b/listkeeper/R/server.R index 0f176a0..7dcd62a 100644 --- a/listkeeper/R/server.R +++ b/listkeeper/R/server.R @@ -1,3 +1,15 @@ server <- function(input, output, session, db) { + if (is.null(db$token)) { + output$main_content <- renderUI( + shiny::div( + class = "text-center py-5", + shiny::tags$p(class = "mb-3", "Bitte zuerst anmelden."), + shiny::tags$a(href = "/", class = "btn btn-primary", "Zur Anmeldung") + ) + ) + return() + } + + output$main_content <- renderUI(screen_home_ui("s_home")) screen_home_server("s_home", db = db) } diff --git a/listkeeper/R/ui.R b/listkeeper/R/ui.R index f498075..dd34141 100644 --- a/listkeeper/R/ui.R +++ b/listkeeper/R/ui.R @@ -29,7 +29,7 @@ ui <- function() { ), shiny::div( class = "container-fluid p-3", - screen_home_ui("s_home") + shiny::uiOutput("main_content") ) ) } diff --git a/www/index.html b/www/index.html index aea64cf..b387e36 100644 --- a/www/index.html +++ b/www/index.html @@ -19,6 +19,10 @@ background: #fff; border-bottom: 1px solid #dee2e6; padding: 1rem 1.5rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; } header h1 { @@ -27,6 +31,17 @@ color: #212529; } + #auth-area { + font-size: 0.875rem; + color: #6c757d; + white-space: nowrap; + } + + #auth-area a { + color: #0d6efd; + text-decoration: none; + } + main { flex: 1; padding: 2rem 1.5rem; @@ -91,6 +106,7 @@

Homekeeper

+ Anmelden

Apps

@@ -105,5 +121,19 @@
+