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

19
.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# dependencies
node_modules/
# build output
dist/
build/
# environment files (keep examples)
.env
.env.*
!.env.example
# local installer state
.homelab/
# OS noise
.DS_Store
Thumbs.db
*.log

167
README.md
View file

@ -1 +1,168 @@
# homelab # homelab
A common, reusable repository for running services on a personal homelab.
Every service lives in its own folder under [`services/`](services/) and is described by a
`metadata.json` file (plus any extra files the service needs, such as a `Caddyfile`). The
[`installer/`](installer/) directory contains a Bun + TypeScript
terminal UI (TUI) that reads the catalog straight from GitHub (used as the CDN) and installs or
updates services with Docker.
```
homelab/
├── services/ # one folder per service
│ ├── catalog.json # generated index used by the installer (CDN listing)
│ ├── allprox/ # Caddy reverse proxy + portal
│ │ ├── metadata.json
│ │ ├── Caddyfile # proxy rules (domains → local IPs)
│ │ └── portal/index.html
│ ├── authelia/ # SSO / IdP (login portal, OIDC, forward-auth)
│ │ ├── metadata.json
│ │ ├── configuration.yml
│ │ └── users_database.yml
│ ├── lldap/ # lightweight LDAP user store
│ │ └── metadata.json
│ └── ...
├── installer/ # Bun + TypeScript TUI
│ ├── src/
│ └── scripts/
└── docs/
└── metadata-schema.json
```
## Identity stack (SSO)
Three services work together for single sign-on:
```
browser ──▶ allprox (Caddy) ──forward_auth──▶ authelia ──LDAP──▶ lldap
```
- [`allprox`](services/allprox) — reverse proxy; protected routes use Caddy `forward_auth` to Authelia.
- [`authelia`](services/authelia) — the SSO server (login portal, OIDC, 2FA) that authenticates users against…
- [`lldap`](services/lldap) — the lightweight LDAP directory (web UI at `http://<host>:17170`).
They talk to each other over a shared external Docker network named `homelab`, which the installer
creates automatically. Install `lldap` first, then `authelia`, then `allprox`:
```bash
bun run src/index.ts install lldap authelia allprox
```
## Quick start
```bash
# 1. Install Bun: https://bun.sh
# 2. Install the installer's dependencies
cd installer
bun install
# 3. Launch the TUI
bun run src/index.ts
```
The TUI lets you browse the catalog, install services, update them, uninstall them, and inspect
what is currently running. Services are deployed with `docker compose` into `~/.homelab/services/<id>/`
(a `docker-compose.yml` and a `.env` are generated from each `metadata.json`).
> **Requirement:** Docker must be installed and the `docker` command available on your `PATH`.
## Non-interactive CLI
The same app works as a plain CLI:
```bash
bun run src/index.ts list
bun run src/index.ts info allprox
bun run src/index.ts install allprox
bun run src/index.ts update # update everything installed
bun run src/index.ts update allprox # update one service
bun run src/index.ts uninstall allprox
bun run src/index.ts status
```
Useful flags:
| Flag | Meaning |
| --- | --- |
| `--local <path>` | Read services from a local folder instead of GitHub (handy while developing a new service). |
| `--owner/--repo/--branch` | Override the GitHub source (also available as `HOMELAB_OWNER`, `HOMELAB_REPO`, `HOMELAB_BRANCH`). |
| `--env KEY=VALUE` | Provide an environment value non-interactively (repeatable). |
| `--yes` | Never prompt; use defaults or provided `--env` values. |
| `--dry-run` | Print what would happen (including the generated compose file) without touching Docker. |
| `--force` | Re-run an update even if versions already match. |
| `--verbose` | Stream `docker compose` output instead of only showing failures. |
## How the CDN works
The installer treats this GitHub repository as its content delivery network:
1. It lists services from [`services/catalog.json`](services/catalog.json) (one request). If that
file is missing it falls back to the GitHub `git/trees` API.
2. Each service's `metadata.json` is fetched from
`https://raw.githubusercontent.com/<owner>/<repo>/<branch>/services/<id>/metadata.json`.
Pointing the installer at your own fork is just a matter of setting `--owner`/`--repo` (or the
`HOMELAB_*` environment variables). For a **private** repository, export a GitHub token and the
installer authenticates its API and raw requests automatically: `HOMELAB_GITHUB_TOKEN` (or
`GITHUB_TOKEN` / `HOMELAB_TOKEN`). The repository must be pushed — the installer reads from GitHub,
not from your local checkout.
Adding a new service means adding a folder plus its `metadata.json` and regenerating the catalog:
```bash
cd installer
bun run catalog
```
## Adding a service
1. Create `services/<id>/metadata.json` (the folder name **must** equal the `id`).
2. Fill in the [metadata schema](docs/metadata-schema.json) — see [`services/README.md`](services/README.md)
for a walkthrough of every field. Add any extra files the service needs (a `Caddyfile`, a
`Dockerfile`, a static `portal/`, …) alongside `metadata.json`; the installer copies them into
the deploy directory, so reference them with relative bind mounts in `compose.volumes`.
3. Regenerate the catalog: `cd installer && bun run catalog`.
4. Commit and push. The installer will now offer the new service.
## Service metadata at a glance
`metadata.json` is the single source of truth for a service. The essential shape:
```json
{
"id": "allprox",
"name": "allprox",
"description": "Caddy reverse proxy with an SSO-ready portal",
"version": "1.0.0",
"category": "network",
"compose": {
"image": "caddy:2-alpine",
"container_name": "allprox",
"restart": "unless-stopped",
"ports": ["80:80", "443:443"],
"volumes": ["./Caddyfile:/etc/caddy/Caddyfile:ro", "allprox_data:/data"],
"environment": ["PORTAL_AUTH_HASH=${PORTAL_AUTH_HASH}"]
},
"volumes": { "allprox_data": {} },
"env": [
{ "name": "PORTAL_AUTH_HASH", "label": "Portal admin password hash", "secret": true }
]
}
```
- `compose` is a standard Docker Compose *service* definition (image, ports, volumes, environment, …).
The installer wraps it in a generated `docker-compose.yml`.
- `volumes` / `networks` are optional top-level named volumes/networks to declare.
- `env` declares variables the installer should resolve for you. Use `${NAME}` in `compose` to
reference them — the installer writes the resolved values to a `.env` file next to the compose file.
- Any other files in the service folder (e.g. `Caddyfile`, `portal/index.html`) are copied into the
deploy directory, so you can mount them with relative paths (`./Caddyfile:...`) in `compose.volumes`.
See [`docs/metadata-schema.json`](docs/metadata-schema.json) for the complete, machine-readable schema.
## License
This repository is a personal template. Service entries reference their upstream projects; each
service's own license applies to the software it deploys.

