# 105 — `apps`

Docker host for everything that doesn't justify its own LXC. Currently runs Artifacto, PlantUML server, Portainer (and historically WriteFreely / blog), plus the [homelab-context distribution services](../infrastructure/homelab-context.md) (MCP + secrets-issuance) since 2026-05-20. Booklore migrated to [grimmory (130)](130-grimmory.md) on 2026-06-29.

## At a glance
- **Hostname:** `apps`
- **IP:** `192.168.8.205`
- **Privilege:** privileged (so container UID = host UID for bind-mount sanity)
- **Resources:** 2 cores / 4 GiB RAM / 30 GiB rootfs
- **Mounts:** `/mnt/library` ↔ `/mnt/library`
- **Public hostnames:** see table below

## Public hostname → service map

| Hostname                          | Container        | Backend port | Notes |
| --------------------------------- | ---------------- | ------------ | ----- |
| `docker.hubris.network`           | Portainer        | `:9443`      | Native OAuth2 via Authentik. Trusted-origins requires hostname only (no scheme/port). |
| `artifacto.hubris.network`        | Artifacto        | `:3100`      | Public `/p/*`, `/static/*`, `/healthz` exposed via [VPS traefik](../infrastructure/ingress.md). |
| `blog.hubris.network`             | WriteFreely      | `:8080`      | Native OIDC via `[oauth.generic]`. |
| `git.hubris.network/_plantuml/*`  | PlantUML server  | `:8079`      | Same-origin route from [gitea (104)](104-gitea.md). |
| `mcp.hubris.network`              | homelab-mcp      | `:9810`      | MCP server over SSE. No SSO — agent-facing, gated at source-IP layer. See [homelab-context](../infrastructure/homelab-context.md). |
| `secrets.hubris.network`          | secrets-issuance | `:9820`      | Per-client age-key issuance. Mesh+LAN source-IP gated (`MESH_SUBNETS`). |

## Stacks

### Artifacto (`/opt/artifacto/`)
Self-hosted HTML-artifact share (Go + SQLite). Repo `dtoro/Artifacto` is the working tree itself — `git push origin main` triggers rebuild.
- Compose: tracked `docker-compose.yml` binds `./data:/data` and `127.0.0.1:3100:3000`. `docker-compose.override.yml` uses the `!override` YAML tag on `volumes:` and `ports:` to redirect `data` to `/mnt/library/documents/artifacts`.
- Data dir: `/mnt/library/documents/artifacts` — owner **UID 10001 : GID 10001** (matches the `app` user baked into the Dockerfile), mode 750. Parent `/mnt/library/documents` stays `www-data:www-data 750`; bind targets the child directly so no traversal needed.
- `.env` (untracked) holds `ADMIN_PASSWORD`, `SESSION_SECRET` (`openssl rand -hex 32`), `BASE_URL=https://artifacto.hubris.network`, `SSO_GATEWAY_SECRET` (must match `ARTIFACTO_GATEWAY_SECRET` in `/etc/caddy/caddy.env` on [caddy](121-caddy.md)). Don't `git checkout .env`.
- Auth: Authentik forward-auth on the admin surface (`/`, `/login`, `/a/*`, `/api/*`). `X-Authentik-Username` is honored only when the `X-Artifacto-Gateway` header matches `SSO_GATEWAY_SECRET` — prevents direct LAN connections from spoofing identity.
- Public exposure: VPS traefik gates path scope to share URLs; admin surface returns 404 at the edge. See [ingress](../infrastructure/ingress.md).

#### Auto-deploy pipeline (Artifacto)
Receiver at `/opt/artifacto-deploy/` (outside the app repo): `deploy.sh` + `webhook.py`, secret in `/etc/artifacto-deploy/secret`, systemd unit `artifacto-deploy-webhook.service`. Listens on `0.0.0.0:9798`, validates `X-Gitea-Signature` HMAC-SHA256, filters to `refs/heads/main`, runs deploy in a daemon thread (returns 202 immediately). Gitea webhook id 7 on `dtoro/Artifacto`.

