Rebuild auth on Keycloak OIDC, fix rootless Ansible deploy
Replaces the single shared HTTP Basic service-account credential (which caused a production outage from a username mismatch) with per-user login: Keycloak (already running on this VM for gcnm, now also fronted on auth.friessn.de with its own "homekeeper" realm) authenticates the user once via the landing page, FastAPI verifies the OIDC id_token and mints its own signed session JWT as a cookie, and both Shiny apps forward that per-session token as a Bearer credential instead of a static shared one. Authorization is a simple ALLOWED_USERS allowlist; the old auth.users table and bcrypt seeding are gone entirely. Also carries forward the in-progress rootless Podman/Quadlet migration (gitea, homekeeper, podman roles) and fixes a pre-existing bug where each role's handlers were malformed inside tasks/main.yml instead of their own handlers/main.yml, which broke ansible-playbook entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FbiCdckkTX2HyAkyi1R39d
This commit is contained in:
@@ -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 |
|
| `nginx` | `nginx:alpine` | 80 | local only; system nginx on VM |
|
||||||
|
|
||||||
**ENV vars (all services read from docker-compose / Quadlet):**
|
**ENV vars (all services read from docker-compose / Quadlet):**
|
||||||
- `API_URL`, `API_USER`, `API_PASS` — Shiny apps
|
- `API_URL` — Shiny apps (no credentials needed — auth is per-session via cookie, see below)
|
||||||
- `DB_HOST/PORT/NAME/USER/PASSWORD`, `ROOT_PATH`, `INITIAL_USERS` — API
|
- `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/`)
|
- `LOG_DIR` — Shiny apps (default `/logs`, mounted from `./*/logs/`)
|
||||||
- `PORT` — listkeeper only (default 3838)
|
- `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.
|
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 <jwt>` 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.
|
**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
|
```r
|
||||||
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
|
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
|
||||||
log_init()
|
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) {
|
server_fn <- function(input, output, session) {
|
||||||
log_info("session_start", session_id = session$token)
|
log_info("session_start", session_id = session$token)
|
||||||
session$onSessionEnded(function() log_info("session_end", 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)
|
server(input, output, session, db = api)
|
||||||
}
|
}
|
||||||
shiny::runApp(shiny::shinyApp(ui(), server_fn), host = host, port = port, launch.browser = FALSE)
|
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:**
|
**Module pattern:**
|
||||||
```r
|
```r
|
||||||
screen_example_ui <- function(id) {
|
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).
|
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.
|
||||||
|
|||||||
+66
-56
@@ -1,67 +1,77 @@
|
|||||||
import os
|
import os
|
||||||
from fastapi import Depends, HTTPException, status
|
import time
|
||||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
from functools import lru_cache
|
||||||
from passlib.context import CryptContext
|
|
||||||
from sqlalchemy import text
|
|
||||||
|
|
||||||
security = HTTPBasic()
|
import httpx
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
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:
|
def _allowed_users() -> set[str]:
|
||||||
return pwd_context.verify(plain, hashed)
|
return {u.strip() for u in os.getenv("ALLOWED_USERS", "").split(",") if u.strip()}
|
||||||
|
|
||||||
|
|
||||||
def hash_password(plain: str) -> str:
|
def is_allowed_user(username: str) -> bool:
|
||||||
return pwd_context.hash(plain)
|
return username in _allowed_users()
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(credentials: HTTPBasicCredentials = Depends(security)):
|
def mint_session_jwt(username: str) -> str:
|
||||||
from app.database import engine
|
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]):
|
def get_current_user(request: Request) -> str:
|
||||||
raise HTTPException(
|
"""Accepts either 'Authorization: Bearer <jwt>' (used by the Shiny apps)
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
or the hk_session cookie (used by same-origin browser fetches from the
|
||||||
detail="Invalid credentials",
|
landing page)."""
|
||||||
headers={"WWW-Authenticate": "Basic"},
|
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
|
except JWTError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"Invalid OIDC token: {e}")
|
||||||
|
|
||||||
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()
|
|
||||||
|
|||||||
+1
-15
@@ -21,20 +21,8 @@ def get_session():
|
|||||||
|
|
||||||
|
|
||||||
def init_db():
|
def init_db():
|
||||||
"""Create schemas and tables, then seed initial users."""
|
"""Create schemas and tables."""
|
||||||
from app.auth import seed_users
|
|
||||||
|
|
||||||
with engine.connect() as conn:
|
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
|
# Create lk schema and tables
|
||||||
conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk"))
|
conn.execute(text("CREATE SCHEMA IF NOT EXISTS lk"))
|
||||||
conn.execute(text("""
|
conn.execute(text("""
|
||||||
@@ -239,5 +227,3 @@ def init_db():
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
seed_users()
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from app.routers import (
|
from app.routers import (
|
||||||
apiaries,
|
apiaries,
|
||||||
|
auth,
|
||||||
colonies,
|
colonies,
|
||||||
inspections,
|
inspections,
|
||||||
varroa,
|
varroa,
|
||||||
@@ -29,6 +30,7 @@ app = FastAPI(
|
|||||||
lifespan=lifespan,
|
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(apiaries.router, prefix="/v1/apiaries", tags=["apiaries"])
|
||||||
app.include_router(colonies.router, prefix="/v1/colonies", tags=["colonies"])
|
app.include_router(colonies.router, prefix="/v1/colonies", tags=["colonies"])
|
||||||
app.include_router(inspections.router, prefix="/v1/inspections", tags=["inspections"])
|
app.include_router(inspections.router, prefix="/v1/inspections", tags=["inspections"])
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,6 @@ fastapi>=0.115
|
|||||||
uvicorn[standard]>=0.30
|
uvicorn[standard]>=0.30
|
||||||
sqlmodel>=0.0.21
|
sqlmodel>=0.0.21
|
||||||
psycopg2-binary>=2.9
|
psycopg2-binary>=2.9
|
||||||
passlib[bcrypt]>=1.7
|
python-jose[cryptography]>=3.3
|
||||||
bcrypt>=4.0,<5.0
|
httpx>=0.27
|
||||||
python-multipart>=0.0.9
|
python-multipart>=0.0.9
|
||||||
|
|||||||
@@ -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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
.api_req <- function(api, path) {
|
.api_req <- function(api, path) {
|
||||||
httr2::request(paste0(api$base_url, 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) {
|
.api_check <- function(resp, path) {
|
||||||
|
|||||||
+4
-6
@@ -2,17 +2,15 @@
|
|||||||
run_app <- function(host = "0.0.0.0", port = 3838) {
|
run_app <- function(host = "0.0.0.0", port = 3838) {
|
||||||
log_init()
|
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) {
|
server_fn <- function(input, output, session) {
|
||||||
log_info("session_start", session_id = session$token)
|
log_info("session_start", session_id = session$token)
|
||||||
session$onSessionEnded(function()
|
session$onSessionEnded(function()
|
||||||
log_info("session_end", session_id = session$token)
|
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)
|
server(input, output, session, db = api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
server <- function(input, output, session, db) {
|
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(
|
nav <- reactiveValues(
|
||||||
screen = "apiaries",
|
screen = "apiaries",
|
||||||
apiary_id = NULL,
|
apiary_id = NULL,
|
||||||
|
|||||||
+8
-5
@@ -28,7 +28,14 @@ services:
|
|||||||
DB_USER: homestead
|
DB_USER: homestead
|
||||||
DB_PASSWORD: homestead
|
DB_PASSWORD: homestead
|
||||||
ROOT_PATH: /api
|
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
|
restart: unless-stopped
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
@@ -50,8 +57,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
API_URL: http://api:8000
|
API_URL: http://api:8000
|
||||||
API_USER: homestead
|
|
||||||
API_PASS: homestead
|
|
||||||
LOG_DIR: /logs
|
LOG_DIR: /logs
|
||||||
volumes:
|
volumes:
|
||||||
- ./listkeeper/logs:/logs
|
- ./listkeeper/logs:/logs
|
||||||
@@ -64,8 +69,6 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
environment:
|
environment:
|
||||||
API_URL: http://api:8000
|
API_URL: http://api:8000
|
||||||
API_USER: homestead
|
|
||||||
API_PASS: homestead
|
|
||||||
LOG_DIR: /logs
|
LOG_DIR: /logs
|
||||||
volumes:
|
volumes:
|
||||||
- ./beekeeper/logs:/logs
|
- ./beekeeper/logs:/logs
|
||||||
|
|||||||
+24
-20
@@ -1,24 +1,28 @@
|
|||||||
# Homekeeper Infrastructure
|
# 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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
nginx (system) ← HTTPS, routes by path
|
nginx (system) ← HTTPS
|
||||||
├── / ← static landing page (/var/www/html)
|
├── home.friessn.de ← landing page (/var/www/html) + path routing
|
||||||
├── /gitea/ → Gitea on :3000 (git + container registry)
|
│ ├── / → static landing page
|
||||||
├── /api/ → homekeeper-api on :8000
|
│ ├── /api/ → homekeeper-api on 127.0.0.1:8000
|
||||||
├── /beekeeper/ → homekeeper-beekeeper on :3838
|
│ ├── /beekeeper/ → homekeeper-beekeeper on 127.0.0.1:3838
|
||||||
└── /listkeeper/ → homekeeper-listkeeper on :3839
|
│ └── /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)
|
Podman (rootless, user Quadlets under {{ deploy_user }} → systemd --user units)
|
||||||
├── homekeeper-db postgres:17, data at /opt/homekeeper/pg_data
|
├── homekeeper-db postgres:17, data in named volume {{ homekeeper_db_volume }}
|
||||||
├── homekeeper-api from Gitea registry, auto-update enabled
|
├── homekeeper-api from Gitea registry, auto-update enabled
|
||||||
├── homekeeper-beekeeper 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
|
## First-time setup
|
||||||
@@ -54,16 +58,16 @@ On your local machine, build and push the three custom images:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Login to Gitea registry
|
# Login to Gitea registry
|
||||||
docker login git.friessn.de -u nico
|
docker login git.friessn.de -u friessn
|
||||||
|
|
||||||
# Build and push
|
# Build and push
|
||||||
docker build -t git.friessn.de/nico/homekeeper-api:latest ./api
|
docker build -t git.friessn.de/friessn/homekeeper-api:latest ./api
|
||||||
docker build -t git.friessn.de/nico/homekeeper-beekeeper:latest ./beekeeper
|
docker build -t git.friessn.de/friessn/homekeeper-beekeeper:latest ./beekeeper
|
||||||
docker build -t git.friessn.de/nico/homekeeper-listkeeper:latest ./listkeeper
|
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/friessn/homekeeper-api:latest
|
||||||
docker push git.friessn.de/nico/homekeeper-beekeeper:latest
|
docker push git.friessn.de/friessn/homekeeper-beekeeper:latest
|
||||||
docker push git.friessn.de/nico/homekeeper-listkeeper:latest
|
docker push git.friessn.de/friessn/homekeeper-listkeeper:latest
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. Full playbook run
|
### 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
|
After pushing a new image to the Gitea registry, `podman auto-update` picks it up
|
||||||
automatically within `autoupdate_schedule` (default: every 15 minutes).
|
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
|
```bash
|
||||||
ssh root@YOUR_IP podman auto-update
|
ssh nico@YOUR_IP podman auto-update
|
||||||
```
|
```
|
||||||
|
|
||||||
## Secrets management
|
## Secrets management
|
||||||
|
|||||||
@@ -2,28 +2,71 @@
|
|||||||
# Main domain (apps + landing page)
|
# Main domain (apps + landing page)
|
||||||
domain: home.friessn.de
|
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 on a subdomain — cleaner than a subpath, required for container registry
|
||||||
gitea_domain: git.friessn.de
|
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_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_password: CHANGE_ME # replace — set via ansible-vault in production
|
||||||
gitea_admin_email: nico.friess@googlemail.com
|
gitea_admin_email: nico.friess@googlemail.com
|
||||||
|
|
||||||
# Container registry — Gitea's built-in OCI registry, same host as Gitea
|
# Container registry — Gitea's built-in OCI registry, same host as Gitea, over HTTPS (443)
|
||||||
# Images: git.friessn.de/nico/homekeeper-api:latest
|
# Images: git.friessn.de/friessn/homekeeper-api:latest
|
||||||
registry_host: "{{ gitea_domain }}"
|
registry_host: "{{ gitea_domain }}"
|
||||||
registry_user: "{{ gitea_admin_user }}"
|
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 app
|
||||||
homekeeper_data_dir: /opt/homekeeper
|
homekeeper_db_volume: homekeeper-db-data # named Podman volume
|
||||||
db_name: homestead
|
db_name: homestead
|
||||||
db_user: homestead
|
db_user: homestead
|
||||||
db_password: CHANGE_ME # replace — set via ansible-vault in production
|
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
|
autoupdate_schedule: "*:*:0/10" # every 10 seconds
|
||||||
|
|||||||
@@ -2,6 +2,5 @@
|
|||||||
all:
|
all:
|
||||||
hosts:
|
hosts:
|
||||||
homekeeper:
|
homekeeper:
|
||||||
ansible_host: YOUR_HETZNER_IP # replace with actual IP
|
ansible_host: localhost
|
||||||
ansible_user: root
|
ansible_connection: local # this repo is normally run from the target VM itself
|
||||||
# ansible_ssh_private_key_file: ~/.ssh/id_ed25519
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
file:
|
||||||
path: /etc/containers/systemd
|
path: "/home/{{ deploy_user }}/.config/containers/systemd"
|
||||||
state: directory
|
state: directory
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
|
||||||
- name: Deploy Gitea container quadlet
|
- name: Deploy Gitea container quadlet
|
||||||
template:
|
template:
|
||||||
src: gitea.container.j2
|
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"
|
mode: "0644"
|
||||||
notify:
|
notify:
|
||||||
- Reload systemd
|
- Reload gitea user systemd
|
||||||
- Restart gitea
|
- Restart gitea
|
||||||
|
|
||||||
handlers:
|
|
||||||
- name: Reload systemd
|
|
||||||
systemd:
|
|
||||||
daemon_reload: true
|
|
||||||
|
|
||||||
- name: Restart gitea
|
|
||||||
systemd:
|
|
||||||
name: gitea
|
|
||||||
state: restarted
|
|
||||||
enabled: true
|
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ After=network-online.target
|
|||||||
Image=docker.io/gitea/gitea:latest
|
Image=docker.io/gitea/gitea:latest
|
||||||
ContainerName=gitea
|
ContainerName=gitea
|
||||||
PublishPort=127.0.0.1:{{ gitea_http_port }}:3000
|
PublishPort=127.0.0.1:{{ gitea_http_port }}:3000
|
||||||
Volume={{ gitea_data_dir }}:/data:Z
|
PublishPort={{ gitea_ssh_port }}:22
|
||||||
Environment=USER_UID=1000
|
Volume={{ gitea_data_volume }}:/data
|
||||||
Environment=USER_GID=1000
|
Environment=USER_UID={{ deploy_uid }}
|
||||||
|
Environment=USER_GID={{ deploy_uid }}
|
||||||
Environment=GITEA__server__DOMAIN={{ gitea_domain }}
|
Environment=GITEA__server__DOMAIN={{ gitea_domain }}
|
||||||
Environment=GITEA__server__ROOT_URL=https://{{ 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__server__HTTP_PORT=3000
|
||||||
Environment=GITEA__packages__ENABLED=true
|
Environment=GITEA__packages__ENABLED=true
|
||||||
Environment=GITEA__packages__CHUNKED_UPLOAD_PATH=/data/tmp/package-upload
|
Environment=GITEA__packages__CHUNKED_UPLOAD_PATH=/data/tmp/package-upload
|
||||||
@@ -20,4 +22,4 @@ Restart=always
|
|||||||
TimeoutStartSec=120
|
TimeoutStartSec=120
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
file:
|
||||||
path: /etc/containers/systemd
|
path: "/home/{{ deploy_user }}/.config/containers/systemd"
|
||||||
state: directory
|
state: directory
|
||||||
mode: "0755"
|
mode: "0755"
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
|
|
||||||
- name: Deploy homekeeper network quadlet
|
- name: Deploy homekeeper network quadlet
|
||||||
template:
|
template:
|
||||||
src: homekeeper.network.j2
|
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"
|
mode: "0644"
|
||||||
notify: Reload systemd
|
notify: Reload homekeeper user systemd
|
||||||
|
|
||||||
- name: Deploy container quadlets
|
- name: Deploy container quadlets
|
||||||
template:
|
template:
|
||||||
src: "{{ item }}.j2"
|
src: "{{ item }}.j2"
|
||||||
dest: "/etc/containers/systemd/{{ item }}"
|
dest: "/home/{{ deploy_user }}/.config/containers/systemd/{{ item }}"
|
||||||
|
owner: "{{ deploy_user }}"
|
||||||
|
group: "{{ deploy_user }}"
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
loop:
|
loop:
|
||||||
- homekeeper-db.container
|
- homekeeper-db.container
|
||||||
@@ -23,21 +30,5 @@
|
|||||||
- homekeeper-beekeeper.container
|
- homekeeper-beekeeper.container
|
||||||
- homekeeper-listkeeper.container
|
- homekeeper-listkeeper.container
|
||||||
notify:
|
notify:
|
||||||
- Reload systemd
|
- Reload homekeeper user systemd
|
||||||
- Restart homekeeper
|
- 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
|
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ Environment=DB_NAME={{ db_name }}
|
|||||||
Environment=DB_USER={{ db_user }}
|
Environment=DB_USER={{ db_user }}
|
||||||
Environment=DB_PASSWORD={{ db_password }}
|
Environment=DB_PASSWORD={{ db_password }}
|
||||||
Environment=ROOT_PATH=/api
|
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
|
AutoUpdate=registry
|
||||||
Label=io.containers.autoupdate=registry
|
Label=io.containers.autoupdate=registry
|
||||||
|
|
||||||
@@ -22,4 +29,4 @@ Restart=always
|
|||||||
TimeoutStartSec=120
|
TimeoutStartSec=120
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ ContainerName=homekeeper-beekeeper
|
|||||||
Network=homekeeper.network
|
Network=homekeeper.network
|
||||||
PublishPort=127.0.0.1:3838:3838
|
PublishPort=127.0.0.1:3838:3838
|
||||||
Environment=API_URL=http://homekeeper-api:8000
|
Environment=API_URL=http://homekeeper-api:8000
|
||||||
Environment=API_USER={{ api_user }}
|
|
||||||
Environment=API_PASS={{ api_pass }}
|
|
||||||
AutoUpdate=registry
|
AutoUpdate=registry
|
||||||
Label=io.containers.autoupdate=registry
|
Label=io.containers.autoupdate=registry
|
||||||
|
|
||||||
@@ -18,4 +16,4 @@ Restart=always
|
|||||||
TimeoutStartSec=120
|
TimeoutStartSec=120
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ After=network-online.target
|
|||||||
Image=docker.io/library/postgres:17
|
Image=docker.io/library/postgres:17
|
||||||
ContainerName=homekeeper-db
|
ContainerName=homekeeper-db
|
||||||
Network=homekeeper.network
|
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_DB={{ db_name }}
|
||||||
Environment=POSTGRES_USER={{ db_user }}
|
Environment=POSTGRES_USER={{ db_user }}
|
||||||
Environment=POSTGRES_PASSWORD={{ db_password }}
|
Environment=POSTGRES_PASSWORD={{ db_password }}
|
||||||
@@ -20,4 +20,4 @@ Restart=always
|
|||||||
TimeoutStartSec=120
|
TimeoutStartSec=120
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ Network=homekeeper.network
|
|||||||
PublishPort=127.0.0.1:3839:3839
|
PublishPort=127.0.0.1:3839:3839
|
||||||
Environment=PORT=3839
|
Environment=PORT=3839
|
||||||
Environment=API_URL=http://homekeeper-api:8000
|
Environment=API_URL=http://homekeeper-api:8000
|
||||||
Environment=API_USER={{ api_user }}
|
|
||||||
Environment=API_PASS={{ api_pass }}
|
|
||||||
AutoUpdate=registry
|
AutoUpdate=registry
|
||||||
Label=io.containers.autoupdate=registry
|
Label=io.containers.autoupdate=registry
|
||||||
|
|
||||||
@@ -19,4 +17,4 @@ Restart=always
|
|||||||
TimeoutStartSec=120
|
TimeoutStartSec=120
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
- name: Reload nginx
|
||||||
|
service:
|
||||||
|
name: nginx
|
||||||
|
state: reloaded
|
||||||
@@ -14,37 +14,63 @@
|
|||||||
state: absent
|
state: absent
|
||||||
notify: Reload nginx
|
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:
|
template:
|
||||||
src: homekeeper.conf.j2
|
src: home.friessn.de.conf.j2
|
||||||
dest: /etc/nginx/sites-available/homekeeper.conf
|
dest: /etc/nginx/sites-available/home.friessn.de
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
notify: Reload nginx
|
notify: Reload nginx
|
||||||
|
|
||||||
- name: Enable homekeeper nginx site
|
- name: Enable home.friessn.de nginx site
|
||||||
file:
|
file:
|
||||||
src: /etc/nginx/sites-available/homekeeper.conf
|
src: /etc/nginx/sites-available/home.friessn.de
|
||||||
dest: /etc/nginx/sites-enabled/homekeeper.conf
|
dest: /etc/nginx/sites-enabled/home.friessn.de
|
||||||
state: link
|
state: link
|
||||||
notify: Reload nginx
|
notify: Reload nginx
|
||||||
|
|
||||||
- name: Obtain Let's Encrypt certificates
|
- name: Obtain Let's Encrypt certificate for {{ domain }}
|
||||||
command: >
|
command: >
|
||||||
certbot --nginx -d {{ domain }} -d {{ gitea_domain }}
|
certbot --nginx -d {{ domain }}
|
||||||
--non-interactive --agree-tos -m {{ gitea_admin_email }}
|
--non-interactive --agree-tos -m {{ gitea_admin_email }}
|
||||||
--redirect
|
--redirect
|
||||||
args:
|
args:
|
||||||
creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem
|
creates: /etc/letsencrypt/live/{{ domain }}/fullchain.pem
|
||||||
notify: Reload nginx
|
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
|
- name: Enable nginx
|
||||||
service:
|
service:
|
||||||
name: nginx
|
name: nginx
|
||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
|
||||||
handlers:
|
|
||||||
- name: Reload nginx
|
|
||||||
service:
|
|
||||||
name: nginx
|
|
||||||
state: reloaded
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-19
@@ -3,7 +3,7 @@ map $http_upgrade $connection_upgrade {
|
|||||||
'' close;
|
'' close;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ── Main app: friessn.de ────────────────────────────────────────────────────
|
# ── Main app: {{ domain }} ───────────────────────────────────────────────────
|
||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name {{ domain }};
|
server_name {{ domain }};
|
||||||
@@ -57,21 +57,3 @@ server {
|
|||||||
proxy_send_timeout 86400s;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
@@ -4,18 +4,14 @@
|
|||||||
name:
|
name:
|
||||||
- podman
|
- podman
|
||||||
- podman-compose # for ad-hoc use; production uses quadlets
|
- podman-compose # for ad-hoc use; production uses quadlets
|
||||||
|
- slirp4netns # rootless networking / port publishing
|
||||||
|
- uidmap # rootless subuid/subgid mapping
|
||||||
state: present
|
state: present
|
||||||
update_cache: true
|
update_cache: true
|
||||||
|
|
||||||
- name: Create homekeeper data directories
|
# All app data lives in named Podman volumes (created implicitly on first
|
||||||
file:
|
# `podman run`/Quadlet start), not host bind mounts — avoids rootless UID
|
||||||
path: "{{ item }}"
|
# mapping headaches. Nothing to pre-create here.
|
||||||
state: directory
|
|
||||||
mode: "0750"
|
|
||||||
loop:
|
|
||||||
- "{{ homekeeper_data_dir }}"
|
|
||||||
- "{{ homekeeper_data_dir }}/pg_data"
|
|
||||||
- "{{ gitea_data_dir }}"
|
|
||||||
|
|
||||||
- name: Configure Gitea as additional registry
|
- name: Configure Gitea as additional registry
|
||||||
template:
|
template:
|
||||||
@@ -25,35 +21,46 @@
|
|||||||
notify: Restart podman services
|
notify: Restart podman services
|
||||||
|
|
||||||
- name: Login to Gitea container registry
|
- name: Login to Gitea container registry
|
||||||
|
become_user: "{{ deploy_user }}"
|
||||||
|
environment:
|
||||||
|
XDG_RUNTIME_DIR: "/run/user/{{ deploy_uid }}"
|
||||||
command: >
|
command: >
|
||||||
podman login {{ registry_host }}:{{ registry_port }}
|
podman login {{ registry_host }}
|
||||||
-u {{ registry_user }} -p {{ registry_token }}
|
-u {{ registry_user }} -p {{ registry_token }}
|
||||||
register: login_result
|
register: login_result
|
||||||
changed_when: "'Login Succeeded' in login_result.stdout"
|
changed_when: "'Login Succeeded' in login_result.stdout"
|
||||||
# Run this after Gitea is up and registry_token is set
|
# 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:
|
systemd:
|
||||||
name: podman-auto-update.timer
|
name: podman-auto-update.timer
|
||||||
enabled: true
|
enabled: true
|
||||||
state: started
|
state: started
|
||||||
|
scope: user
|
||||||
daemon_reload: true
|
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:
|
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: |
|
content: |
|
||||||
[Timer]
|
[Timer]
|
||||||
OnCalendar=
|
OnCalendar=
|
||||||
OnCalendar={{ autoupdate_schedule }}
|
OnCalendar={{ autoupdate_schedule }}
|
||||||
AccuracySec=1s
|
AccuracySec=1s
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
notify: Reload systemd
|
notify: Reload user systemd
|
||||||
|
|
||||||
handlers:
|
|
||||||
- name: Reload systemd
|
|
||||||
systemd:
|
|
||||||
daemon_reload: true
|
|
||||||
|
|
||||||
- name: Restart podman services
|
|
||||||
command: systemctl daemon-reload
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
[[registry]]
|
[[registry]]
|
||||||
location = "{{ registry_host }}:{{ registry_port }}"
|
location = "{{ registry_host }}"
|
||||||
insecure = false
|
insecure = false
|
||||||
|
|||||||
@@ -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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
|
|||||||
@@ -1,6 +1,20 @@
|
|||||||
.api_req <- function(api, path) {
|
.api_req <- function(api, path) {
|
||||||
httr2::request(paste0(api$base_url, 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) {
|
.api_check <- function(resp, path) {
|
||||||
|
|||||||
+4
-6
@@ -2,17 +2,15 @@
|
|||||||
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
|
run_app <- function(host = "0.0.0.0", port = as.integer(Sys.getenv("PORT", "3838"))) {
|
||||||
log_init()
|
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) {
|
server_fn <- function(input, output, session) {
|
||||||
log_info("session_start", session_id = session$token)
|
log_info("session_start", session_id = session$token)
|
||||||
session$onSessionEnded(function()
|
session$onSessionEnded(function()
|
||||||
log_info("session_end", session_id = session$token)
|
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)
|
server(input, output, session, db = api)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,15 @@
|
|||||||
server <- function(input, output, session, db) {
|
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)
|
screen_home_server("s_home", db = db)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -29,7 +29,7 @@ ui <- function() {
|
|||||||
),
|
),
|
||||||
shiny::div(
|
shiny::div(
|
||||||
class = "container-fluid p-3",
|
class = "container-fluid p-3",
|
||||||
screen_home_ui("s_home")
|
shiny::uiOutput("main_content")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border-bottom: 1px solid #dee2e6;
|
border-bottom: 1px solid #dee2e6;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
header h1 {
|
header h1 {
|
||||||
@@ -27,6 +31,17 @@
|
|||||||
color: #212529;
|
color: #212529;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#auth-area {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: #6c757d;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#auth-area a {
|
||||||
|
color: #0d6efd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 2rem 1.5rem;
|
padding: 2rem 1.5rem;
|
||||||
@@ -91,6 +106,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
<h1>Homekeeper</h1>
|
<h1>Homekeeper</h1>
|
||||||
|
<span id="auth-area"><a href="/api/v1/auth/login">Anmelden</a></span>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<h2>Apps</h2>
|
<h2>Apps</h2>
|
||||||
@@ -105,5 +121,19 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
<script>
|
||||||
|
fetch('/api/v1/auth/me', { credentials: 'same-origin' })
|
||||||
|
.then(function (res) { return res.ok ? res.json() : null; })
|
||||||
|
.then(function (data) {
|
||||||
|
if (!data) return;
|
||||||
|
var area = document.getElementById('auth-area');
|
||||||
|
area.textContent = 'Angemeldet als ' + data.username + ' – ';
|
||||||
|
var logout = document.createElement('a');
|
||||||
|
logout.href = '/api/v1/auth/logout';
|
||||||
|
logout.textContent = 'Abmelden';
|
||||||
|
area.appendChild(logout);
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user