68
docs/metadata-schema.json Normal file
View file

@ -0,0 +1,68 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://raw.githubusercontent.com/ReinadoRojo/homelab/master/docs/metadata-schema.json",
"title": "Homelab Service Metadata",
"description": "Schema for a service's metadata.json in a homelab repository.",
"type": "object",
"required": ["id", "name", "description", "version", "compose"],
"additionalProperties": true,
"properties": {
"id": {
"type": "string",
"description": "Unique kebab-case id. Must equal the folder name.",
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"name": {
"type": "string",
"description": "Human-readable service name."
},
"description": {
"type": "string",
"description": "Short description of the service."
},
"version": {
"type": "string",
"description": "Version string used to detect updates."
},
"compose": {
"type": "object",
"description": "A Docker Compose service definition (image, ports, volumes, environment, ...)."
},
"volumes": {
"type": "object",
"description": "Top-level named volumes to declare."
},
"networks": {
"type": "object",
"description": "Top-level networks to declare."
},
"env": {
"type": "array",
"description": "Variables the installer resolves and writes to .env.",
"items": {
"type": "object",
"required": ["name"],
"additionalProperties": false,
"properties": {
"name": { "type": "string" },
"label": { "type": "string" },
"description": { "type": "string" },
"default": { "type": "string" },
"required": { "type": "boolean" },
"secret": { "type": "boolean" },
"options": { "type": "array", "items": { "type": "string" } },
"generate": { "type": "boolean" }
}
}
},
"category": { "type": "string" },
"tags": { "type": "array", "items": { "type": "string" } },
"icon": { "type": "string", "format": "uri" },
"author": { "type": "string" },
"license": { "type": "string" },
"homepage": { "type": "string", "format": "uri" },
"documentation": { "type": "string", "format": "uri" },
"dependsOn": { "type": "array", "items": { "type": "string" } },
"notes": { "type": "string" }
}
}

4
installer/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.tgz
.env

73
installer/README.md Normal file
View file

@ -0,0 +1,73 @@
# homelab installer
A terminal UI (and CLI) written in TypeScript for [Bun](https://bun.sh) that installs and updates
homelab services from this repository's GitHub-hosted catalog.
## Install
```bash
cd installer
bun install
```
## Run
```bash
bun run src/index.ts # interactive TUI
bun run src/index.ts list # list the catalog
bun run src/index.ts install portainer
bun run src/index.ts update
bun run src/index.ts uninstall portainer
bun run src/index.ts status
bun run src/index.ts info pihole
```
Run `bun run src/index.ts --help` for the full flag reference.
## Layout
```
installer/
├── package.json
├── tsconfig.json
├── src/
│ ├── index.ts # entry point + CLI argument parsing
│ ├── tui.ts # interactive menu (powered by @clack/prompts)
│ ├── commands.ts # install/update/uninstall/list/status/info actions
│ ├── github.ts # GitHub CDN: catalog listing + raw metadata fetch
│ ├── metadata.ts # metadata parsing + validation
│ ├── compose.ts # docker-compose.yml generation from metadata
│ ├── env.ts # env var resolution + .env read/write
│ ├── docker.ts # `docker compose` runner
│ ├── state.ts # installed-state persistence (~/.homelab/state.json)
│ ├── paths.ts # config/data directory helpers
│ ├── prompts.ts # @clack prompt wrappers
│ ├── config.ts # source configuration (owner/repo/branch/local)
│ └── util.ts # colors + table printer
└── scripts/
└── generate-catalog.ts # regenerate services/catalog.json
```
## How it works
1. **List** — fetch `services/catalog.json` from GitHub (falling back to the `git/trees` API).
2. **Fetch** — download each selected `metadata.json` from `raw.githubusercontent.com`.
3. **Resolve env** — prompt for declared variables (or use defaults / `--env` values).
4. **Generate** — write `~/.homelab/services/<id>/docker-compose.yml` + `.env`.
5. **Deploy** — run `docker compose up -d` (or `pull` + `up -d` for updates).
6. **Record** — persist the installed version in `~/.homelab/state.json`.
Set `HOMELAB_HOME` to relocate `~/.homelab`. For a private GitHub repository, set
`HOMELAB_GITHUB_TOKEN` (or `GITHUB_TOKEN` / `HOMELAB_TOKEN`) to a personal access token so the
installer can read the catalog and metadata.
## Developing services locally
Point the installer at your checkout while you write a new `metadata.json`:
```bash
bun run src/index.ts list --local ../services
bun run src/index.ts install my-service --local ../services --dry-run
```
`--dry-run` prints the generated `docker-compose.yml` without running Docker.

82
installer/bun.lock Normal file
View file

@ -0,0 +1,82 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "homelab-installer",
"dependencies": {
"@clack/prompts": "^1.7.0",
"yaml": "^2.9.0",
},
"devDependencies": {
"@types/bun": "^1.4.0",
"typescript": "^7.0.2",
},
},
},
"packages": {
"@clack/core": ["@clack/core@1.4.3", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ=="],
"@clack/prompts": ["@clack/prompts@1.7.0", "", { "dependencies": { "@clack/core": "1.4.3", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A=="],
"@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="],
"@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="],
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
"undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="],
"yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
}
}

23
installer/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "homelab-installer",
"version": "1.0.0",
"description": "Interactive TUI to install and update homelab services from a GitHub-hosted catalog.",
"type": "module",
"bin": {
"homelab": "./src/index.ts"
},
"scripts": {
"start": "bun run src/index.ts",
"dev": "bun --watch src/index.ts",
"catalog": "bun run scripts/generate-catalog.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@clack/prompts": "^1.7.0",
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/bun": "^1.4.0",
"typescript": "^7.0.2"
}
}

View file

