Add homelab installer and services (allprox, authelia, lldap)

This commit is contained in:
Ezequiel C. 2026-09-01 18:32:56 +02:00
parent 68b23f1cbe
commit bb754cdd8c
32 changed files with 2356 additions and 1 deletions

131
services/README.md Normal file
View file

@ -0,0 +1,131 @@
# services
One folder per service. Each folder must contain a `metadata.json` describing the service
(including the Docker Compose definition used to deploy it). The folder may also contain any extra
files the service needs — a `Caddyfile`, a `Dockerfile`, static assets — which the installer copies
into the deploy directory.
## Folder conventions
- The folder name **must** equal the service `id` in `metadata.json` (lowercase kebab-case).
- Only folders that contain a valid `metadata.json` are treated as services by the installer.
- After adding, editing, or removing a service, regenerate the index:
```bash
cd installer
bun run catalog
```
This rewrites [`catalog.json`](catalog.json), which the installer uses as a one-request listing.
## Extra files
A service folder can contain files beyond `metadata.json` — for example the `allprox` service ships
a `Caddyfile` and a `portal/index.html`. On install/update the installer copies the whole folder
(except `metadata.json`) into `~/.homelab/services/<id>/`, preserving subdirectories.
Reference those files from `compose.volumes` with **relative** paths:
```json
"volumes": [
"./Caddyfile:/etc/caddy/Caddyfile:ro",
"./portal:/srv/portal:ro"
]
```
Because the generated `docker-compose.yml` lives in the same directory, `docker compose` resolves
the `./...` paths against it.
## Shared networks
Services that need to talk to each other (e.g. `allprox``authelia``lldap`) join an external
Docker network. Declare it at the top level of `metadata.json` and attach the service to it:
```json
"networks": { "homelab": { "external": true } },
"compose": {
"networks": ["homelab"]
}
```
The installer creates the external network automatically (if it doesn't already exist) before
running `docker compose up`.
## `metadata.json` reference
| Field | Required | Type | Description |
| --- | --- | --- | --- |
| `id` | ✅ | string | Unique kebab-case id, equal to the folder name. |
| `name` | ✅ | string | Human-readable name shown in the TUI. |
| `description` | ✅ | string | Short description. |
| `version` | ✅ | string | Version string for change detection on update. |
| `compose` | ✅ | object | A Docker Compose **service** definition (`image`, `ports`, `volumes`, `environment`, …). The installer wraps it in a generated `docker-compose.yml`. |
| `volumes` | — | object | Optional top-level named volumes to declare. |
| `networks` | — | object | Optional top-level networks to declare. |
| `env` | — | array | Variables the installer resolves for you (see below). |
| `category` | — | string | Grouping shown as a hint in the TUI. |
| `tags` | — | string[] | Free-form tags. |
| `icon` | — | string | URL to an icon. |
| `author` | — | string | Upstream author. |
| `license` | — | string | License of the deployed software. |
| `homepage` | — | string | Project homepage URL. |
| `documentation` | — | string | Documentation URL. |
| `dependsOn` | — | string[] | Ids of services that should be installed first (informational). |
| `notes` | — | string | Free-form notes shown by `info`. |
A machine-readable JSON Schema is available at [`docs/metadata-schema.json`](../docs/metadata-schema.json).
### `compose` (the service definition)
This object is the value you would normally put under a service key in `docker-compose.yml`. For
example:
```json
{
"image": "ghcr.io/example/myapp:1.0.0",
"container_name": "myapp",
"restart": "unless-stopped",
"ports": ["8080:8080"],
"volumes": ["myapp_data:/data"],
"environment": ["TZ=${TZ}"]
}
```
The installer turns it into:
```yaml
services:
myapp:
image: ghcr.io/example/myapp:1.0.0
# ...
```
Pin images to a tag so updates are predictable; the installer compares `version` to decide whether
an update is available.
### `env` (interactive variables)
Each entry describes a variable the installer should collect (and write to `.env`):
```json
{
"name": "WEBPASSWORD",
"label": "Web admin password",
"description": "Password for the web UI",
"default": "change-me",
"required": true,
"secret": true,
"options": []
}
```
- `name` (required) — the variable name. Reference it in `compose` as `${NAME}`.
- `label` / `description` — shown when prompting.
- `default` — used when the user accepts the default or runs non-interactively.
- `required` — a missing value is an error in non-interactive mode unless a `default` is set.
- `secret` — mask input at the prompt (e.g. passwords).
- `options` — if provided, the installer offers a fixed choice list instead of free text.
- `generate` — generate a random 64-char hex secret instead of prompting (for JWT/session secrets).
Docker Compose automatically reads the `.env` written next to the generated compose file, so
`${NAME}` references resolve at `docker compose up` time.

View file

@ -0,0 +1,63 @@
# allprox — Caddy reverse proxy
#
# Maps domains to local IPs and hosts an authenticated portal (web UI).
# Edit the domain names below to match your DNS, and set the upstream
# IP:port values in the installer's .env (APP1_UPSTREAM, APP2_UPSTREAM, ...).
# Duplicate a "Proxied services" block to add more services.
#
# Authentication:
# * Today: basic_auth (a single admin account; hash set via PORTAL_AUTH_HASH).
# * Later: Authelia SSO — replace the basic_auth blocks with the forward_auth
# block shown below (see https://www.authelia.com/integration/proxies/caddy/).
{
# For public domains with automatic HTTPS, set your ACME email here:
# email you@example.com
}
# ---------------------------------------------------------------------------
# Web UI — authenticated portal (change the domain to your own)
# ---------------------------------------------------------------------------
portal.example.com {
# `tls internal` issues a self-signed cert for LAN use. Remove this line
# and set the ACME email above when exposing a real public domain.
tls internal
basic_auth {
admin {$PORTAL_AUTH_HASH}
}
# Authelia SSO (later): replace the basic_auth block above with:
# forward_auth authelia:9091 {
# uri /api/authz/forward-auth
# copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
# }
root * /srv/portal
file_server
}
# ---------------------------------------------------------------------------
# Authelia portal — the SSO login page (proxy to the authelia container).
# With Authelia running, users are redirected here to sign in.
# ---------------------------------------------------------------------------
auth.example.com {
tls internal
reverse_proxy authelia:9091
}
# ---------------------------------------------------------------------------
# Proxied services — copy a block per service. The local IP:port comes from
# the {$APPx_UPSTREAM} variable in .env.
# ---------------------------------------------------------------------------
app1.example.com {
tls internal
# basic_auth { admin {$PORTAL_AUTH_HASH} } # or the Authelia forward_auth block
reverse_proxy {$APP1_UPSTREAM}
}
app2.example.com {
tls internal
# basic_auth { admin {$PORTAL_AUTH_HASH} }
reverse_proxy {$APP2_UPSTREAM}
}

View file

@ -0,0 +1,64 @@
{
"id": "allprox",
"name": "allprox",
"description": "Caddy reverse proxy that routes domains to local IPs, hosts an authenticated portal (web UI), and is SSO-ready for Authelia (OAuth2/OIDC).",
"version": "1.0.0",
"category": "network",
"tags": ["reverse-proxy", "caddy", "authelia", "sso", "https"],
"author": "Caddy / Authelia",
"license": "Apache-2.0",
"homepage": "https://caddyserver.com",
"documentation": "https://www.authelia.com/integration/proxies/caddy/",
"compose": {
"image": "caddy:2-alpine",
"container_name": "allprox",
"restart": "unless-stopped",
"ports": ["80:80", "443:443", "443:443/udp"],
"volumes": [
"./Caddyfile:/etc/caddy/Caddyfile:ro",
"./portal:/srv/portal:ro",
"allprox_data:/data",
"allprox_config:/config"
],
"environment": [
"APP1_UPSTREAM=${APP1_UPSTREAM}",
"APP2_UPSTREAM=${APP2_UPSTREAM}",
"PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"
],
"networks": ["homelab"]
},
"volumes": {
"allprox_data": {},
"allprox_config": {}
},
"networks": {
"homelab": { "external": true }
},
"env": [
{
"name": "PORTAL_AUTH_HASH",
"label": "Portal admin password (bcrypt hash)",
"description": "bcrypt hash for basic_auth (default is 'changeme'). Generate your own with: docker compose exec allprox caddy hash-password",
"default": "$2b$10$fKkpKSlwLZtOBXpInl8pG.8mS65kiEfjOVsuvBj7ikHgtfqEa7h4y",
"required": false,
"secret": true
},
{
"name": "APP1_UPSTREAM",
"label": "Upstream for app1.example.com",
"description": "Local IP:port to proxy app1.example.com to",
"default": "127.0.0.1:3000",
"required": false,
"secret": false
},
{
"name": "APP2_UPSTREAM",
"label": "Upstream for app2.example.com",
"description": "Local IP:port to proxy app2.example.com to",
"default": "127.0.0.1:8080",
"required": false,
"secret": false
}
],
"notes": "Edit services/allprox/Caddyfile to add domains and change upstreams. The portal (web UI) is at portal.example.com (change the domain). Interim auth is basic_auth (admin / 'changeme' by default) — swap to the Authelia forward_auth block in the Caddyfile for OAuth2/OIDC SSO."
}

View file

@ -0,0 +1,80 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>allprox — portal</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: #0f172a;
color: #e2e8f0;
margin: 0;
display: grid;
place-items: center;
min-height: 100vh;
}
main { width: 100%; max-width: 680px; padding: 2rem; }
h1 { font-size: 2rem; margin: 0 0 .25rem; letter-spacing: -.02em; }
.muted { color: #94a3b8; }
.card {
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
padding: 1.1rem 1.35rem;
margin: 1.25rem 0;
}
.card strong { display: block; margin-bottom: .25rem; }
ul { list-style: none; padding: 0; margin: 0; }
li {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: .55rem 0;
border-bottom: 1px solid #334155;
}
li:last-child { border-bottom: none; }
a { color: #7dd3fc; text-decoration: none; }
a:hover { text-decoration: underline; }
code {
background: #0b1220;
padding: .15rem .45rem;
border-radius: 5px;
font-size: .85em;
color: #cbd5e1;
}
.badge {
font-size: .7rem;
text-transform: uppercase;
letter-spacing: .06em;
color: #86efac;
border: 1px solid #166534;
background: #052e16;
padding: .15rem .5rem;
border-radius: 999px;
}
</style>
</head>
<body>
<main>
<h1>allprox <span class="badge">signed in</span></h1>
<p class="muted">Authenticated access portal — you are signed in.</p>
<div class="card">
<strong>Services</strong>
<ul>
<li><a href="https://app1.example.com">app1.example.com</a> <code>127.0.0.1:3000</code></li>
<li><a href="https://app2.example.com">app2.example.com</a> <code>127.0.0.1:8080</code></li>
</ul>
</div>
<p class="muted">
Edit this page at <code>services/allprox/portal/index.html</code> and the proxy
rules in <code>services/allprox/Caddyfile</code>.
</p>
</main>
</body>
</html>

View file

@ -0,0 +1,71 @@
# Authelia configuration — https://www.authelia.com/configuration/prologue/introduction/
#
# Secrets (JWT_SECRET, RESET_JWT_SECRET, SESSION_SECRET, LDAP_ADMIN_PASSWORD) are
# resolved from the service's .env by the installer and substituted into this file
# on install/update, so they are not committed here.
theme: dark
jwt_secret: '${JWT_SECRET}'
server:
address: 'tcp://0.0.0.0:9091/'
endpoints:
authz:
forward-auth:
implementation: 'ForwardAuth'
log:
level: info
totp:
issuer: 'homelab'
identity_validation:
reset_password:
jwt_secret: '${RESET_JWT_SECRET}'
authentication_backend:
password_reset:
disable: true
refresh_interval: '5m'
ldap:
implementation: 'lldap'
address: 'ldap://lldap:3890'
base_dn: 'dc=homelab,dc=local'
user: 'uid=admin,ou=people,dc=homelab,dc=local'
password: '${LDAP_ADMIN_PASSWORD}'
access_control:
default_policy: deny
rules:
- domain: 'auth.example.com'
policy: bypass
- domain: 'portal.example.com'
policy: one_factor
- domain: '*.example.com'
policy: one_factor
session:
name: 'authelia_session'
secret: '${SESSION_SECRET}'
expiration: '1h'
inactivity: '5m'
remember_me: '1M'
cookies:
- domain: 'example.com'
authelia_url: 'https://auth.example.com'
default_redirection_url: 'https://portal.example.com'
regulation:
max_retries: 3
find_time: '2m'
ban_time: '5m'
storage:
local:
path: '/config/db.sqlite3'
notifier:
filesystem:
filename: '/config/notification.txt'

View file

@ -0,0 +1,57 @@
{
"id": "authelia",
"name": "Authelia",
"description": "Open-source authentication and authorization server providing SSO and 2FA for the homelab.",
"version": "1.0.0",
"category": "identity",
"tags": ["sso", "authentication", "2fa", "oidc", "forward-auth"],
"author": "Authelia",
"license": "Apache-2.0",
"homepage": "https://www.authelia.com",
"documentation": "https://www.authelia.com/configuration/prologue/introduction/",
"compose": {
"image": "authelia/authelia:latest",
"container_name": "authelia",
"restart": "unless-stopped",
"ports": ["9091:9091"],
"volumes": [
"authelia_config:/config",
"./configuration.yml:/config/configuration.yml:ro",
"./users_database.yml:/config/users_database.yml:ro"
],
"networks": ["homelab"]
},
"volumes": {
"authelia_config": {}
},
"networks": {
"homelab": { "external": true }
},
"env": [
{
"name": "JWT_SECRET",
"label": "JWT secret (auto-generated)",
"generate": true
},
{
"name": "RESET_JWT_SECRET",
"label": "Reset-password JWT secret (auto-generated)",
"generate": true
},
{
"name": "SESSION_SECRET",
"label": "Session secret (auto-generated)",
"generate": true
},
{
"name": "LDAP_ADMIN_PASSWORD",
"label": "LDAP admin password",
"description": "Must match lldap's LLDAP_LDAP_USER_PASS",
"default": "changeme-admin",
"required": false,
"secret": true
}
],
"dependsOn": ["lldap"],
"notes": "Authelia's portal is normally reached through allprox at auth.example.com (see the allprox Caddyfile). The forward-auth endpoint is http://authelia:9091/api/authz/forward-auth. Default access-control rules protect portal.example.com and *.example.com — edit configuration.yml to match your domains."
}

View file

@ -0,0 +1,3 @@
# Local users (file backend). We authenticate against LLDAP, so this stays empty.
# Authelia still requires the file to exist.
users: {}

20
services/catalog.json Normal file
View file

@ -0,0 +1,20 @@
{
"generatedAt": "2026-09-01T16:20:56.550Z",
"services": [
{
"id": "allprox",
"version": "1.0.0",
"path": "services/allprox/metadata.json"
},
{
"id": "authelia",
"version": "1.0.0",
"path": "services/authelia/metadata.json"
},
{
"id": "lldap",
"version": "1.0.0",
"path": "services/lldap/metadata.json"
}
]
}

View file

@ -0,0 +1,66 @@
{
"id": "lldap",
"name": "LLDAP",
"description": "Lightweight LDAP server with a simple web UI for managing users and groups.",
"version": "1.0.0",
"category": "identity",
"tags": ["ldap", "authentication", "identity", "web-ui"],
"author": "LLDAP",
"license": "MIT",
"homepage": "https://github.com/lldap/lldap",
"documentation": "https://github.com/lldap/lldap#readme",
"compose": {
"image": "lldap/lldap:stable",
"container_name": "lldap",
"restart": "unless-stopped",
"ports": ["17170:17170", "3890:3890"],
"volumes": ["lldap_data:/data"],
"environment": [
"LLDAP_LDAP_BASE_DN=${LLDAP_LDAP_BASE_DN}",
"LLDAP_LDAP_USER_DN=admin",
"LLDAP_LDAP_USER_PASS=${LLDAP_LDAP_USER_PASS}",
"LLDAP_LDAP_USER_EMAIL=${LLDAP_LDAP_USER_EMAIL}",
"LLDAP_JWT_SECRET=${LLDAP_JWT_SECRET}",
"LLDAP_LDAP_PORT=3890",
"LLDAP_HTTP_PORT=17170"
],
"networks": ["homelab"]
},
"volumes": {
"lldap_data": {}
},
"networks": {
"homelab": { "external": true }
},
"env": [
{
"name": "LLDAP_LDAP_BASE_DN",
"label": "LDAP base DN",
"description": "Base DN for users and groups (keep in sync with Authelia)",
"default": "dc=homelab,dc=local",
"required": false,
"secret": false
},
{
"name": "LLDAP_LDAP_USER_PASS",
"label": "Admin password",
"description": "Admin bind password — must match authelia's LDAP_ADMIN_PASSWORD",
"default": "changeme-admin",
"required": false,
"secret": true
},
{
"name": "LLDAP_LDAP_USER_EMAIL",
"label": "Admin email",
"default": "admin@homelab.local",
"required": false,
"secret": false
},
{
"name": "LLDAP_JWT_SECRET",
"label": "JWT secret (auto-generated)",
"generate": true
}
],
"notes": "Web UI at http://<host>:17170 — log in with 'admin' and your admin password. LDAP endpoint is ldap://lldap:3890 (base DN dc=homelab,dc=local). Create users and groups here; Authelia authenticates against them."
}