- Logs: `pct exec 105 -- journalctl -u artifacto-deploy-webhook -f`
- Manual deploy: `pct exec 105 -- /opt/artifacto-deploy/deploy.sh`
- Health: `pct exec 105 -- curl -s http://127.0.0.1:9798/health` → `ok`
- Slug length bumped 5 → 8 chars (in `internal/store/artifacts.go:41`) once the endpoint went public.

### PlantUML (`/opt/plantuml/`)
`plantuml/plantuml-server:jetty` listening on `192.168.8.205:8079`, context path `/plantuml` (`BASE_URL=plantuml`). Reached only via [gitea](104-gitea.md)'s caddy `handle_path /_plantuml/*` rewrite — no DNS record of its own. Same-origin from gitea so the embedded markdown previewer can call it without CORS.

### Portainer
Native OAuth2 (Settings → Authentication → OAuth → Custom). Manual endpoints (no OIDC discovery). Uses `portainer-uid` custom-claim scope from Authentik. Container is **not** compose-managed — safe to `docker run` recreate; data lives in named volume `portainer_data`. CLI flag: `--trusted-origins docker.hubris.network` (hostname only — `IsTrustedOrigin` rejects strings containing `://`).

### homelab-mcp (`/opt/homelab-mcp/`) — DEPRECATED (Go rewrite, Phase 6)

> **Status:** This Python MCP server is being replaced by the Go `oikos api` binary
> running in Docker on mac-mini. Cutover pending — see
> [scripts/cutover-checklist.md](../../scripts/cutover-checklist.md) for the
> execution plan. The Go MCP uses the official MCP Go SDK (Streamable HTTP, not
> FastMCP) with 15 tools including `get_blast_radius`, `request_execution`, and
> `get_agent_activity`. Source: `internal/mcp/server.go`.

**Current (Python) implementation — DO NOT MODIFY, awaiting cutover:**
FastMCP server (Python venv at `/opt/homelab-mcp/.venv`). Reads from
`/opt/homelab-context/` (this LXC is itself an enrolled
[homelab-context](../infrastructure/homelab-context.md) client). Source
is `dtoro/Homelab-Docs/mcp/server.py`; service unit
`homelab-mcp.service` binds `0.0.0.0:9810`. DNS-rebinding protection
disabled at the FastMCP layer because mesh+LAN gating is the actual
trust boundary.

- Endpoint: `https://mcp.hubris.network/mcp` (Caddy → `:9810`). StreamableHTTP transport (POST `/mcp`).
- 14 tools registered: `get_host`, `list_services`, `find_service`,
  `get_topology`, `search_docs`, `get_page`, `get_changelog`, `whoami`,
  `list_my_secrets` (context); `get_service_status`, `tail_log`,
  `list_lxcs`, `get_lxc_state`, `ping_service` (read-only management).
- Management tools shell out via SSH using a dedicated `mcp-reader` key
  (planned; not provisioned yet — management tools fail until then).

#### Auto-deploy pipeline (homelab-mcp)
Receiver at `/opt/homelab-mcp/mcp/deploy/webhook/` (in-tree alongside
the server code). Listens on `0.0.0.0:9811`, secret in
`/etc/homelab-mcp-deploy/secret`, systemd unit
`homelab-mcp-deploy.service`. Gitea webhook id 10 on
`dtoro/Homelab-Docs`. Deploy script reinstalls the service unit and
restarts on push.

### secrets-issuance (`/opt/secrets-issuance/`) — DEPRECATED (Go Phase 5)

> **Status:** Replaced by `internal/secrets/` in the Go rewrite. Machine identities
> are now managed via Infisical (`docker compose --profile infisical up`).
Tiny HTTP service that issues per-client age keypairs the first time
each client calls `/issue`. Idempotent: subsequent calls return the
same key. Mesh+LAN source-IP gated via the `MESH_SUBNETS` env in
`secrets-issuance.service`. State (per-client keys + denylist) lives at
`/var/lib/secrets-issuance/` (NOT in the repo).

- Endpoint: `https://secrets.hubris.network/issue` (Caddy → `:9820`).
- `/revoke` is admin-token-gated by `/etc/secrets-issuance/admin-token`;
  shreds the local key file and adds the hostname to the denylist.
  Called by `homelab client remove`.