@ -0,0 +1,30 @@
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Catalog, ServiceMetadata } from "../src/types.ts";
const here = import.meta.dir;
const repoRoot = join(here, "..", "..");
const servicesDir = join(repoRoot, "services");
const services: Catalog["services"] = [];
for (const name of readdirSync(servicesDir).sort()) {
const metaPath = join(servicesDir, name, "metadata.json");
if (!statSync(metaPath, { throwIfNoEntry: false })?.isFile()) continue;
const meta = JSON.parse(readFileSync(metaPath, "utf8")) as ServiceMetadata;
services.push({
id: meta.id ?? name,
version: meta.version ?? "unknown",
path: `services/${name}/metadata.json`,
});
}
const catalog: Catalog = {
generatedAt: new Date().toISOString(),
services,
};
const out = join(servicesDir, "catalog.json");
writeFileSync(out, `${JSON.stringify(catalog, null, 2)}\n`, "utf8");
console.log(`Wrote ${out} with ${services.length} services.`);

240
installer/src/commands.ts Normal file
View file

@ -0,0 +1,240 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import type { ActionResult, InstalledService, ServiceMetadata } from "./types.ts";
import type { Config } from "./config.ts";
import { fetchMetadata, fetchServiceFiles, listServices, type ServiceFile } from "./github.ts";
import { generateComposeFile } from "./compose.ts";
import { collectEnvVars, envFileContent, readEnvFile } from "./env.ts";
import { isDockerAvailable, runCommand, runDockerCompose, type RunResult } from "./docker.ts";
import { loadState, markInstalled, removeInstalled } from "./state.ts";
import { serviceDir } from "./paths.ts";
import * as ui from "./util.ts";
/** Fetch every service in the catalog (skipping any that fail to parse). */
export async function loadCatalog(cfg: Config): Promise<ServiceMetadata[]> {
const entries = await listServices(cfg);
const metas = await Promise.all(
entries.map(async (e) => {
try {
return await fetchMetadata(e.id, cfg);
} catch (err) {
ui.warn(`Skipping ${e.id}: ${(err as Error).message}`);
return null;
}
}),
);
return metas.filter((m): m is ServiceMetadata => m !== null);
}
/**
* Resolve env vars for a service. Interactive prompting only happens when
* stdout is a TTY and `--yes` was not passed.
*/
export async function resolveEnv(
meta: ServiceMetadata,
cfg: Config,
existing?: Record<string, string>,
): Promise<Record<string, string>> {
const interactive = !cfg.yes && Boolean(process.stdout.isTTY);
const provided = { ...(existing ?? {}), ...cfg.env };
return collectEnvVars(meta, { interactive, provided });
}
export async function installService(
meta: ServiceMetadata,
cfg: Config,
preEnv?: Record<string, string>,
): Promise<ActionResult> {
const dir = serviceDir(meta.id);
const vars = preEnv ?? (await resolveEnv(meta, cfg));
const composeContent = generateComposeFile(meta);
if (cfg.dryRun) {
ui.info(`[dry-run] Would install ${meta.name} (${meta.version}) into ${dir}`);
const envNames = Object.keys(vars);
ui.info(
`[dry-run] Environment: ${envNames.length > 0 ? envNames.join(", ") : "(none)"}`,
);
console.log(
ui.dim(`\n--- docker-compose.yml ---\n${composeContent}----------------------------`),
);
const files = await fetchServiceFiles(meta.id, cfg);
ui.info(
`[dry-run] Files: ${files.length > 0 ? files.map((f) => f.path).join(", ") : "(none)"}`,
);
return { ok: true, message: `dry-run: prepared ${meta.id}` };
}
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
await requireDocker();
await ensureExternalNetworks(meta, cfg);
const r = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
if (r.code !== 0) {
printDockerFailure(r);
throw new Error(`docker compose up failed for ${meta.id}`);
}
markInstalled(meta.id, meta.name, meta.version);
return { ok: true, message: `${meta.name} v${meta.version} installed` };
}
export async function updateService(
installed: InstalledService,
meta: ServiceMetadata,
cfg: Config,
preEnv?: Record<string, string>,
): Promise<ActionResult> {
const dir = serviceDir(meta.id);
const vars = preEnv ?? (await resolveEnv(meta, cfg, readEnvFile(dir)));
const composeContent = generateComposeFile(meta);
if (cfg.dryRun) {
ui.info(
`[dry-run] Would update ${meta.name} from ${installed.version} to ${meta.version}`,
);
return { ok: true, message: `dry-run: update ${meta.id}` };
}
writeFileSync(join(dir, "docker-compose.yml"), composeContent, "utf8");
writeFileSync(join(dir, ".env"), envFileContent(vars), "utf8");
materializeFiles(dir, await fetchServiceFiles(meta.id, cfg), vars);
await requireDocker();
await ensureExternalNetworks(meta, cfg);
const pull = await runDockerCompose(dir, ["pull"], !cfg.verbose);
if (pull.code !== 0) {
printDockerFailure(pull);
throw new Error(`docker compose pull failed for ${meta.id}`);
}
const up = await runDockerCompose(dir, ["up", "-d"], !cfg.verbose);
if (up.code !== 0) {
printDockerFailure(up);
throw new Error(`docker compose up failed for ${meta.id}`);
}
markInstalled(meta.id, meta.name, meta.version);
return { ok: true, message: `${meta.name} updated to v${meta.version}` };
}
export async function uninstallService(id: string, cfg: Config): Promise<ActionResult> {
const dir = serviceDir(id);
if (cfg.dryRun) {
ui.info(`[dry-run] Would uninstall ${id} (data volumes preserved)`);
return { ok: true, message: `dry-run: uninstall ${id}` };
}
await requireDocker();
const r = await runDockerCompose(dir, ["down"], !cfg.verbose);
if (r.code !== 0) {
ui.warn(`docker compose down reported an error for ${id}; removing local files anyway.`);
}
rmSync(dir, { recursive: true, force: true });
removeInstalled(id);
return { ok: true, message: `${id} uninstalled` };
}
export function printStatus(): void {
const state = loadState();
const rows = Object.values(state.services).map((s) => [
s.id,
s.name,
s.version,
s.updatedAt.slice(0, 19).replace("T", " "),
]);
if (rows.length === 0) {
ui.info("No services installed yet.");
return;
}
ui.table(["ID", "NAME", "VERSION", "UPDATED"], rows);
}
export async function printAvailable(cfg: Config): Promise<void> {
const metas = await loadCatalog(cfg);
const state = loadState();
if (metas.length === 0) {
ui.info("No services found in the catalog.");
return;
}
const rows = metas.map((m) => [
m.id,
m.name,
m.version,
state.services[m.id] ? "installed" : "",
]);
ui.table(["ID", "NAME", "VERSION", "STATUS"], rows);
}
export async function printInfo(id: string, cfg: Config): Promise<void> {
if (!id) {
ui.error("info requires a service id.");
return;
}
const meta = await fetchMetadata(id, cfg);
console.log(ui.bold(`${meta.name} (${meta.id}) v${meta.version}`));
console.log(meta.description);
if (meta.homepage) console.log(ui.cyan(`Homepage: ${meta.homepage}`));
if (meta.documentation) console.log(ui.cyan(`Docs: ${meta.documentation}`));
if (meta.category) console.log(`Category: ${meta.category}`);
if (meta.tags?.length) console.log(`Tags: ${meta.tags.join(", ")}`);
if (meta.license) console.log(`License: ${meta.license}`);
if (meta.dependsOn?.length) console.log(`Depends on: ${meta.dependsOn.join(", ")}`);
const env = meta.env ?? [];
if (env.length > 0) {
console.log(ui.bold("\nEnvironment variables:"));
for (const e of env) {
const req = e.required ? " (required)" : "";
const def = e.default !== undefined ? ` (default: ${e.default})` : "";
const desc = e.description ? `${e.description}` : "";
console.log(` ${e.name}${req}${def}${desc}`);
}
}
if (meta.notes) console.log(`\n${meta.notes}`);
}
function materializeFiles(dir: string, files: ServiceFile[], vars: Record<string, string>): void {
for (const f of files) {
const target = join(dir, f.path);
mkdirSync(dirname(target), { recursive: true });
const content = f.content.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (m, name: string) =>
Object.prototype.hasOwnProperty.call(vars, name) ? vars[name] : m,
);
writeFileSync(target, content, "utf8");
}
}
async function ensureExternalNetworks(meta: ServiceMetadata, cfg: Config): Promise<void> {
if (cfg.dryRun) return;
const nets = meta.networks ?? {};
for (const [name, def] of Object.entries(nets)) {
if (def && typeof def === "object" && (def as Record<string, unknown>).external) {
await runCommand("docker", ["network", "create", name], { capture: true });
}
}
}
async function requireDocker(): Promise<void> {
if (!(await isDockerAvailable())) {
throw new Error(
"Docker is not available. Install Docker (or Docker Desktop) and make sure `docker` is on your PATH.",
);
}
}
function printDockerFailure(r: RunResult): void {
const tail = (r.stderr || r.stdout || "")
.trim()
.split(/\r?\n/)
.slice(-25)
.join("\n");
if (tail) ui.error(tail);
}

