diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e31a3bc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 212a399..487f798 100644 --- a/README.md +++ b/README.md @@ -1 +1,168 @@ -# homelab \ No newline at end of file +# 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://: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//` +(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 ` | 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////services//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//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. diff --git a/docs/metadata-schema.json b/docs/metadata-schema.json new file mode 100644 index 0000000..09190ed --- /dev/null +++ b/docs/metadata-schema.json @@ -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" } + } +} diff --git a/installer/.gitignore b/installer/.gitignore new file mode 100644 index 0000000..6f3dd62 --- /dev/null +++ b/installer/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.tgz +.env diff --git a/installer/README.md b/installer/README.md new file mode 100644 index 0000000..b3fb48a --- /dev/null +++ b/installer/README.md @@ -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//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. diff --git a/installer/bun.lock b/installer/bun.lock new file mode 100644 index 0000000..8c38a46 --- /dev/null +++ b/installer/bun.lock @@ -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=="], + } +} diff --git a/installer/package.json b/installer/package.json new file mode 100644 index 0000000..85b20df --- /dev/null +++ b/installer/package.json @@ -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" + } +} diff --git a/installer/scripts/generate-catalog.ts b/installer/scripts/generate-catalog.ts new file mode 100644 index 0000000..4eae27d --- /dev/null +++ b/installer/scripts/generate-catalog.ts @@ -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.`); diff --git a/installer/src/commands.ts b/installer/src/commands.ts new file mode 100644 index 0000000..33b6191 --- /dev/null +++ b/installer/src/commands.ts @@ -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 { + 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, +): Promise> { + 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, +): Promise { + 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, +): Promise { + 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 { + 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 { + 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 { + 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): 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 { + if (cfg.dryRun) return; + const nets = meta.networks ?? {}; + for (const [name, def] of Object.entries(nets)) { + if (def && typeof def === "object" && (def as Record).external) { + await runCommand("docker", ["network", "create", name], { capture: true }); + } + } +} + +async function requireDocker(): Promise { + 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); +} diff --git a/installer/src/compose.ts b/installer/src/compose.ts new file mode 100644 index 0000000..e7f7831 --- /dev/null +++ b/installer/src/compose.ts @@ -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 = {}; + + 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 }); +} diff --git a/installer/src/config.ts b/installer/src/config.ts new file mode 100644 index 0000000..501c04a --- /dev/null +++ b/installer/src/config.ts @@ -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; + token?: string; +} + +export function loadConfig(cli: Partial = {}): 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}`; +} diff --git a/installer/src/docker.ts b/installer/src/docker.ts new file mode 100644 index 0000000..3ec90d5 --- /dev/null +++ b/installer/src/docker.ts @@ -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 { + 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 { + 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 { + return runCommand("docker", ["compose", ...args], { cwd: dir, capture }); +} diff --git a/installer/src/env.ts b/installer/src/env.ts new file mode 100644 index 0000000..f4eb191 --- /dev/null +++ b/installer/src/env.ts @@ -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; +} + +/** Resolve declared env vars from provided values, defaults, or interactive prompts. */ +export async function collectEnvVars( + meta: ServiceMetadata, + opts: EnvOptions, +): Promise> { + const vars: Record = {}; + + 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 { + 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 { + const out: Record = {}; + 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 { + 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; +} diff --git a/installer/src/github.ts b/installer/src/github.ts new file mode 100644 index 0000000..6f7ebb4 --- /dev/null +++ b/installer/src/github.ts @@ -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 { + return cfg.token ? { Authorization: `${scheme} ${cfg.token}` } : {}; +} + +async function fetchText( + url: string, + cfg: Config, + scheme: "token" | "bearer" = "bearer", +): Promise { + 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(url: string, cfg: Config): Promise { + 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 { + 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(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 .`, + ); + } +} + +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 { + 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 { + 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 { + 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") }); + } + } +} diff --git a/installer/src/index.ts b/installer/src/index.ts new file mode 100644 index 0000000..d182bcc --- /dev/null +++ b/installer/src/index.ts @@ -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; + env: Record; +} + +function parseArgs(argv: string[]): CliArgs { + const ids: string[] = []; + const flags: Record = {}; + const env: Record = {}; + 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, 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 { + 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 { + 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 { + 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 { + 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 Show details for a service + homelab install Install one or more services + homelab update [id...] Update installed services (all if no id) + homelab uninstall Uninstall one or more services + +Options: + --local Read services from a local directory instead of GitHub + --owner GitHub repository owner (default: env HOMELAB_OWNER) + --repo GitHub repository name (default: env HOMELAB_REPO) + --branch 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); +}); diff --git a/installer/src/metadata.ts b/installer/src/metadata.ts new file mode 100644 index 0000000..3737556 --- /dev/null +++ b/installer/src/metadata.ts @@ -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; +} diff --git a/installer/src/paths.ts b/installer/src/paths.ts new file mode 100644 index 0000000..09da5f2 --- /dev/null +++ b/installer/src/paths.ts @@ -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; +} diff --git a/installer/src/prompts.ts b/installer/src/prompts.ts new file mode 100644 index 0000000..7d3c222 --- /dev/null +++ b/installer/src/prompts.ts @@ -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 { + 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); +} diff --git a/installer/src/state.ts b/installer/src/state.ts new file mode 100644 index 0000000..3148425 --- /dev/null +++ b/installer/src/state.ts @@ -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; + 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); +} diff --git a/installer/src/tui.ts b/installer/src/tui.ts new file mode 100644 index 0000000..fc1cbd2 --- /dev/null +++ b/installer/src/tui.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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}`)); + } +} diff --git a/installer/src/types.ts b/installer/src/types.ts new file mode 100644 index 0000000..dd7602d --- /dev/null +++ b/installer/src/types.ts @@ -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; + volumes?: Record; + networks?: Record; + 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; +} + +export interface CatalogEntry { + id: string; + version: string; + path: string; +} + +export interface Catalog { + generatedAt?: string; + services: CatalogEntry[]; +} + +export interface ActionResult { + ok: boolean; + message: string; +} diff --git a/installer/src/util.ts b/installer/src/util.ts new file mode 100644 index 0000000..7abc44b --- /dev/null +++ b/installer/src/util.ts @@ -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", + ), + ); +} diff --git a/installer/tsconfig.json b/installer/tsconfig.json new file mode 100644 index 0000000..ae6c32c --- /dev/null +++ b/installer/tsconfig.json @@ -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"] +} diff --git a/services/README.md b/services/README.md new file mode 100644 index 0000000..3d8f2cc --- /dev/null +++ b/services/README.md @@ -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//`, 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. diff --git a/services/allprox/Caddyfile b/services/allprox/Caddyfile new file mode 100644 index 0000000..81355bd --- /dev/null +++ b/services/allprox/Caddyfile @@ -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} +} diff --git a/services/allprox/metadata.json b/services/allprox/metadata.json new file mode 100644 index 0000000..02e2ca5 --- /dev/null +++ b/services/allprox/metadata.json @@ -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." +} diff --git a/services/allprox/portal/index.html b/services/allprox/portal/index.html new file mode 100644 index 0000000..3ee4856 --- /dev/null +++ b/services/allprox/portal/index.html @@ -0,0 +1,80 @@ + + + + + +allprox — portal + + + +
+