- Trust subnets today: `100.122.0.0/16` (Netbird), `100.64.0.0/10`
  (Tailscale), `192.168.8.0/24` (LAN). Tighten if the LAN gets
  untrusted devices.

#### Auto-deploy pipeline (secrets-issuance)
Receiver at `/opt/secrets-issuance/secrets-issuance/deploy/webhook/`,
port `9821`, secret in `/etc/secrets-issuance-deploy/secret`, unit
`secrets-issuance-deploy.service`. Gitea webhook id 11.

### WriteFreely (`/opt/writefreely-docker/`)
Native OIDC via `[oauth.generic]` in `config/config.ini`. `host = https://auth.hubris.network`, scope `openid profile email`, `map_user_id = sub`. Redirect URI in Authentik: `https://blog.hubris.network/oauth/callback/generic`. Container has `extra_hosts: auth.hubris.network:192.168.8.175`. First-time users with a local WF account must connect SSO via Customize/Settings → Linked Accounts.

## Permissions
- LXC is privileged → in-container UID == host UID.
- Member of [media GID 10000](../infrastructure/media-permissions.md). Adding `media` to the LXC alone is *not* enough for inner Docker containers — each container needs `--group-add 10000` or `user: "<uid>:10000"` to write into `/mnt/library`.

## Related
- [Gitea (104)](104-gitea.md) — uses the PlantUML server
- [Caddy (121)](121-caddy.md)
- [Authentik (124)](106-auth-outpost.md)
- [DNS](../infrastructure/dns.md)
- [Auto-deploy](../infrastructure/auto-deploy.md)
- [Public ingress (Artifacto + blog)](../infrastructure/ingress.md)

## Changelog

### 2026-06-29 — Booklore migrated to Grimmory on LXC 130
Booklore stack removed from Portainer. MariaDB dump taken first, then restored into [grimmory (130)](130-grimmory.md)'s fresh MariaDB. `books.hubris.network` Caddy backend updated to `192.168.8.213:6060`. Authentik OIDC provider updated to Public client type (PKCE) for Grimmory compatibility.

### 2026-05-20 — homelab-mcp + secrets-issuance live
Two new services from the [homelab-context distribution plan](../infrastructure/homelab-context.md):
`homelab-mcp.service` on `:9810` (MCP read+management surface) and
`secrets-issuance.service` on `:9820` (per-client age-key provisioning).
Caddy fronts both with Let's Encrypt; new vhosts on
[caddy](121-caddy.md), split-horizon DNS entries on
[authentik (124)](106-auth-outpost.md). Gitea webhook ids 10 + 11 wire
auto-deploy. LXC is itself an enrolled context client
(`/opt/homelab-context/`).

### 2026-04-28 — wiki entry created
Initial documentation.

### 2026-04-22 — Artifacto deployed + Authentik gateway-secret auto-login wired
Repo `dtoro/Artifacto`. Compose, data dir, `.env`, webhook (id 7), Caddyfile site block, gateway-secret pattern, public-internet share exposure on the VPS — all set up that day. Slug length bumped 5 → 8 for brute-force resistance.

### 2026-04-22 — Booklore mariadb data wiped (lesson, not a feature)
`docker compose up -d` from the host shell on a Portainer-managed stack recreated services and re-initialized the bind-mounted `./mariadb/config`. Recovered by reseeding from snapshot. Rule recorded: edit Portainer-managed stacks via the Portainer UI.

### 2026-04-22 — Portainer wired into Authentik (native OAuth2)
Custom-claim `portainer_uid` scope. CLI added `--trusted-origins docker.hubris.network`.

### 2026-04-22 — `app.ini` host whitelist on gitea extended
Added `192.168.8.205` so the Artifacto webhook delivery wouldn't be rejected. See [gitea (104)](104-gitea.md).

### 2026-04-21 — Booklore + WriteFreely wired into Authentik OIDC
Native OIDC for both. `extra_hosts` override added inside each container so DNS resolves `auth.hubris.network` to the Caddy IP.

### 2026-04-20 — PlantUML server deployed
Used by [gitea (104)](104-gitea.md)'s footer.tmpl injection. Same-origin via caddy `handle_path /_plantuml/*` rewrite — no separate hostname.