17
installer/src/compose.ts Normal file
View file

@ -0,0 +1,17 @@
import { stringify } from "yaml";
import type { ServiceMetadata } from "./types.ts";
/** Build the docker-compose.yml content for a service from its metadata. */
export function generateComposeFile(meta: ServiceMetadata): string {
const doc: Record<string, unknown> = {};
if (meta.networks && Object.keys(meta.networks).length > 0) {
doc.networks = meta.networks;
}
if (meta.volumes && Object.keys(meta.volumes).length > 0) {
doc.volumes = meta.volumes;
}
doc.services = { [meta.id]: meta.compose };
return stringify(doc, { lineWidth: 0 });
}

43
installer/src/config.ts Normal file
View file

@ -0,0 +1,43 @@
import { resolve } from "node:path";
export interface Config {
owner: string;
repo: string;
branch: string;
local: boolean;
localPath?: string;
dryRun: boolean;
yes: boolean;
force: boolean;
verbose: boolean;
env: Record<string, string>;
token?: string;
}
export function loadConfig(cli: Partial<Config> = {}): Config {
const envLocal = process.env.HOMELAB_LOCAL;
const localPath = cli.localPath ?? (envLocal || undefined);
return {
owner: cli.owner ?? process.env.HOMELAB_OWNER ?? "ReinadoRojo",
repo: cli.repo ?? process.env.HOMELAB_REPO ?? "homelab",
branch: cli.branch ?? process.env.HOMELAB_BRANCH ?? "master",
local: Boolean(localPath),
localPath: localPath ? resolve(localPath) : undefined,
dryRun: cli.dryRun ?? false,
yes: cli.yes ?? false,
force: cli.force ?? false,
verbose: cli.verbose ?? false,
env: cli.env ?? {},
token:
process.env.HOMELAB_GITHUB_TOKEN ??
process.env.GITHUB_TOKEN ??
process.env.HOMELAB_TOKEN,
};
}
export function describeSource(cfg: Config): string {
return cfg.local
? `local: ${cfg.localPath}`
: `${cfg.owner}/${cfg.repo}@${cfg.branch}`;
}

47
installer/src/docker.ts Normal file
View file

@ -0,0 +1,47 @@
export interface RunResult {
code: number;
stdout: string;
stderr: string;
}
export async function runCommand(
command: string,
args: string[],
opts?: { cwd?: string; capture?: boolean },
): Promise<RunResult> {
const proc = Bun.spawn([command, ...args], {
cwd: opts?.cwd,
stdin: "ignore",
stdout: opts?.capture ? "pipe" : "inherit",
stderr: opts?.capture ? "pipe" : "inherit",
});
let stdout = "";
let stderr = "";
if (opts?.capture) {
[stdout, stderr] = await Promise.all([
Bun.readableStreamToText(proc.stdout!),
Bun.readableStreamToText(proc.stderr!),
]);
}
const code = await proc.exited;
return { code, stdout, stderr };
}
export async function isDockerAvailable(): Promise<boolean> {
try {
const r = await runCommand("docker", ["--version"], { capture: true });
return r.code === 0;
} catch {
return false;
}
}
export function runDockerCompose(
dir: string,
args: string[],
capture = true,
): Promise<RunResult> {
return runCommand("docker", ["compose", ...args], { cwd: dir, capture });
}

81
installer/src/env.ts Normal file
View file