allprox signed in

+

Authenticated access portal — you are signed in.

+ +
+ Services + +
+ +

+ Edit this page at services/allprox/portal/index.html and the proxy + rules in services/allprox/Caddyfile. +

+
+ + diff --git a/services/authelia/configuration.yml b/services/authelia/configuration.yml new file mode 100644 index 0000000..260c280 --- /dev/null +++ b/services/authelia/configuration.yml @@ -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' diff --git a/services/authelia/metadata.json b/services/authelia/metadata.json new file mode 100644 index 0000000..ffdf980 --- /dev/null +++ b/services/authelia/metadata.json @@ -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." +} diff --git a/services/authelia/users_database.yml b/services/authelia/users_database.yml new file mode 100644 index 0000000..0c9f7f4 --- /dev/null +++ b/services/authelia/users_database.yml @@ -0,0 +1,3 @@ +# Local users (file backend). We authenticate against LLDAP, so this stays empty. +# Authelia still requires the file to exist. +users: {} diff --git a/services/catalog.json b/services/catalog.json new file mode 100644 index 0000000..a48ef7d --- /dev/null +++ b/services/catalog.json @@ -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" + } + ] +} diff --git a/services/lldap/metadata.json b/services/lldap/metadata.json new file mode 100644 index 0000000..96d6ed1 --- /dev/null +++ b/services/lldap/metadata.json @@ -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://: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." +}