@ -0,0 +1,81 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import type { ServiceMetadata } from "./types.ts";
import { promptEnvValue } from "./prompts.ts";
export interface EnvOptions {
interactive: boolean;
provided: Record<string, string>;
}
/** Resolve declared env vars from provided values, defaults, or interactive prompts. */
export async function collectEnvVars(
meta: ServiceMetadata,
opts: EnvOptions,
): Promise<Record<string, string>> {
const vars: Record<string, string> = {};
for (const spec of meta.env ?? []) {
let value: string | undefined = opts.provided[spec.name];
if (value === undefined && spec.default !== undefined) value = spec.default;
if (value === undefined && spec.generate) value = randomHex(32);
if (value === undefined && opts.interactive) {
value = await promptEnvValue(spec);
}
if (value === undefined && spec.required) {
throw new Error(
`required variable "${spec.name}" for "${meta.id}" was not provided. ` +
`Pass --env ${spec.name}=VALUE or run interactively.`,
);
}
if (value !== undefined) vars[spec.name] = value;
}
return vars;
}
/** Serialize resolved vars into a dotenv file body. */
export function envFileContent(vars: Record<string, string>): string {
const lines = Object.entries(vars).map(([k, v]) => `${k}=${quote(v)}`);
return `${lines.join("\n")}\n`;
}
/** Parse a dotenv file body into a plain object. */
export function parseEnvFile(content: string): Record<string, string> {
const out: Record<string, string> = {};
for (const line of content.split(/\r?\n/)) {
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
if (!m) continue;
out[m[1]] = unquote(m[2]);
}
return out;
}
/** Read the .env file in a service dir (returns {} if absent). */
export function readEnvFile(dir: string): Record<string, string> {
try {
return parseEnvFile(readFileSync(join(dir, ".env"), "utf8"));
} catch {
return {};
}
}
function quote(v: string): string {
return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function randomHex(bytes: number): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
function unquote(v: string): string {
if (v.startsWith('"') && v.endsWith('"')) {
return v
.slice(1, -1)
.replace(/\\"/g, '"')
.replace(/\\\\/g, "\\");
}
return v;
}

170
installer/src/github.ts Normal file
View file

@ -0,0 +1,170 @@
import { readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import { join } from "node:path";
import type { Catalog, CatalogEntry, ServiceMetadata } from "./types.ts";
import type { Config } from "./config.ts";
import { parseAndValidate } from "./metadata.ts";
const UA = "homelab-installer/1.0";
function rawUrl(cfg: Config, path: string): string {
return `https://raw.githubusercontent.com/${cfg.owner}/${cfg.repo}/${cfg.branch}/${path}`;
}
function apiUrl(cfg: Config, path: string): string {
return `https://api.github.com/repos/${cfg.owner}/${cfg.repo}/${path}`;
}
function authHeaders(cfg: Config, scheme: "token" | "bearer"): Record<string, string> {
return cfg.token ? { Authorization: `${scheme} ${cfg.token}` } : {};
}
async function fetchText(
url: string,
cfg: Config,
scheme: "token" | "bearer" = "bearer",
): Promise<string> {
const res = await fetch(url, {
headers: {
"User-Agent": UA,
Accept: "application/vnd.github+json",
...authHeaders(cfg, scheme),
},
});
if (!res.ok) {
throw new Error(`HTTP ${res.status} for ${url}`);
}
return await res.text();
}
async function fetchJson<T>(url: string, cfg: Config): Promise<T> {
return JSON.parse(await fetchText(url, cfg)) as T;
}
/** Return the list of services (id + path) from the configured source. */
export async function listServices(cfg: Config): Promise<CatalogEntry[]> {
if (cfg.local && cfg.localPath) {
return listLocal(cfg.localPath);
}
// Preferred: a single committed catalog.json served from raw.githubusercontent.com.
try {
const catalog = await fetchJson<Catalog>(rawUrl(cfg, "services/catalog.json"), cfg);
if (Array.isArray(catalog.services)) return catalog.services;
} catch {
// Fall through to the git trees API below.
}
try {
return await listViaApi(cfg);
} catch (err) {
throw new Error(
`Could not read the catalog from ${cfg.owner}/${cfg.repo}@${cfg.branch}: ${(err as Error).message}. ` +
`Check --owner/--repo/--branch (and that the repo is pushed), set HOMELAB_GITHUB_TOKEN for a private repo, or use --local <path>.`,
);
}
}
function listLocal(dir: string): CatalogEntry[] {
const entries: CatalogEntry[] = [];
let names: string[] = [];
try {
names = readdirSync(dir);
} catch {
throw new Error(`Local services directory not found: ${dir}`);
}
for (const name of names.sort()) {
const metaPath = join(dir, name, "metadata.json");
if (!statSync(metaPath, { throwIfNoEntry: false })?.isFile()) continue;
try {
const meta = parseAndValidate(readFileSync(metaPath, "utf8"), name);
entries.push({
id: meta.id,
version: meta.version,
path: `services/${name}/metadata.json`,
});
} catch {
// Ignore folders without a valid metadata.json.
}
}
return entries;
}
async function listViaApi(cfg: Config): Promise<CatalogEntry[]> {
const tree = await fetchJson<{ tree?: { path: string }[] }>(
apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`),
cfg,
);
const paths = (tree.tree ?? [])
.map((t) => t.path)
.filter((p) => /^services\/[^/]+\/metadata\.json$/.test(p));
return paths.map((p) => {
const id = p.split("/")[1] ?? "unknown";
return { id, version: "unknown", path: p };
});
}
/** Fetch and validate a single service's metadata.json. */
export async function fetchMetadata(id: string, cfg: Config): Promise<ServiceMetadata> {
let raw: string;
if (cfg.local && cfg.localPath) {
raw = readFileSync(join(cfg.localPath, id, "metadata.json"), "utf8");
} else {
raw = await fetchText(rawUrl(cfg, `services/${id}/metadata.json`), cfg, "token");
}
return parseAndValidate(raw, id);
}
export interface ServiceFile {
path: string;
content: string;
}
/** Return every extra file in a service folder (excluding metadata.json). */
export async function fetchServiceFiles(id: string, cfg: Config): Promise<ServiceFile[]> {
if (cfg.local && cfg.localPath) {
return listLocalFiles(join(cfg.localPath, id));
}
const tree = await fetchJson<{ tree?: { path: string; type?: string }[] }>(
apiUrl(cfg, `git/trees/${cfg.branch}?recursive=1`),
cfg,
);
const prefix = `services/${id}/`;
const paths = (tree.tree ?? [])
.filter((t) => t.type === "blob" && t.path.startsWith(prefix) && t.path !== `${prefix}metadata.json`)
.map((t) => t.path);
const files: ServiceFile[] = [];
for (const p of paths) {
const rel = p.slice(prefix.length);
const content = await fetchText(rawUrl(cfg, p), cfg, "token");
files.push({ path: rel, content });
}
return files;
}
function listLocalFiles(dir: string): ServiceFile[] {
const out: ServiceFile[] = [];
walk(dir, "", out);
return out;
}
function walk(base: string, rel: string, out: ServiceFile[]): void {
const full = rel ? join(base, rel) : base;
let entries: Dirent[];
try {
entries = readdirSync(full, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const relPath = rel ? `${rel}/${e.name}` : e.name;
if (e.isDirectory()) {
walk(base, relPath, out);
} else if (!(rel === "" && e.name === "metadata.json")) {
out.push({ path: relPath, content: readFileSync(join(base, relPath), "utf8") });
}
}
}

250
installer/src/index.ts Normal file
View file

@ -0,0 +1,250 @@
#!/usr/bin/env bun
import type { Config } from "./config.ts";
import { loadConfig } from "./config.ts";
import { runTui } from "./tui.ts";
import {
installService,
loadCatalog,
printAvailable,
printInfo,
printStatus,
uninstallService,
updateService,
} from "./commands.ts";
import { loadState } from "./state.ts";
import * as ui from "./util.ts";
interface CliArgs {
command: string;
ids: string[];
flags: Record<string, string | boolean>;
env: Record<string, string>;
}
function parseArgs(argv: string[]): CliArgs {
const ids: string[] = [];
const flags: Record<string, string | boolean> = {};
const env: Record<string, string> = {};
let command = "";
const valueFlags = new Set(["local", "owner", "repo", "branch"]);
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === "--help" || a === "-h") {
flags.help = true;
continue;
}
if (a === "-e" || a === "--env") {
const v = argv[++i];
if (!v) throw new Error(`${a} requires a KEY=VALUE argument.`);
applyEnv(env, v);
continue;
}
if (a.startsWith("--")) {
let key = a.slice(2);
let value: string | undefined;
const eq = key.indexOf("=");
if (eq >= 0) {
value = key.slice(eq + 1);
key = key.slice(0, eq);
}
if (value === undefined && valueFlags.has(key) && i + 1 < argv.length && !argv[i + 1].startsWith("-")) {
value = argv[++i];
}
flags[key] = value ?? true;
continue;
}
if (!command) {
command = a;
} else {
ids.push(a);
}
}
return { command, ids, flags, env };
}
function applyEnv(env: Record<string, string>, kv: string): void {
const eq = kv.indexOf("=");
if (eq <= 0) throw new Error(`invalid env "${kv}" (expected KEY=VALUE).`);
env[kv.slice(0, eq)] = kv.slice(eq + 1);
}
function asString(v: string | boolean | undefined): string | undefined {
return typeof v === "string" ? v : undefined;
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const cfg: Config = loadConfig({
localPath: asString(args.flags.local),
owner: asString(args.flags.owner),
repo: asString(args.flags.repo),
branch: asString(args.flags.branch),
dryRun: Boolean(args.flags["dry-run"]),
yes: Boolean(args.flags.yes),
force: Boolean(args.flags.force),
verbose: Boolean(args.flags.verbose),
env: args.env,
});
if (args.flags.help) {
printHelp();
return;
}
if (!args.command) {
if (!process.stdout.isTTY) {
ui.error("No command provided and stdout is not a TTY. Run `homelab --help` for usage.");
process.exit(1);
}
await runTui(cfg);
return;
}
switch (args.command) {
case "help":
printHelp();
return;
case "list":
await printAvailable(cfg);
return;
case "status":
printStatus();
return;
case "info":
await printInfo(args.ids[0], cfg);
return;
case "install":
if (await runInstall(args.ids, cfg)) process.exit(1);
return;
case "update":
if (await runUpdate(args.ids, cfg)) process.exit(1);
return;
case "uninstall":
if (await runUninstall(args.ids, cfg)) process.exit(1);
return;
default:
ui.error(`Unknown command "${args.command}".`);
printHelp();
process.exit(1);
}
}
async function runInstall(ids: string[], cfg: Config): Promise<boolean> {
if (ids.length === 0) {
ui.error("install requires at least one service id.");
return true;
}
const metas = await loadCatalog(cfg);
const byId = new Map(metas.map((m) => [m.id, m]));
let failed = false;
for (const id of ids) {
const meta = byId.get(id);
if (!meta) {
ui.error(`Unknown service "${id}". Run \`homelab list\` to see available services.`);
failed = true;
continue;
}
try {
const r = await installService(meta, cfg);
ui.success(r.message);
} catch (err) {
ui.error(`Failed to install ${id}: ${(err as Error).message}`);
failed = true;
}
}
return failed;
}
async function runUpdate(ids: string[], cfg: Config): Promise<boolean> {
const state = loadState();
const targets = ids.length > 0 ? ids : Object.keys(state.services);
if (targets.length === 0) {
ui.info("No services installed. Nothing to update.");
return false;
}
const metas = await loadCatalog(cfg);
const byId = new Map(metas.map((m) => [m.id, m]));
let failed = false;
for (const id of targets) {
const installed = state.services[id];
if (!installed) {
ui.warn(`"${id}" is not installed. Skipping.`);
failed = true;
continue;
}
const meta = byId.get(id);
if (!meta) {
ui.warn(`"${id}" not found in the catalog. Skipping.`);
failed = true;
continue;
}
if (!cfg.force && meta.version === installed.version) {
ui.info(`${id} is already up to date (${installed.version}).`);
continue;
}
try {
const r = await updateService(installed, meta, cfg);
ui.success(r.message);
} catch (err) {
ui.error(`Failed to update ${id}: ${(err as Error).message}`);
failed = true;
}
}
return failed;
}
async function runUninstall(ids: string[], cfg: Config): Promise<boolean> {
if (ids.length === 0) {
ui.error("uninstall requires at least one service id.");
return true;
}
let failed = false;
for (const id of ids) {
try {
const r = await uninstallService(id, cfg);
ui.success(r.message);
} catch (err) {
ui.error(`Failed to uninstall ${id}: ${(err as Error).message}`);
failed = true;
}
}
return failed;
}
function printHelp(): void {
console.log(`homelab — install and update services for your homelab
Usage:
homelab Launch the interactive TUI
homelab list List available services
homelab status Show installed services
homelab info <id> Show details for a service
homelab install <id...> Install one or more services
homelab update [id...] Update installed services (all if no id)
homelab uninstall <id...> Uninstall one or more services
Options:
--local <path> Read services from a local directory instead of GitHub
--owner <name> GitHub repository owner (default: env HOMELAB_OWNER)
--repo <name> GitHub repository name (default: env HOMELAB_REPO)
--branch <name> Git branch used as the catalog (default: env HOMELAB_BRANCH)
--env KEY=VALUE Set an environment variable (repeatable)
--dry-run Show what would happen without running docker
--yes Non-interactive: use defaults/provided values, never prompt
--force Force update even if versions match
--verbose Stream docker compose output
--help Show this help
`);
}
main().catch((err) => {
ui.error((err as Error).message);
process.exit(1);
});

52
installer/src/metadata.ts Normal file
View file

@ -0,0 +1,52 @@
import type { ServiceMetadata } from "./types.ts";
export interface ValidationIssue {
path: string;
message: string;
}
export function parseAndValidate(raw: string, fallbackId: string): ServiceMetadata {
let data: unknown;
try {
data = JSON.parse(raw);
} catch (e) {
throw new Error(`metadata.json is not valid JSON: ${(e as Error).message}`);
}
if (typeof data !== "object" || data === null || Array.isArray(data)) {
throw new Error("metadata.json must be a JSON object");
}
const meta = data as ServiceMetadata;
const issues = validateMetadata(meta, fallbackId);
if (issues.length > 0) {
const detail = issues.map((i) => `${i.path}: ${i.message}`).join("; ");
throw new Error(`invalid metadata.json (${detail})`);
}
meta.id = meta.id || fallbackId;
return meta;
}
export function validateMetadata(meta: ServiceMetadata, fallbackId: string): ValidationIssue[] {
const issues: ValidationIssue[] = [];
const id = meta.id || fallbackId;
if (!meta.name || typeof meta.name !== "string") {
issues.push({ path: "name", message: "required string" });
}
if (!meta.description || typeof meta.description !== "string") {
issues.push({ path: "description", message: "required string" });
}
if (!meta.version || typeof meta.version !== "string") {
issues.push({ path: "version", message: "required string" });
}
if (!meta.compose || typeof meta.compose !== "object" || Array.isArray(meta.compose)) {
issues.push({ path: "compose", message: "required object (docker-compose service spec)" });
}
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
issues.push({ path: "id", message: "must be kebab-case and match the folder name" });
}
return issues;
}

24
installer/src/paths.ts Normal file
View file

@ -0,0 +1,24 @@
import { homedir } from "node:os";
import { join } from "node:path";
import { mkdirSync } from "node:fs";
export function getHome(): string {
return process.env.HOMELAB_HOME ?? join(homedir(), ".homelab");
}
export function stateFile(): string {
return join(getHome(), "state.json");
}
export function servicesRoot(): string {
return ensureDir(join(getHome(), "services"));
}
export function serviceDir(id: string): string {
return ensureDir(join(servicesRoot(), id));
}
function ensureDir(p: string): string {
mkdirSync(p, { recursive: true });
return p;
}

27
installer/src/prompts.ts Normal file
View file

@ -0,0 +1,27 @@
import { isCancel, password, select, text } from "@clack/prompts";
import type { EnvSpec } from "./types.ts";
/** Prompt for a single env var (only called in interactive mode). */
export async function promptEnvValue(spec: EnvSpec): Promise<string | undefined> {
const message = spec.label ? `${spec.label} (${spec.name})` : spec.name;
if (spec.options && spec.options.length > 0) {
const res = await select({
message,
options: spec.options.map((o) => ({ value: o, label: o })),
});
return isCancel(res) ? undefined : (res as string);
}
if (spec.secret) {
const res = await password({ message });
return isCancel(res) ? undefined : (res as string);
}
const res = await text({
message,
placeholder: spec.description,
defaultValue: spec.default ?? "",
});
return isCancel(res) ? undefined : (res as string);
}

46
installer/src/state.ts Normal file
View file

@ -0,0 +1,46 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { InstalledService, State } from "./types.ts";
import { stateFile } from "./paths.ts";
const EMPTY: State = { version: 1, services: {} };
export function loadState(): State {
const file = stateFile();
if (!existsSync(file)) return EMPTY;
try {
const parsed = JSON.parse(readFileSync(file, "utf8")) as Partial<State>;
return {
version: parsed.version ?? 1,
services: parsed.services ?? {},
};
} catch {
return EMPTY;
}
}
export function saveState(state: State): void {
const file = stateFile();
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, `${JSON.stringify(state, null, 2)}\n`, "utf8");
}
export function markInstalled(id: string, name: string, version: string): void {
const state = loadState();
const now = new Date().toISOString();
const existing: InstalledService | undefined = state.services[id];
state.services[id] = {
id,
name,
version,
installedAt: existing?.installedAt ?? now,
updatedAt: now,
};
saveState(state);
}
export function removeInstalled(id: string): void {
const state = loadState();
delete state.services[id];
saveState(state);
}

196
installer/src/tui.ts Normal file
View file

@ -0,0 +1,196 @@
import {
confirm,
intro,
isCancel,
multiselect,
outro,
select,
spinner,
} from "@clack/prompts";
import type { ServiceMetadata } from "./types.ts";
import { describeSource, type Config } from "./config.ts";
import {
installService,
loadCatalog,
printAvailable,
printStatus,
resolveEnv,
uninstallService,
updateService,
} from "./commands.ts";
import { readEnvFile } from "./env.ts";
import { serviceDir } from "./paths.ts";
import { loadState } from "./state.ts";
import * as ui from "./util.ts";
export async function runTui(cfg: Config): Promise<void> {
ui.banner();
intro(ui.cyan("Homelab Installer"));
ui.info(`Source: ${describeSource(cfg)}`);
let running = true;
while (running) {
const action = await select({
message: "What would you like to do?",
options: [
{ value: "install", label: "Install services", hint: "add new services" },
{ value: "update", label: "Update services", hint: "pull newer versions" },
{ value: "uninstall", label: "Uninstall services", hint: "stop and remove" },
{ value: "list", label: "List available services" },
{ value: "status", label: "Show installed services" },
{ value: "exit", label: "Exit" },
],
});
if (isCancel(action)) {
running = false;
break;
}
switch (action) {
case "install":
await tuiInstall(cfg);
break;
case "update":
await tuiUpdate(cfg);
break;
case "uninstall":
await tuiUninstall(cfg);
break;
case "list":
await printAvailable(cfg);
break;
case "status":
printStatus();
break;
case "exit":
running = false;
break;
default:
break;
}
}
outro(ui.green("Goodbye!"));
}
async function tuiInstall(cfg: Config): Promise<void> {
const metas = await loadCatalog(cfg);
if (metas.length === 0) {
ui.warn("No services found in the catalog.");
return;
}
const state = loadState();
const choices = metas
.filter((m) => !state.services[m.id])
.map((m) => ({
value: m.id,
label: `${m.name} (${m.version})`,
hint: m.category,
}));
if (choices.length === 0) {
ui.info("All available services are already installed.");
return;
}
const selected = await multiselect({
message: "Select services to install (space to toggle, enter to confirm)",
options: choices,
required: true,
});
if (isCancel(selected)) return;
const byId = new Map(metas.map((m) => [m.id, m]));
for (const id of selected as string[]) {
const meta = byId.get(id);
if (!meta) continue;
await runWithSpinner(`Installing ${meta.name}...`, meta, cfg, async () => {
const vars = await resolveEnv(meta, cfg);
return installService(meta, cfg, vars);
});
}
}
async function tuiUpdate(cfg: Config): Promise<void> {
const state = loadState();
const installed = Object.values(state.services);
if (installed.length === 0) {
ui.info("No services installed yet.");
return;
}
const metas = await loadCatalog(cfg);
const byId = new Map(metas.map((m) => [m.id, m]));
const options = installed.map((s) => {
const meta = byId.get(s.id);
const newer = meta && meta.version !== s.version;
return {
value: s.id,
label: `${s.name} (${s.version})`,
hint: newer && meta ? `update to ${meta.version}` : "up to date",
};
});
const selected = await multiselect({
message: "Select services to update",
options,
required: true,
});
if (isCancel(selected)) return;
for (const id of selected as string[]) {
const meta = byId.get(id);
const installedService = state.services[id];
if (!meta || !installedService) continue;
await runWithSpinner(`Updating ${meta.name}...`, meta, cfg, async () => {
const vars = await resolveEnv(meta, cfg, readEnvFile(serviceDir(id)));
return updateService(installedService, meta, cfg, vars);
});
}
}
async function tuiUninstall(cfg: Config): Promise<void> {
const state = loadState();
const installed = Object.values(state.services);
if (installed.length === 0) {
ui.info("No services installed yet.");
return;
}
const selected = await multiselect({
message: "Select services to uninstall",
options: installed.map((s) => ({ value: s.id, label: s.name, hint: s.version })),
required: true,
});
if (isCancel(selected)) return;
const confirmed = await confirm({
message: "Uninstall selected services? (data volumes are preserved)",
});
if (isCancel(confirmed) || !confirmed) return;
for (const id of selected as string[]) {
await runWithSpinner(`Uninstalling ${id}...`, null, cfg, async () => {
return uninstallService(id, cfg);
});
}
}
async function runWithSpinner(
label: string,
meta: ServiceMetadata | null,
cfg: Config,
fn: () => Promise<{ message: string }>,
): Promise<void> {
const s = spinner();
s.start(label);
try {
const result = await fn();
s.stop(ui.green(result.message));
} catch (err) {
s.stop(ui.red(`Failed: ${(err as Error).message}`));
}
}

59
installer/src/types.ts Normal file
View file

@ -0,0 +1,59 @@
export interface EnvSpec {
name: string;
label?: string;
description?: string;
default?: string;
required?: boolean;
secret?: boolean;
options?: string[];
generate?: boolean;
}
export interface ServiceMetadata {
id: string;
name: string;
description: string;
version: string;
category?: string;
tags?: string[];
icon?: string;
author?: string;
license?: string;
homepage?: string;
documentation?: string;
compose: Record<string, unknown>;
volumes?: Record<string, unknown>;
networks?: Record<string, unknown>;
env?: EnvSpec[];
dependsOn?: string[];
notes?: string;
}
export interface InstalledService {
id: string;
name: string;
version: string;
installedAt: string;
updatedAt: string;
}
export interface State {
version: number;
services: Record<string, InstalledService>;
}
export interface CatalogEntry {
id: string;
version: string;
path: string;
}
export interface Catalog {
generatedAt?: string;
services: CatalogEntry[];
}
export interface ActionResult {
ok: boolean;
message: string;
}

66
installer/src/util.ts Normal file
View file

@ -0,0 +1,66 @@
export const c = {
reset: "\x1b[0m",
bold: "\x1b[1m",
dim: "\x1b[2m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
magenta: "\x1b[35m",
cyan: "\x1b[36m",
gray: "\x1b[90m",
} as const;
export function color(code: string, s: string): string {
return `${code}${s}${c.reset}`;
}
export const green = (s: string): string => color(c.green, s);
export const red = (s: string): string => color(c.red, s);
export const yellow = (s: string): string => color(c.yellow, s);
export const cyan = (s: string): string => color(c.cyan, s);
export const bold = (s: string): string => color(c.bold, s);
export const dim = (s: string): string => color(c.dim, s);
export function info(msg: string): void {
console.log(`${color(c.gray, "•")} ${msg}`);
}
export function success(msg: string): void {
console.log(`${color(c.green, "✔")} ${msg}`);
}
export function warn(msg: string): void {
console.error(`${color(c.yellow, "⚠")} ${msg}`);
}
export function error(msg: string): void {
console.error(`${color(c.red, "✖")} ${msg}`);
}
export function table(headers: string[], rows: string[][]): void {
const all = [headers, ...rows];
const widths = headers.map((_, i) =>
Math.max(...all.map((r) => (r[i] ?? "").length)),
);
const fmt = (row: string[]): string =>
row
.map((cell, i) => (cell ?? "").padEnd(widths[i] ?? 0))
.join(" ")
.trimEnd();
console.log(bold(fmt(headers)));
for (const row of rows) console.log(fmt(row));
}
export function banner(): void {
console.log(
color(
c.cyan,
" _ _ ___ __ __ ___ _ ___ ___ \n" +
" | || |/ _ \\| \\/ |/ _ \\| | / __|| _ ) \n" +
" | __ | (_) | |\\/| | (_) | |__ | (__ | _ \\ \n" +
" |_||_|\\___/|_| |_|\\___/|____| \\___||___/ \n",
),
);
}

16
installer/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"types": ["bun"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"allowImportingTsExtensions": true
},
"include": ["src/**/*.ts", "scripts/**/*.ts"]
}

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."
}