Compare commits

..
14 Commits
Author SHA1 Message Date
Damien 1c07727d35 Merge pull request 'chore: réduire le dépôt à netlab + komodo' (#31) from chore/reduce-scope-netlab-komodo into main
Reviewed-on: #31
2026-08-02 08:09:01 +00:00
Damien e5d59473d2 chore: réduire le dépôt à netlab + komodo
Migration Gitea → GitHub et bascule de l'infra sur Komodo (suppression
de Proxmox) : proxy/, openbao/ et gitea-runner/ n'ont plus lieu d'être,
et lib/common.sh n'était sourcé que par ces deux derniers.

Réf: #30
2026-08-01 20:26:54 +02:00
Damien 9927e4dec1 Add shellcheck ignore for dynamic source 2026-07-30 14:31:27 +02:00
Damien 8299431aed Merge pull request 'Standardiser les scripts créateurs de LXC (template, autologin, update)' (#16) from chore/standardize-lxc-scripts into main
Reviewed-on: #16
2026-07-30 12:19:37 +00:00
Damien d86848eb71 openbao,gitea-runner: use shared ensure_template_present, gitea-runner parity fixes
openbao: removed its now-duplicate ensure_template_present() definition,
using the one factored into lib/common.sh (call site unchanged).

gitea-runner: replaced its ad-hoc template-download check (which skipped
`pveam update`, risking a stale cached template list now that the template
is auto-detected) with a call to the shared ensure_template_present().
Also added ca-certificates to exec_in_lxc's apk add, matching openbao, and
added require_root() to the inside-LXC dispatch path in main(), which
openbao already does but gitea-runner was missing.
2026-07-30 14:05:50 +02:00
Damien 936ab2c2fb lib/common.sh: fix refresh_os_packages contract, drop set -e, add ensure_template_present
- refresh_os_packages()'s header claimed it was callable both host-side (via
  pct exec) and inside the LXC; it's a plain bash function in this process,
  it cannot cross a pct exec boundary. Corrected to match the comment
  already present at its one host-side non-call-site in openbao/install.sh.
- Removed `set -euo pipefail`: a sourced file must not impose shell options
  on the caller. Both install scripts already set these before sourcing.
- Added ensure_template_present(), ported from openbao/install.sh, so
  gitea-runner can reuse it (issue #12 follow-up) instead of duplicating a
  template-download check that skipped `pveam update` and could silently
  settle for a stale cached template list.
2026-07-30 14:05:43 +02:00
Damien 3e4ede907e openbao,gitea-runner: fail loudly if lib/common.sh sourcing fails
source <(curl ...) swallows curl failures (404, network error): an empty
stream still makes `source` return 0, so the failure would otherwise only
surface later as a confusing "command not found" for one of lib/common.sh's
functions. Check that a known function landed after the source, and exit 1
with the attempted URL if not.
2026-07-30 13:34:05 +02:00
Damien fef37d2676 gitea-runner: make SCRIPT_URL overridable, matching openbao
Was a fixed value, unlike openbao's SCRIPT_URL="${SCRIPT_URL:-...}". Two
consequences: exec_in_lxc forwarded SCRIPT_URL into the container where it
was immediately overwritten by the hardcoded value, making the forward a
no-op; and the lib/common.sh sourcing fallback derives its fetch URL from
SCRIPT_URL, so it always pointed at main (where lib/common.sh doesn't exist
yet), making this branch untestable end-to-end for gitea-runner.
2026-07-30 13:33:35 +02:00
Damien 82540472ae gitea-runner: fix log_info/warn/error writing to stdout, corrupting TEMPLATE
log_info() etc. wrote to stdout, unlike openbao's identical functions
which write to stderr specifically so $(fn) capture is safe. lib/common.sh's
detect_latest_alpine_template() does `log_info "Selected..."; echo "$tmpl"` —
in gitea-runner, TEMPLATE=$(detect_latest_alpine_template) therefore captured
the log line and ANSI codes along with the template name, breaking both the
pveam list lookup and pct create's template argument.
2026-07-30 13:33:20 +02:00
Damien 294960f44d gitea-runner: detect existing LXC on re-run, refresh OS on update
Ports openbao's host-side update path to gitea-runner: main() now uses
find_existing_lxc (hostname/tag match) to switch into a new update_lxc()
instead of always recreating the container, with an explicit --update
dispatch case reaching update_runner() (previously inferred only from
/usr/local/bin/act_runner presence). require_root is ported from openbao
for the host-side pct branch.

Extracted a small exec_in_lxc() helper (option a from the two offered)
rather than duplicating the curl-pipe invocation, since main() now needs
to drive both the create and update paths through the same piping logic
with only the trailing --install/--update flag differing — matching
openbao's own exec_in_lxc for consistency.

update_runner() now calls refresh_os_packages before touching the binary,
mirroring openbao's update_inside_lxc(). Also switched create_lxc's LXC
tag from the hardcoded "cicd" to the LXC_TAG variable (added in the prior
commit but unused until now) so find_existing_lxc's tag match actually
works.

Documented the OS-refresh-on-update behavior in both READMEs.

Closes #15
2026-07-30 11:28:14 +02:00
Damien 51f2474db6 openbao,gitea-runner: source lib/common.sh, fix hardcoded Alpine template
Both install scripts now source lib/common.sh instead of duplicating
detect_latest_alpine_template(), find_existing_lxc(), and the tty1
autologin block. Since these scripts are distributed via curl one-liner
and piped into `pct exec` inside the LXC, there is no local checkout to
source from in those contexts — lib/common.sh is sourced from disk when
a local checkout is available (BASH_SOURCE resolves to a real path),
otherwise fetched over HTTP next to SCRIPT_URL. Both scripts already
require outbound network to curl themselves and to download their
respective binaries, so this adds no new failure mode.

openbao/install.sh is a behavioral no-op: same log output, same control
flow. One inline apk update/upgrade is intentionally left as-is in
update_lxc() — refresh_os_packages() is a bash function in this
process and can't run over `pct exec ... sh -c` without shipping the
function definition into the container.

gitea-runner/install.sh fixes the actual bug: TEMPLATE was hardcoded to
a specific dated Alpine release, so create_lxc() would keep trying to
provision a stale/absent template. TEMPLATE now defaults to empty and
create_lxc() calls detect_latest_alpine_template() when unset, mirroring
openbao. Also renamed HOSTNAME -> HOSTNAME_LXC and added LXC_TAG to
match openbao's naming, since a follow-up (issue #15) will wire up
find_existing_lxc()-based update detection here.

Closes #12, Closes #14
2026-07-30 11:23:03 +02:00
Damien d0138b742d lib/common.sh: template detection + autologin + update helpers 2026-07-30 11:14:20 +02:00
Damien e545a27e9a scaffold: lib/common.sh helper skeleton (refs #12 #14 #15) 2026-07-30 11:09:51 +02:00
Damien b5ff76232e Remove ferretdb (Debian LXC, no Alpine build for DocumentDB extension)
FerretDB required Debian (DocumentDB is Debian/RHEL-only, no musl build),
making it an outlier among the Alpine-based LXC creator scripts in this
repo. Dropping it leaves openbao and gitea-runner as the only LXC creator
scripts, both Alpine, which simplifies template/OS handling (see #12).

Closes #13
2026-07-30 11:09:05 +02:00
11 changed files with 10 additions and 2378 deletions
+6 -11
View File
@@ -9,25 +9,20 @@ These scripts automate the deployment of personal infrastructure components. The
- **Self-contained**: No external dependencies beyond standard Debian packages
- **Idempotent-ish**: Safe to re-run (where possible)
- **Curl-friendly**: Designed for one-liner deployment from a fresh server
- **Multi-OS**: Supports Debian and Alpine-based deployments
- **Multi-OS**: Supports Debian and Alpine-based deployments, chosen per-script based on that service's requirements
- **Loopback by default**: Services bind to `127.0.0.1`; Tailscale handles the reverse proxy and TLS termination
- **Log hygiene**: Every long-running service ships with a `logrotate` config (no unbounded log files)
- **Console auto-login**: Proxmox LXCs are configured for root auto-login on `tty1` (fast `pct enter` and Web UI shell access)
- **Keep it simple**: One script per service, plain bash, no frameworks — readability over cleverness
### Available Scripts
| Script | Description | Usage |
| ------------------------------------------ | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| [`proxy/install.sh`](proxy/) | Reverse proxy with Tailscale + Nginx Proxy Manager | `curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/proxy/install.sh` \| `bash` |
| [`netlab/install.sh`](netlab/) | Network lab with ContainerLab | `curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/netlab/install.sh` \| `bash` |
| [`gitea-runner/install.sh`](gitea-runner/) | Gitea Act Runner on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh)"` |
| [`openbao/install.sh`](openbao/) | OpenBao secrets manager on Alpine LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/openbao/install.sh)"` |
| [`ferretdb/install.sh`](ferretdb/) | FerretDB (MongoDB-compatible) on Debian LXC (Proxmox) | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh)"` |
| [`komodo/install.sh`](komodo/) | Komodo (Docker + MongoDB) on Alpine VM | `bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/komodo/install.sh)"` |
| -------------------------------- | ---------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| [`netlab/install.sh`](netlab/) | Network lab with ContainerLab | `curl -fsSL https://raw.githubusercontent.com/darnodo/infra-scripts/main/netlab/install.sh` \| `bash` |
| [`komodo/install.sh`](komodo/) | Komodo (Docker + MongoDB) on Alpine VM | `bash -c "$(curl -fsSL https://raw.githubusercontent.com/darnodo/infra-scripts/main/komodo/install.sh)"` |
### Requirements
- Fresh Debian 12/13 installation (proxy, netlab) or Proxmox VE host (gitea-runner, openbao, ferretdb) or Alpine VM (komodo)
- User with sudo privileges (do not run as root) — except gitea-runner, openbao, ferretdb, and komodo which run as root
- Fresh Debian 12/13 installation (netlab) or Alpine VM (komodo)
- User with sudo privileges (do not run as root) — except komodo, which runs as root
- Internet access
-139
View File
@@ -1,139 +0,0 @@
# FerretDB
Automated installation and update script for [FerretDB](https://www.ferretdb.io) — a truly
open-source, MongoDB-compatible database — running inside a **Debian LXC** on Proxmox.
A drop-in MongoDB replacement for any app that speaks the MongoDB wire protocol (e.g.
[LibreChat](https://www.librechat.ai)) without running MongoDB itself. The script installs
and exposes the database only; wiring it into an application is left to that app's own
configuration (a separate script, or manually).
### Why Debian and not Alpine?
FerretDB v2 is two pieces:
1. the **FerretDB proxy** (a static Go binary), and
2. **PostgreSQL + Microsoft's DocumentDB extension**, the mandatory storage engine.
The DocumentDB extension is a compiled C PostgreSQL extension and is published **only** as
`deb`/`rpm` packages (`deb11`, `deb12`, `ubuntu`, `rhel`) — there is **no Alpine/musl build**.
So, unlike the `openbao`/`gitea-runner` Alpine LXCs in this repo, this stack runs on Debian 12
(`deb12`, the newest target the extension ships for).
### Features
Single script, automatic mode selection:
| Context | Action |
| ----------------------------------------------------- | ---------------------------------------------------------------------------------------- |
| From Proxmox host, no existing FerretDB container | Detects newest Debian template, creates LXC, installs PostgreSQL + DocumentDB + FerretDB |
| From Proxmox host, FerretDB container already present | Reuses the existing LXC, refreshes packages, upgrades the FerretDB stack to latest |
| From inside an LXC, no `ferretdb` binary | Installs the full stack from scratch |
| From inside an LXC, `ferretdb` already present | Updates the packages only (no config / role / data changes) |
The container is identified by hostname **and** the `ferretdb` tag, so it is re-found across
reruns even if the CTID was auto-allocated the first time.
### Requirements
- Proxmox VE host with `pveam`, `pct`, `pvesh`, `jq` available
- Internet access from both the host (template download) and the LXC (package downloads)
- Script must be run as **root** on the Proxmox host (enforced; the Web UI shell qualifies)
### Usage
#### Full install (from Proxmox shell)
```bash
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh)"
```
The script prints the generated password and the ready-to-paste `MONGO_URI` at the end.
**Save them** — the password is not persisted on the Proxmox host.
Re-running the exact same command later upgrades packages inside the LXC and brings the
FerretDB stack to the latest release, without touching the config, role, or PostgreSQL data.
#### Customisation
Every parameter is exposed as an environment variable:
| Variable | Default | Description |
| ---------------------- | --------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `CTID` | auto | Container ID (auto-allocated via `pvesh get /cluster/nextid`) |
| `FERRETDB_HOSTNAME` | `ferretdb` | LXC hostname |
| `TEMPLATE` | auto-detected | Debian template; auto-detected from `pveam available` |
| `STORAGE` | `local-lvm` | Proxmox storage for the LXC root disk |
| `TEMPLATE_STORAGE` | `local` | Storage where Debian templates live |
| `CORES` | `2` | vCPU cores |
| `RAM` | `2048` | RAM in MiB (Postgres + FerretDB) |
| `DISK` | `16` | Root disk size in GB |
| `BRIDGE` | `vmbr0` | Network bridge |
| `LXC_TAG` | `ferretdb` | Stable tag used to re-discover the container |
| `PG_VERSION` | `17` | PostgreSQL major version (from PGDG) |
| `DOCUMENTDB_TAG` | `latest` | DocumentDB release tag (couples documentdb + FerretDB versions); pin e.g. `v0.107.0-ferretdb-2.7.0` |
| `DOCUMENTDB_DISTRO` | `deb12` | Distro target in the documentdb deb filename. Escape hatch for a future `deb13` (set with `TEMPLATE`) |
| `FERRETDB_LISTEN_ADDR` | `0.0.0.0:27017` | TCP listener. Exposed on all interfaces — it is a database other hosts must reach. |
| `FERRETDB_USER` | `ferretdb` | App user — both the PostgreSQL role and the MongoDB user clients authenticate as |
| `FERRETDB_PASSWORD` | auto-generated | Auto-generated (`openssl rand -hex 24`) when unset; printed in the final summary |
| `TS_AUTHKEY` | _(unset)_ | Pre-auth key (generate at <https://login.tailscale.com/admin/settings/keys>). If unset, finish `tailscale up` manually inside the LXC. |
```bash
CTID=220 FERRETDB_HOSTNAME=mongo RAM=4096 DISK=32 \
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh)"
```
#### Connecting a client
Any MongoDB driver or tool connects with a standard connection string (the script prints the
exact one, with the generated password, at the end of the install):
```
mongodb://ferretdb:<password>@<lxc-ip-or-tailnet-fqdn>:27017/
```
Append a database name to target one (e.g. `…:27017/myapp`); it is created on first write.
Wiring this into a specific application (setting its Mongo connection string, disabling any
bundled MongoDB it ships, etc.) is intentionally out of scope — do it from that app's own
config or a dedicated script.
#### Tailscale
The LXC joins the tailnet (`tailscale up --ssh`) so a client on another node can reach the
database over the tailnet. Unlike the `openbao` script there is **no `tailscale serve`** — the
MongoDB wire protocol is raw TCP, not HTTP, so the listener is exposed directly on
`0.0.0.0:27017` (LAN + tailnet) by design.
If `TS_AUTHKEY` was supplied the node is brought up automatically; otherwise finish it
manually inside the LXC:
```bash
pct enter <CTID>
tailscale up --ssh --hostname ferretdb
tailscale status # prints the tailnet FQDN
```
> Because the listener is on `0.0.0.0`, restrict access with your tailnet ACLs and/or a host
> firewall — anyone who can route to TCP 27017 can attempt to authenticate.
#### Update (from inside the LXC)
```bash
curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh | bash
```
The script auto-detects the presence of `/usr/bin/ferretdb` and switches to update mode:
packages are upgraded (including the DocumentDB extension via
`ALTER EXTENSION documentdb UPDATE`) and the services are restarted. The config, the
PostgreSQL role, and the data are left untouched.
### Architecture
- **OS**: latest Debian LXC template (auto-detected), unprivileged, `nesting=1`, `/dev/net/tun` passthrough for Tailscale
- **Storage engine**: PostgreSQL `17` (PGDG) + the DocumentDB extension (`pg_documentdb`, `pg_cron`), loopback-only on `127.0.0.1:5432`
- **Proxy**: official `ferretdb` deb from `github.com/FerretDB/FerretDB`, systemd unit, listening on `0.0.0.0:27017`
- **Auth**: the app user is provisioned through `documentdb_api.create_user` (roles `clusterAdmin` + `readWriteAnyDatabase`), **not** a plain `CREATE ROLE`. DocumentDB builds the SCRAM-SHA-256 verifier with its own 28-byte salt (`documentdb.scramDefaultSaltLen`); a native PostgreSQL role would store a 16-byte salt that MongoDB clients reject (`invalid salt length of 16 in sasl step2`). FerretDB connects to PostgreSQL as the same user.
- **Network**: FerretDB exposed on `0.0.0.0:27017`; Tailscale runs in the LXC for tailnet reachability (no `serve`)
- **Config**: `/etc/postgresql/17/main/conf.d/documentdb.conf` (extension settings) and `/etc/systemd/system/ferretdb.service.d/override.conf` (`FERRETDB_POSTGRESQL_URL`, `FERRETDB_LISTEN_ADDR`)
- **Logs**: PostgreSQL via its stock `logrotate`; FerretDB via journald, capped at `SystemMaxUse=200M`
- **Version tracking**: `/opt/ferretdb_version.txt` records the installed DocumentDB tag for idempotent reruns
-676
View File
@@ -1,676 +0,0 @@
#!/bin/bash
# install.sh - FerretDB: LXC creation, installation & update
# Usage:
# From Proxmox host : bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh)"
# From inside LXC : bash /root/install.sh (updates packages)
#
# Single entrypoint, three automatic modes:
# 1. Proxmox host, no existing container -> create Debian LXC + install FerretDB
# 2. Proxmox host, container already present -> update packages + upgrade FerretDB
# 3. Inside an LXC -> install if missing, otherwise update
#
# FerretDB v2 is a MongoDB wire-protocol proxy backed by PostgreSQL + the
# DocumentDB extension. The extension is a compiled C PostgreSQL extension and
# is only published as deb/rpm packages (no Alpine/musl build), so this stack
# runs on Debian — unlike the openbao/gitea-runner Alpine LXCs in this repo.
#
# The package install/upgrade logic lives in a single reusable function
# (install_or_upgrade_packages) shared by both the create and update paths.
set -euo pipefail
# Force an always-present locale. A fresh Debian template has not generated the
# host's locale (e.g. fr_FR.UTF-8 inherited through pct exec), so apt, perl and
# apt-listchanges warn loudly about it. C.UTF-8 ships with glibc and is always
# valid; this silences the noise without installing extra locales.
export LC_ALL=C.UTF-8
export LANG=C.UTF-8
# --- Config (override via environment) ---
CTID="${CTID:-}"
HOSTNAME_LXC="${FERRETDB_HOSTNAME:-ferretdb}"
TEMPLATE="${TEMPLATE:-}" # auto-detected when empty
STORAGE="${STORAGE:-local-lvm}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
CORES="${CORES:-2}"
RAM="${RAM:-2048}" # Postgres needs more headroom than openbao
DISK="${DISK:-16}"
BRIDGE="${BRIDGE:-vmbr0}"
LXC_TAG="${LXC_TAG:-ferretdb}" # stable identifier for the container
PG_VERSION="${PG_VERSION:-17}" # PostgreSQL major version (PGDG)
# DocumentDB release tag couples both pieces: it encodes the documentdb package
# version AND the matching FerretDB version. "latest" resolves both at once.
DOCUMENTDB_TAG="${DOCUMENTDB_TAG:-latest}"
# Distro target embedded in the documentdb deb filename. Only deb11/deb12 exist
# today (no deb13). Escape hatch: when upstream ships deb13, set this + TEMPLATE
# to move to trixie without editing the script.
DOCUMENTDB_DISTRO="${DOCUMENTDB_DISTRO:-deb12}"
DOCUMENTDB_RELEASES_URL="${DOCUMENTDB_RELEASES_URL:-https://api.github.com/repos/FerretDB/documentdb/releases}"
# As a database we deliberately expose the listener on all interfaces so other
# hosts can reach it over the LAN / tailnet. PostgreSQL stays local.
FERRETDB_LISTEN_ADDR="${FERRETDB_LISTEN_ADDR:-0.0.0.0:27017}"
# Application credentials. The same user/password is both the PostgreSQL role
# FerretDB connects with AND the MongoDB user clients authenticate as.
FERRETDB_USER="${FERRETDB_USER:-ferretdb}"
FERRETDB_PASSWORD="${FERRETDB_PASSWORD:-}" # auto-generated when empty
# Optional: pre-authorise the LXC's Tailscale non-interactively.
# Generate at https://login.tailscale.com/admin/settings/keys
TS_AUTHKEY="${TS_AUTHKEY:-}"
# SCRIPT_URL is what the host-side flow pipes into the LXC. Override it when
# testing from a non-main branch.
SCRIPT_URL="${SCRIPT_URL:-https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/ferretdb/install.sh}"
VERSION_FILE="${VERSION_FILE:-/opt/ferretdb_version.txt}"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Logs go to stderr so callers can safely use $(fn) without capturing log noise.
log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# ============================================================
# Generic helpers
# ============================================================
require_root() {
if [[ "$(id -u)" -ne 0 ]]; then
log_error "This script must be run as root (current uid: $(id -u))."
log_error "On Proxmox, launch it from the host shell or via the Web UI shell, both of which run as root."
exit 1
fi
}
# Debian packages use dpkg-style arch names (amd64, arm64), which is also what
# both the FerretDB and DocumentDB release assets are named with.
get_arch() {
case "$(uname -m)" in
x86_64) echo "amd64" ;;
aarch64) echo "arm64" ;;
*) log_error "Unsupported architecture: $(uname -m)"; exit 1 ;;
esac
}
# Resolve the DocumentDB release tag into the two coupled versions it encodes.
# Sets globals: DOC_TAG (full tag), DOC_PKG_VER (documentdb pkg version),
# FERRET_VER (matching FerretDB version, no leading v).
# Tag shape: v0.107.0-ferretdb-2.7.0
resolve_versions() {
local endpoint tag
if [[ "$DOCUMENTDB_TAG" == "latest" ]]; then
endpoint="${DOCUMENTDB_RELEASES_URL}/latest"
else
endpoint="${DOCUMENTDB_RELEASES_URL}/tags/${DOCUMENTDB_TAG}"
fi
tag=$(curl -fsSL "$endpoint" | jq -r '.tag_name')
if [[ -z "$tag" || "$tag" == "null" ]]; then
log_error "Failed to resolve DocumentDB release '${DOCUMENTDB_TAG}' from GitHub API."
exit 1
fi
DOC_TAG="$tag"
DOC_PKG_VER="${tag#v}" # 0.107.0-ferretdb-2.7.0
DOC_PKG_VER="${DOC_PKG_VER%%-ferretdb-*}" # 0.107.0
FERRET_VER="${tag##*-ferretdb-}" # 2.7.0
if [[ -z "$DOC_PKG_VER" || -z "$FERRET_VER" || "$FERRET_VER" == "$tag" ]]; then
log_error "Could not parse DocumentDB tag '${tag}' (expected vX-ferretdb-Y)."
exit 1
fi
}
# ============================================================
# Reusable: install or upgrade PostgreSQL + DocumentDB + FerretDB.
# Used by both fresh-install and update flows. Idempotent.
# ============================================================
install_or_upgrade_packages() {
local arch current doc_url ferret_url tmpdir doc_deb_name doc_full_ver
resolve_versions
arch=$(get_arch)
current=""
if [[ -f "$VERSION_FILE" ]]; then
current=$(cat "$VERSION_FILE")
fi
if [[ "$current" == "$DOC_TAG" && -x /usr/bin/ferretdb ]]; then
log_info "FerretDB stack already at ${DOC_TAG}, nothing to do."
return 0
fi
# Ensure the PGDG repo is present so the requested PostgreSQL major exists.
if [[ ! -f /etc/apt/sources.list.d/pgdg.list ]]; then
log_info "Adding the PostgreSQL APT (PGDG) repository..."
install -d -m 0755 /usr/share/keyrings
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /usr/share/keyrings/postgresql.gpg
echo "deb [signed-by=/usr/share/keyrings/postgresql.gpg] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list
apt-get update >/dev/null
fi
log_info "Installing PostgreSQL ${PG_VERSION} + pg_cron..."
DEBIAN_FRONTEND=noninteractive apt-get install -y \
"postgresql-${PG_VERSION}" "postgresql-${PG_VERSION}-cron" >/dev/null
# documentdb deb naming: deb12-postgresql-17-documentdb_0.107.0.ferretdb.2.7.0_amd64.deb
doc_full_ver="${DOC_PKG_VER}.ferretdb.${FERRET_VER}"
doc_deb_name="${DOCUMENTDB_DISTRO}-postgresql-${PG_VERSION}-documentdb_${doc_full_ver}_${arch}.deb"
doc_url="https://github.com/FerretDB/documentdb/releases/download/${DOC_TAG}/${doc_deb_name}"
ferret_url="https://github.com/FerretDB/FerretDB/releases/download/v${FERRET_VER}/ferretdb-${arch}-linux.deb"
tmpdir=$(mktemp -d)
log_info "Downloading DocumentDB extension (${doc_deb_name})..."
curl -fsSL "$doc_url" -o "${tmpdir}/documentdb.deb"
log_info "Downloading FerretDB ${FERRET_VER} (${arch})..."
curl -fsSL "$ferret_url" -o "${tmpdir}/ferretdb.deb"
log_info "Installing DocumentDB extension + FerretDB (apt resolves dependencies)..."
DEBIAN_FRONTEND=noninteractive apt-get install -y \
"${tmpdir}/documentdb.deb" "${tmpdir}/ferretdb.deb" >/dev/null
# If the extension is already created (update path), bring it to the new version.
if su -s /bin/sh postgres -c "psql -tAc \"SELECT 1 FROM pg_extension WHERE extname='documentdb'\" -d postgres" 2>/dev/null | grep -q 1; then
log_info "Updating documentdb extension to ${DOC_PKG_VER}..."
su -s /bin/sh postgres -c "psql -d postgres -c 'ALTER EXTENSION documentdb UPDATE;'" >/dev/null 2>&1 || \
log_warn "ALTER EXTENSION documentdb UPDATE failed — check after restart."
fi
echo "$DOC_TAG" > "$VERSION_FILE"
rm -rf "$tmpdir"
log_info "Installed FerretDB: $(/usr/bin/ferretdb --version 2>&1 | head -n1 || true)"
# Restart services if they already exist (update path); the install path
# enables them explicitly after configuration.
if systemctl list-unit-files ferretdb.service >/dev/null 2>&1; then
systemctl restart postgresql 2>/dev/null || true
systemctl restart ferretdb 2>/dev/null || true
fi
}
# ============================================================
# Reusable: bring Tailscale up so the LXC joins the tailnet.
# Unlike openbao we do NOT use 'tailscale serve' — FerretDB speaks the raw
# MongoDB wire protocol (TCP), not HTTP, so serve does not apply. The DB is
# reachable directly on 0.0.0.0:27017 over the LAN / tailnet.
# ============================================================
configure_tailscale() {
if ! command -v tailscale >/dev/null 2>&1; then
log_warn "tailscale CLI not found, skipping tailnet setup."
return 0
fi
local backend_state
backend_state=$(tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"')
if [[ "$backend_state" == "Running" ]]; then
log_info "Tailscale already up."
return 0
fi
if [[ -n "$TS_AUTHKEY" ]]; then
log_info "Bringing Tailscale up with provided auth key..."
tailscale up --authkey "$TS_AUTHKEY" --ssh --hostname "$HOSTNAME_LXC" \
|| log_warn "tailscale up failed — run it manually inside the LXC."
else
log_warn "Tailscale not authenticated and TS_AUTHKEY was not supplied."
log_warn "Finish setup inside the LXC with: tailscale up --ssh --hostname ${HOSTNAME_LXC}"
fi
}
# ============================================================
# Proxmox-host helpers
# ============================================================
# The installer re-fetches itself inside the LXC from SCRIPT_URL. Verify it is
# reachable on the host *before* creating any container, so a wrong branch/path
# fails immediately with guidance instead of dying mid-install with a curl 404.
preflight_script_url() {
if curl -fsSL -o /dev/null "$SCRIPT_URL"; then
return 0
fi
log_error "SCRIPT_URL is not reachable: ${SCRIPT_URL}"
log_error "The installer re-fetches itself inside the LXC from SCRIPT_URL, so this"
log_error "must resolve. If you are testing from a branch (not yet on main), pass it:"
log_error " SCRIPT_URL=https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/<branch>/ferretdb/install.sh \\"
log_error " bash -c \"\$(curl -fsSL \"\$SCRIPT_URL\")\""
exit 1
}
# Detect newest Debian *12* LXC template available from the Proxmox repos.
# Deliberately pinned to debian-12: the DocumentDB extension only ships a deb12
# build (DOCUMENTDB_DISTRO default), and a newer template (e.g. debian-13) would
# pair that deb against a different libicu soname. To move to trixie, set both
# TEMPLATE and DOCUMENTDB_DISTRO yourself once upstream publishes a deb13 build.
detect_latest_debian_template() {
local tmpl
tmpl=$(pveam available --section system 2>/dev/null \
| awk '/^system[[:space:]]+debian-12-/ {print $2}' \
| sort -V \
| tail -n1)
if [[ -z "$tmpl" ]]; then
log_warn "Could not find a debian-12 template via pveam; falling back to a known-good name."
tmpl="debian-12-standard_12.7-1_amd64.tar.zst"
fi
log_info "Selected Debian template: $tmpl"
echo "$tmpl"
}
# Find an existing LXC by tag or hostname. Echoes CTID, returns 1 if none.
find_existing_lxc() {
local id host tags
while read -r id _; do
[[ -z "$id" || "$id" == "VMID" ]] && continue
host=$(pct config "$id" 2>/dev/null | awk -F': ' '/^hostname:/ {print $2}' || true)
tags=$(pct config "$id" 2>/dev/null | awk -F': ' '/^tags:/ {print $2}' || true)
if [[ "$host" == "$HOSTNAME_LXC" ]] || [[ ",${tags//;/,}," == *",${LXC_TAG},"* ]]; then
echo "$id"
return 0
fi
done < <(pct list | awk 'NR>1 {print $1}')
return 1
}
ensure_template_present() {
local tmpl="$1"
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$tmpl"; then
log_info "Downloading template ${tmpl} to storage ${TEMPLATE_STORAGE}..."
pveam update >/dev/null
pveam download "$TEMPLATE_STORAGE" "$tmpl"
else
log_info "Template ${tmpl} already present on ${TEMPLATE_STORAGE}."
fi
}
# Pick next available CTID if user did not provide one.
allocate_ctid() {
pvesh get /cluster/nextid 2>/dev/null \
|| pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq '[.[].vmid] | max + 1' \
|| echo 100
}
# Inject the script into the container and execute it in the requested mode.
# Forwards the relevant runtime configuration through the environment so the
# inner invocation produces the same config the user requested on the host.
exec_in_lxc() {
local ctid="$1"
local mode="$2" # --install or --update
# Ensure base tooling exists inside the container before piping the script.
pct exec "$ctid" -- sh -c "export DEBIAN_FRONTEND=noninteractive LC_ALL=C.UTF-8 LANG=C.UTF-8; apt-get update >/dev/null 2>&1; apt-get install -y bash curl jq ca-certificates >/dev/null 2>&1"
curl -fsSL "$SCRIPT_URL" \
| pct exec "$ctid" -- env \
LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
SCRIPT_URL="$SCRIPT_URL" \
PG_VERSION="$PG_VERSION" \
DOCUMENTDB_TAG="$DOCUMENTDB_TAG" \
DOCUMENTDB_DISTRO="$DOCUMENTDB_DISTRO" \
FERRETDB_HOSTNAME="$HOSTNAME_LXC" \
FERRETDB_LISTEN_ADDR="$FERRETDB_LISTEN_ADDR" \
FERRETDB_USER="$FERRETDB_USER" \
FERRETDB_PASSWORD="$FERRETDB_PASSWORD" \
TS_AUTHKEY="$TS_AUTHKEY" \
bash -s -- "$mode"
}
# ============================================================
# MODE: Proxmox host — create LXC + install
# ============================================================
create_lxc() {
log_info "=== FerretDB — LXC creation ==="
# Generate the password on the host so we can both pass it in and print it.
if [[ -z "$FERRETDB_PASSWORD" ]]; then
FERRETDB_PASSWORD=$(openssl rand -hex 24)
log_info "Generated FerretDB password (saved in the summary below)."
fi
if [[ -z "$TEMPLATE" ]]; then
TEMPLATE=$(detect_latest_debian_template)
else
log_info "Using user-provided template: $TEMPLATE"
fi
ensure_template_present "$TEMPLATE"
if [[ -z "$CTID" ]]; then
CTID=$(allocate_ctid)
log_info "Auto-selected CTID: $CTID"
fi
log_info "Creating LXC ${CTID} (${HOSTNAME_LXC})..."
pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" \
--hostname "$HOSTNAME_LXC" \
--cores "$CORES" \
--memory "$RAM" \
--rootfs "${STORAGE}:${DISK}" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
--unprivileged 1 \
--features "nesting=1" \
--tags "infra-script,${LXC_TAG}" \
--onboot 1 \
--start 0
# Tailscale needs /dev/net/tun inside the unprivileged container.
log_info "Adding /dev/net/tun passthrough for Tailscale..."
cat >> "/etc/pve/lxc/${CTID}.conf" <<EOF
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
EOF
log_info "Starting LXC ${CTID}..."
pct start "$CTID"
# Wait for network to come up
local tries=0
until pct exec "$CTID" -- sh -c "ip -4 addr show eth0 | grep -q 'inet '" 2>/dev/null; do
tries=$((tries + 1))
if (( tries > 30 )); then
log_error "LXC ${CTID} did not acquire an IP after 30s."
exit 1
fi
sleep 1
done
log_info "Running installer inside LXC ${CTID}..."
exec_in_lxc "$CTID" "--install"
local ip
ip=$(pct exec "$CTID" -- ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 || true)
echo ""
log_info "========================================="
log_info "LXC ${CTID} created successfully!"
log_info "========================================="
echo ""
echo " Hostname : ${HOSTNAME_LXC}"
echo " IP : ${ip:-pending}"
echo " FerretDB : ${FERRETDB_LISTEN_ADDR}"
echo ""
echo "MongoDB connection string (point your client/app at it):"
echo " mongodb://${FERRETDB_USER}:${FERRETDB_PASSWORD}@${ip:-<ip>}:27017/"
echo ""
echo "Store this password somewhere safe — it is not persisted on the host:"
echo " user : ${FERRETDB_USER}"
echo " password : ${FERRETDB_PASSWORD}"
echo ""
}
# ============================================================
# MODE: Proxmox host — update existing LXC
# ============================================================
update_lxc() {
local ctid="$1"
log_info "=== FerretDB — updating existing LXC ${ctid} ==="
if ! pct status "$ctid" | grep -q running; then
log_info "Starting LXC ${ctid}..."
pct start "$ctid"
sleep 3
fi
log_info "Refreshing Debian packages inside LXC ${ctid}..."
pct exec "$ctid" -- sh -c "export DEBIAN_FRONTEND=noninteractive LC_ALL=C.UTF-8 LANG=C.UTF-8; apt-get update >/dev/null && apt-get upgrade -y >/dev/null"
log_info "Upgrading FerretDB stack inside LXC ${ctid}..."
exec_in_lxc "$ctid" "--update"
log_info "Update of LXC ${ctid} complete."
}
# ============================================================
# MODE: inside LXC — fresh install of FerretDB
# ============================================================
install_inside_lxc() {
log_info "=== FerretDB — installation ==="
if [[ -z "$FERRETDB_PASSWORD" ]]; then
FERRETDB_PASSWORD=$(openssl rand -hex 24 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')
log_info "Generated FerretDB password (shown in the summary below)."
fi
log_info "Updating package index..."
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get upgrade -y >/dev/null
log_info "Installing base dependencies..."
apt-get install -y curl jq ca-certificates gnupg lsb-release sudo logrotate openssl >/dev/null
log_info "Installing Tailscale..."
if ! command -v tailscale >/dev/null 2>&1; then
curl -fsSL https://tailscale.com/install.sh | sh >/dev/null 2>&1 \
|| log_warn "Tailscale install script failed — install it manually later."
fi
systemctl enable --now tailscaled >/dev/null 2>&1 \
|| log_warn "tailscaled failed to start (is /dev/net/tun mapped into the LXC?)"
install_or_upgrade_packages
log_info "Configuring PostgreSQL for DocumentDB..."
local pg_confd="/etc/postgresql/${PG_VERSION}/main/conf.d"
mkdir -p "$pg_confd"
# https://docs.ferretdb.io/installation/documentdb/deb/
cat > "${pg_confd}/documentdb.conf" <<EOF
# Managed by infra-scripts/ferretdb — DocumentDB extension settings.
shared_preload_libraries = 'pg_cron,pg_documentdb_core,pg_documentdb'
cron.database_name = 'postgres'
documentdb.enableCompact = true
documentdb.enableLetAndCollationForQueryMatch = true
documentdb.enableNowSystemVariable = true
documentdb.enableSortbyIdPushDownToPrimaryKey = true
documentdb.enableSchemaValidation = true
documentdb.enableBypassDocumentValidation = true
documentdb.enableUserCrud = true
documentdb.maxUserLimit = 100
# Ensure any password we set is hashed with SCRAM-SHA-256 (PG default, set
# explicitly so it is active before roles are provisioned).
password_encryption = 'scram-sha-256'
# Postgres stays loopback-only; FerretDB (same LXC) is the network front door.
listen_addresses = '127.0.0.1'
EOF
log_info "Restarting PostgreSQL..."
systemctl restart postgresql
log_info "Creating the documentdb extension..."
su -s /bin/sh postgres -c "psql -v ON_ERROR_STOP=1 -d postgres" >/dev/null <<'SQL'
CREATE EXTENSION IF NOT EXISTS documentdb CASCADE;
SQL
# Provision the user THROUGH DocumentDB — never a plain CREATE ROLE ... PASSWORD.
# DocumentDB builds the SCRAM-SHA-256 verifier with its own salt length
# (documentdb.scramDefaultSaltLen = 28 bytes) via documentdb_api.create_user /
# update_user. A native PostgreSQL role instead stores a 16-byte salt, which
# MongoDB clients reject at SASL step2 with "invalid salt length of 16".
# create_user only accepts a read-only role, or the clusterAdmin +
# readWriteAnyDatabase pair we use here for full read/write (FerretDB/DocumentDB
# commands/users.c::ValidateAndObtainUserRole). The spec is built with jq so the
# password is JSON-escaped, then embedded in a $DDB$-dollar-quoted SQL literal.
log_info "Provisioning MongoDB user '${FERRETDB_USER}' via DocumentDB (28-byte SCRAM salt)..."
local role_exists cmd spec
role_exists=$(su -s /bin/sh postgres -c \
"psql -tAX -d postgres -c \"SELECT 1 FROM pg_roles WHERE rolname = '${FERRETDB_USER}'\"" 2>/dev/null || true)
if [[ "$role_exists" == "1" ]]; then
log_info "Role '${FERRETDB_USER}' already exists — resetting its password via DocumentDB."
cmd="update_user"
spec=$(jq -nc --arg u "$FERRETDB_USER" --arg p "$FERRETDB_PASSWORD" \
'{updateUser:$u, pwd:$p}')
else
cmd="create_user"
spec=$(jq -nc --arg u "$FERRETDB_USER" --arg p "$FERRETDB_PASSWORD" \
'{createUser:$u, pwd:$p, roles:[{role:"clusterAdmin",db:"admin"},{role:"readWriteAnyDatabase",db:"admin"}]}')
fi
su -s /bin/sh postgres -c "psql -v ON_ERROR_STOP=1 -d postgres" >/dev/null <<SQL
SELECT documentdb_api.${cmd}(\$DDB\$${spec}\$DDB\$);
SQL
log_info "Writing FerretDB systemd override..."
mkdir -p /etc/systemd/system/ferretdb.service.d
cat > /etc/systemd/system/ferretdb.service.d/override.conf <<EOF
[Service]
Environment=FERRETDB_POSTGRESQL_URL=postgres://${FERRETDB_USER}:${FERRETDB_PASSWORD}@127.0.0.1:5432/postgres
Environment=FERRETDB_LISTEN_ADDR=${FERRETDB_LISTEN_ADDR}
Environment=FERRETDB_TELEMETRY=disable
EOF
chmod 600 /etc/systemd/system/ferretdb.service.d/override.conf
systemctl daemon-reload
log_info "Enabling and starting services..."
systemctl enable --now postgresql >/dev/null 2>&1 || true
systemctl enable ferretdb >/dev/null 2>&1 || true
# Explicit restart: the deb postinst may have already started ferretdb with
# default settings, in which case 'enable --now' would not re-read our override.
systemctl restart ferretdb || log_warn "ferretdb failed to start — check 'journalctl -u ferretdb'."
# --- Log hygiene ---
# PostgreSQL ships /etc/logrotate.d/postgresql-common already. FerretDB logs to
# journald, so bound the journal instead of adding a logrotate stanza.
log_info "Bounding the systemd journal size..."
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/ferretdb.conf <<'EOF'
[Journal]
SystemMaxUse=200M
EOF
systemctl restart systemd-journald >/dev/null 2>&1 || true
# --- Console auto-login on tty1 (Proxmox web console / pct console) ---
log_info "Enabling console auto-login on tty1..."
mkdir -p /etc/systemd/system/container-getty@1.service.d
cat > /etc/systemd/system/container-getty@1.service.d/autologin.conf <<'EOF'
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud tty%I 115200,38400,9600 $TERM
EOF
systemctl daemon-reload
systemctl restart container-getty@1.service 2>/dev/null || true
configure_tailscale
log_info "Configuring MOTD..."
# /etc/profile.d/ runs for every interactive login shell — works for both the
# auto-login tty and Tailscale SSH. Quoted heredoc except the values we want
# frozen at install time, which we inject via a small companion env file.
cat > /etc/ferretdb-motd.env <<EOF
FERRETDB_USER='${FERRETDB_USER}'
FERRETDB_LISTEN_ADDR='${FERRETDB_LISTEN_ADDR}'
EOF
chmod 600 /etc/ferretdb-motd.env
cat > /etc/profile.d/00-ferretdb.sh <<'MOTD'
[ -f /etc/ferretdb-motd.env ] && . /etc/ferretdb-motd.env
TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' '
/"Self"/ { in_self=1 }
in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit }
')
[ -z "$TS_FQDN" ] && TS_FQDN="$(hostname).ts.net"
LAN_IP=$(ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1)
FERRET_VERSION=$(/usr/bin/ferretdb --version 2>/dev/null | head -n1 || echo "unknown")
systemctl is-active --quiet ferretdb && FERRET_STATE="active" || FERRET_STATE="DOWN"
systemctl is-active --quiet postgresql && PG_STATE="active" || PG_STATE="DOWN"
echo ""
echo " _____ _ ____ ____ "
echo "| ___|__ _ __ _ __ ___| |_| _ \\| __ ) "
echo "| |_ / _ \\ '__| '__/ _ \\ __| | | | _ \\ "
echo "| _| __/ | | | | __/ |_| |_| | |_) |"
echo "|_| \\___|_| |_| \\___|\\__|____/|____/ "
echo ""
echo "FerretDB (MongoDB-compatible) — ${FERRET_VERSION}"
echo "─────────────────────────────────────────"
echo "Status:"
echo " • FerretDB : ${FERRET_STATE} (listening on ${FERRETDB_LISTEN_ADDR})"
echo " • PostgreSQL : ${PG_STATE} (127.0.0.1:5432)"
echo ""
echo "Connection string (MongoDB URI):"
echo " mongodb://${FERRETDB_USER}:<password>@${LAN_IP:-<ip>}:27017/"
echo " (tailnet) mongodb://${FERRETDB_USER}:<password>@${TS_FQDN}:27017/"
echo ""
echo "Useful commands:"
echo " systemctl status ferretdb postgresql"
echo " journalctl -u ferretdb -f"
echo " mongosh \"mongodb://${FERRETDB_USER}:<password>@127.0.0.1:27017/\""
echo "─────────────────────────────────────────"
echo ""
MOTD
chmod +x /etc/profile.d/00-ferretdb.sh
log_info "Cleaning up..."
apt-get clean >/dev/null 2>&1 || true
local ip
ip=$(ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 || true)
echo ""
log_info "========================================="
log_info "FerretDB installation complete!"
log_info "========================================="
echo ""
echo "MongoDB connection string (point your client/app at it):"
echo " mongodb://${FERRETDB_USER}:${FERRETDB_PASSWORD}@${ip:-<ip>}:27017/"
echo ""
echo "Credentials (store safely — not persisted on the Proxmox host):"
echo " user : ${FERRETDB_USER}"
echo " password : ${FERRETDB_PASSWORD}"
echo ""
}
# ============================================================
# MODE: inside LXC — update only
# ============================================================
update_inside_lxc() {
log_info "=== FerretDB — update ==="
export DEBIAN_FRONTEND=noninteractive
apt-get update >/dev/null
apt-get upgrade -y >/dev/null
install_or_upgrade_packages
configure_tailscale
log_info "Update complete."
}
# ============================================================
# Main — dispatch on explicit mode flag or auto-detect context
# ============================================================
main() {
case "${1:-}" in
--install)
install_inside_lxc
return
;;
--update)
update_inside_lxc
return
;;
esac
if command -v pct >/dev/null 2>&1; then
# Running on a Proxmox host
require_root
preflight_script_url
local existing=""
if existing=$(find_existing_lxc); then
log_info "Found existing FerretDB LXC (CTID ${existing}, hostname/tag match) — switching to update mode."
update_lxc "$existing"
else
create_lxc
fi
else
# Inside a container (no Proxmox tooling)
require_root
if [[ -x /usr/bin/ferretdb ]]; then
update_inside_lxc
else
install_inside_lxc
fi
fi
}
main "$@"
-70
View File
@@ -1,70 +0,0 @@
# Gitea Act Runner
Automated installation script for a Gitea Actions runner in an Alpine LXC on Proxmox.
### Features
Single script, three automatic modes:
| Context | Action |
|---------|--------|
| From Proxmox host | Creates Alpine LXC + installs everything |
| From empty LXC | Installs Docker + act_runner + OpenRC service |
| From LXC with act_runner installed | Updates binary to latest version |
### Usage
#### Full install (from Proxmox shell)
```bash
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh)"
```
The script automatically creates an Alpine 3.23 LXC with Docker and act_runner.
#### Customization
Environment variables to override defaults:
```bash
CTID=120 HOSTNAME=runner-02 CORES=4 RAM=4096 bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh)"
```
| Variable | Default | Description |
|----------|---------|-------------|
| `CTID` | auto | Container ID |
| `RUNNER_HOSTNAME` | `gitea-runner` | LXC Hostname |
| `CORES` | `2` | CPU cores |
| `RAM` | `2048` | RAM in MiB |
| `DISK` | `8` | Disk in GB |
| `STORAGE` | `local-lvm` | Proxmox storage for the LXC |
| `BRIDGE` | `vmbr0` | Network bridge |
#### Runner registration
After installation, enter the LXC and register the runner:
```bash
pct enter <CTID>
cd /var/lib/gitea-runner
su -s /bin/bash gitea-runner -c "act_runner register"
rc-service gitea-runner start
```
#### Update
From inside the LXC:
```bash
curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh | bash
```
The script detects that act_runner is already installed and switches to update mode automatically.
### Architecture
- **OS**: Alpine 3.23 (LXC non-privileged, nesting active)
- **Docker**: installed via apk, OpenRC service
- **act_runner**: official binary from gitea.com/gitea/act_runner
- **Service**: OpenRC with logs in `/var/log/gitea-runner.log`
- **User**: `gitea-runner` (group `docker`)
-323
View File
@@ -1,323 +0,0 @@
#!/bin/bash
# install.sh - Gitea Act Runner: LXC creation, installation & update
# Usage:
# From Proxmox host : curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh | bash
# From inside LXC : bash /root/install.sh (updates act_runner binary)
set -euo pipefail
# --- Config (override via environment) ---
CTID="${CTID:-}"
HOSTNAME="${RUNNER_HOSTNAME:-gitea-runner}"
TEMPLATE="${TEMPLATE:-alpine-3.23-default_20260116_amd64.tar.xz}"
STORAGE="${STORAGE:-local-lvm}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
CORES="${CORES:-2}"
RAM="${RAM:-2048}"
DISK="${DISK:-8}"
BRIDGE="${BRIDGE:-vmbr0}"
SCRIPT_URL="https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/gitea-runner/install.sh"
GITEA_HOSTNAME="${GITEA_HOSTNAME:-gitea.taila5ad8.ts.net}"
GITEA_API="https://gitea.com/api/v1/repos/gitea/act_runner/releases"
VERSION_FILE="/opt/gitea-runner_version.txt"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# --- Helpers ---
get_latest_release() {
local release
release=$(curl -fsSL "$GITEA_API" | jq -r '.[0].tag_name')
if [[ -z "$release" || "$release" == "null" ]]; then
log_error "Failed to fetch latest release from Gitea API"
exit 1
fi
echo "$release"
}
get_arch() {
local arch
arch=$(uname -m)
case "$arch" in
x86_64) echo "amd64" ;;
aarch64) echo "arm64" ;;
armv7l) echo "armv7" ;;
*) log_error "Unsupported architecture: $arch"; exit 1 ;;
esac
}
download_runner() {
local release="$1"
local version="${release#v}"
local arch
arch=$(get_arch)
local url="https://gitea.com/gitea/act_runner/releases/download/${release}/gitea-runner-${version}-linux-${arch}"
log_info "Downloading act_runner ${release} (${arch})..."
curl -fsSL "$url" -o /usr/local/bin/act_runner
chmod +x /usr/local/bin/act_runner
ln -sf /usr/local/bin/act_runner /usr/bin/act_runner
echo "$release" > "$VERSION_FILE"
}
# ============================================================
# MODE 1: Proxmox host — create LXC container
# ============================================================
create_lxc() {
log_info "=== Gitea Act Runner — LXC Creation ==="
# Auto-select next CTID if not specified
if [[ -z "$CTID" ]]; then
CTID=$(pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq '[.[].vmid] | max + 1' 2>/dev/null || echo "100")
log_info "Auto-selected CTID: $CTID"
fi
# Download template if needed
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$TEMPLATE"; then
log_info "Downloading template $TEMPLATE..."
pveam download "$TEMPLATE_STORAGE" "$TEMPLATE"
fi
log_info "Creating LXC $CTID ($HOSTNAME)..."
pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" \
--hostname "$HOSTNAME" \
--cores "$CORES" \
--memory "$RAM" \
--rootfs "${STORAGE}:${DISK}" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
--unprivileged 1 \
--features nesting=1,keyctl=1 \
--tags "infra-script,cicd" \
--start 0
log_info "Configuring LXC for Docker and Tailscale..."
cat >> "/etc/pve/lxc/${CTID}.conf" <<EOF
lxc.apparmor.profile: unconfined
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
EOF
log_info "Starting LXC $CTID..."
pct start "$CTID"
sleep 5
log_info "Injecting install script into container..."
pct exec "$CTID" -- sh -c "apk add --no-cache bash curl jq > /dev/null 2>&1"
curl -fsSL "$SCRIPT_URL" | pct exec "$CTID" -- bash -s -- --install
local ip
ip=$(pct exec "$CTID" -- ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1)
echo ""
log_info "========================================="
log_info "LXC $CTID created successfully!"
log_info "========================================="
echo ""
echo " Hostname : $HOSTNAME"
echo " IP : ${ip:-pending}"
echo ""
echo "Next steps:"
echo " pct enter $CTID"
echo " cd /var/lib/gitea-runner"
echo " su -s /bin/bash gitea-runner -c 'act_runner register'"
echo " rc-service gitea-runner start"
echo ""
}
# ============================================================
# MODE 2: Inside LXC — fresh install
# ============================================================
install_runner() {
log_info "=== Gitea Act Runner — Installation ==="
log_info "Updating system..."
apk update > /dev/null && apk upgrade > /dev/null
log_info "Installing dependencies..."
apk add --no-cache curl jq tar bash docker docker-cli-compose > /dev/null
log_info "Installing Tailscale..."
apk add --no-cache tailscale > /dev/null
rc-update add tailscale default > /dev/null 2>&1
rc-service tailscale start > /dev/null 2>&1
log_info "Starting Docker..."
rc-update add docker default > /dev/null 2>&1
rc-service docker start > /dev/null 2>&1
local release
release=$(get_latest_release)
download_runner "$release"
log_info "act_runner $(act_runner --version 2>&1 || true)"
log_info "Creating gitea-runner user..."
adduser -S -D -H -h /var/lib/gitea-runner -s /bin/bash -G docker gitea-runner 2>/dev/null || true
addgroup gitea-runner docker 2>/dev/null || true
mkdir -p /var/lib/gitea-runner
chown -R gitea-runner:docker /var/lib/gitea-runner
log_info "Generating act_runner config with Prometheus metrics enabled..."
act_runner generate-config > /var/lib/gitea-runner/config.yaml
sed -i '/^metrics:/,/enabled:/{s/enabled: false/enabled: true/}' /var/lib/gitea-runner/config.yaml
chown gitea-runner:docker /var/lib/gitea-runner/config.yaml
chmod 640 /var/lib/gitea-runner/config.yaml
log_info "Creating OpenRC service..."
cat <<'EOF' > /etc/init.d/gitea-runner
#!/sbin/openrc-run
name="Gitea Act Runner"
description="Gitea Actions Runner Daemon"
command="/usr/local/bin/act_runner"
command_args="daemon --config /var/lib/gitea-runner/config.yaml"
command_user="gitea-runner:docker"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
directory="/var/lib/gitea-runner"
output_log="/var/log/gitea-runner.log"
error_log="/var/log/gitea-runner.log"
depend() {
need net docker tailscale
after docker tailscale
}
start_pre() {
export PATH="/usr/local/bin:$PATH"
checkpath --directory --owner gitea-runner:docker --mode 0755 /var/lib/gitea-runner
checkpath --file --owner gitea-runner:docker --mode 0644 /var/log/gitea-runner.log
local timeout=30
local elapsed=0
ebegin "Waiting for Tailscale MagicDNS to resolve __GITEA_HOSTNAME__"
while ! getent hosts "__GITEA_HOSTNAME__" > /dev/null 2>&1; do
if [ "$elapsed" -ge "$timeout" ]; then
eend 1
eerror "Timed out after ${timeout}s waiting for MagicDNS resolution of __GITEA_HOSTNAME__"
return 1
fi
sleep 1
elapsed=$(( elapsed + 1 ))
done
eend 0
}
EOF
sed -i "s/__GITEA_HOSTNAME__/${GITEA_HOSTNAME}/g" /etc/init.d/gitea-runner
chmod +x /etc/init.d/gitea-runner
rc-update add gitea-runner default > /dev/null
log_info "Configuring logrotate for gitea-runner..."
apk add --no-cache logrotate > /dev/null
cat > /etc/logrotate.d/gitea-runner << 'LOGROTATE'
/var/log/gitea-runner.log {
daily
rotate 7
compress
missingok
notifempty
copytruncate
}
LOGROTATE
ln -sf /usr/sbin/logrotate /etc/periodic/daily/logrotate 2>/dev/null || true
log_info "Enabling console auto-login on tty1..."
# Alpine ships busybox getty by default; agetty (from util-linux) is what
# supports --autologin.
apk add --no-cache agetty >/dev/null 2>&1 || apk add --no-cache util-linux >/dev/null
# Replace any existing tty1 entry, then append our autologin line. Doing it
# in two steps (delete + append) is more robust than an in-place sed against
# a pattern that may drift across Alpine releases.
sed -i '/^tty1::/d' /etc/inittab
echo 'tty1::respawn:/sbin/agetty --autologin root --noclear 38400 tty1' >> /etc/inittab
# Tell PID 1 to re-read /etc/inittab so the change takes effect without a reboot.
kill -HUP 1 2>/dev/null || true
# Kick any getty/agetty still attached to tty1 so init respawns it *now* with
# the new line — otherwise the first web-console session lands on the stale
# process and the operator has to type `exit` once before autologin kicks in.
pkill -KILL -f '(getty|agetty).*tty1' 2>/dev/null || true
log_info "Cleaning up..."
rm -rf /var/cache/apk/*
echo ""
log_info "========================================="
log_info "Installation complete!"
log_info "========================================="
echo ""
echo "Connect to Tailscale first:"
echo " tailscale up --ssh"
echo ""
echo "Register the runner:"
echo " cd /var/lib/gitea-runner"
echo " su -s /bin/bash gitea-runner -c 'act_runner register'"
echo " rc-service gitea-runner start"
echo ""
}
# ============================================================
# MODE 3: Inside LXC — update binary
# ============================================================
update_runner() {
log_info "=== Gitea Act Runner — Update ==="
local release
release=$(get_latest_release)
if [[ -f "$VERSION_FILE" && "$release" == "$(cat "$VERSION_FILE")" ]]; then
log_info "Already at latest version: $release"
exit 0
fi
local current
current=$(cat "$VERSION_FILE" 2>/dev/null || echo "unknown")
log_info "Updating: $current$release"
log_info "Stopping service..."
rc-service gitea-runner stop 2>/dev/null || true
log_info "Backing up current binary..."
cp /usr/local/bin/act_runner "/usr/local/bin/act_runner.bak.$(date +%s)"
download_runner "$release"
log_info "Starting service..."
rc-service gitea-runner start
log_info "Updated to $release"
}
# ============================================================
# Main — detect context
# ============================================================
main() {
if [[ "${1:-}" == "--install" ]]; then
# Explicitly called in install mode (from pct exec)
install_runner
elif command -v pct &> /dev/null; then
# We're on the Proxmox host
create_lxc
elif [[ -f /usr/local/bin/act_runner ]]; then
# act_runner exists — update mode
update_runner
else
# Fresh LXC — install mode
install_runner
fi
}
main "$@"
+1 -1
View File
@@ -32,7 +32,7 @@ script can just run `apk add docker` and let dockerd own the kernel namespace.
#### Install / update
```bash
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/komodo/install.sh)"
bash -c "$(curl -fsSL https://raw.githubusercontent.com/darnodo/infra-scripts/main/komodo/install.sh)"
```
The script prints the generated `KOMODO_DATABASE_PASSWORD`, `KOMODO_WEBHOOK_SECRET`, and
+2 -2
View File
@@ -5,7 +5,7 @@ Deploys a network lab server with ContainerLab for network simulation and testin
## Quick Start
```bash
curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/netlab/install.sh | bash
curl -fsSL https://raw.githubusercontent.com/darnodo/infra-scripts/main/netlab/install.sh | bash
```
## Components
@@ -27,7 +27,7 @@ curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/netlab/i
Example:
```bash
NETLAB_HOSTNAME=clab01 SSH_PORT=22222 curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/netlab/install.sh | bash
NETLAB_HOSTNAME=clab01 SSH_PORT=22222 curl -fsSL https://raw.githubusercontent.com/darnodo/infra-scripts/main/netlab/install.sh | bash
```
## Network Access
-123
View File
@@ -1,123 +0,0 @@
# OpenBao
Automated installation and update script for an [OpenBao](https://openbao.org) secrets-manager
server running inside an Alpine LXC on Proxmox.
### Features
Single script, automatic mode selection:
| Context | Action |
| ---------------------------------------------------- | --------------------------------------------------------------------------------- |
| From Proxmox host, no existing OpenBao container | Detects newest Alpine template, creates LXC, installs `bao` + OpenRC service |
| From Proxmox host, OpenBao container already present | Reuses the existing LXC, refreshes packages, upgrades `bao` to the latest release |
| From inside an LXC, no `bao` binary | Installs OpenBao from scratch |
| From inside an LXC, `bao` already present | Updates the binary only (no config / data changes) |
The container is identified by hostname **and** the `openbao` tag, so it is
re-found across reruns even if the CTID was auto-allocated the first time.
### Requirements
- Proxmox VE host with `pveam`, `pct`, `pvesh`, `jq` available
- Internet access from both the host (template download) and the LXC (binary download)
- Script must be run as **root** on the Proxmox host (enforced; the Web UI shell qualifies)
### Usage
#### Full install (from Proxmox shell)
```bash
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/openbao/install.sh)"
```
Re-running the exact same command later upgrades packages inside the LXC and
brings the `bao` binary to the latest release, without touching the config or
the raft data directory.
#### Customisation
Every parameter is exposed as an environment variable:
| Variable | Default | Description |
| --------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `CTID` | auto | Container ID (auto-allocated via `pvesh get /cluster/nextid`) |
| `OPENBAO_HOSTNAME` | `openbao` | LXC hostname (also used as raft `node_id`) |
| `TEMPLATE` | auto-detected | Alpine template; auto-detected from `pveam available` |
| `STORAGE` | `local-lvm` | Proxmox storage for the LXC root disk |
| `TEMPLATE_STORAGE` | `local` | Storage where Alpine templates live |
| `CORES` | `2` | vCPU cores |
| `RAM` | `1024` | RAM in MiB |
| `DISK` | `8` | Root disk size in GB |
| `BRIDGE` | `vmbr0` | Network bridge |
| `LXC_TAG` | `openbao` | Stable tag used to re-discover the container |
| `OPENBAO_VERSION` | `latest` | Pin a specific release (e.g. `v2.0.3`) or `latest` |
| `OPENBAO_LISTEN_ADDR` | `127.0.0.1:8200` | TCP listener address. Loopback by default — Tailscale fronts it. |
| `OPENBAO_API_ADDR` | `http://<listen>` | Public API URL (used for UI / OIDC redirects). Set to `https://<host>.<tailnet>.ts.net` once known. |
| `TS_AUTHKEY` | _(unset)_ | Pre-auth key (generate at <https://login.tailscale.com/admin/settings/keys>). If unset, finish `tailscale up` manually inside the LXC. |
```bash
CTID=210 OPENBAO_HOSTNAME=vault CORES=4 RAM=2048 \
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/openbao/install.sh)"
```
#### First-time initialisation
OpenBao starts sealed. Once the LXC is up:
```bash
pct enter <CTID>
export VAULT_ADDR=http://127.0.0.1:8200
bao operator init # save the unseal keys + root token somewhere safe
bao operator unseal # repeat with each key share until unsealed
```
#### Tailscale reverse proxy
The listener binds to `127.0.0.1:8200` only — Tailscale (running inside the
same LXC) acts as the reverse proxy and terminates TLS via tailnet
certificates.
If `TS_AUTHKEY` was supplied at install time, the script runs
`tailscale up` and `tailscale serve --bg --https=443 http://127.0.0.1:8200`
automatically. OpenBao then becomes reachable at
`https://<hostname>.<tailnet>.ts.net`.
Otherwise, finish setup manually inside the LXC:
```bash
pct enter <CTID>
tailscale up --ssh
tailscale serve --bg --https=443 http://127.0.0.1:8200
tailscale status # prints the tailnet FQDN
```
Then point `OPENBAO_API_ADDR` at that FQDN and rerun the script so the UI / OIDC redirects use it:
```bash
OPENBAO_API_ADDR=https://openbao.<tailnet>.ts.net \
bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/feat/lxc-OpenBao/openbao/install.sh)"
```
> HTTPS in `tailscale serve` requires HTTPS to be enabled on your tailnet
> (Admin console → DNS → HTTPS Certificates).
#### Update (from inside the LXC)
```bash
curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/feat/lxc-OpenBao/openbao/install.sh | bash
```
The script auto-detects the presence of `/usr/local/bin/bao` and switches to
update mode. The OpenRC service is stopped, the binary is swapped (the old one
is kept as `bao.bak.<ts>`), then the service is restarted.
### Architecture
- **OS**: latest Alpine LXC template (auto-detected), unprivileged, `nesting=1`, `/dev/net/tun` passthrough for Tailscale
- **Binary**: official `bao` release from `github.com/openbao/openbao`, installed in `/usr/local/bin`
- **Service**: OpenRC, runs as user `openbao`, logs to `/var/log/openbao.log` (rotated daily, 7 days retained)
- **Network**: listener bound to `127.0.0.1:8200`; **Tailscale** runs in the LXC and acts as the reverse proxy (`tailscale serve --https=443`)
- **Config**: `/etc/openbao/config.hcl` — raft storage, TLS disabled on the listener (Tailscale terminates TLS), `disable_mlock = true` for unprivileged LXC
- **Data**: `/var/lib/openbao/data` (raft)
- **Version tracking**: `/opt/openbao_version.txt` records the currently installed tag for idempotent reruns
-610
View File
@@ -1,610 +0,0 @@
#!/bin/bash
# install.sh - OpenBao: LXC creation, installation & update
# Usage:
# From Proxmox host : bash -c "$(curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/feat/lxc-OpenBao/openbao/install.sh)"
# From inside LXC : bash /root/install.sh (updates bao binary)
#
# Single entrypoint, three automatic modes:
# 1. Proxmox host, no existing container -> create LXC + install OpenBao
# 2. Proxmox host, container already present -> update packages + upgrade bao
# 3. Inside an LXC -> install bao if missing, otherwise update
#
# The OpenBao binary install/upgrade logic lives in a single reusable function
# (install_or_upgrade_bao) shared by both the create and update paths.
set -euo pipefail
# --- Config (override via environment) ---
CTID="${CTID:-}"
HOSTNAME_LXC="${OPENBAO_HOSTNAME:-openbao}"
TEMPLATE="${TEMPLATE:-}" # auto-detected when empty
STORAGE="${STORAGE:-local-lvm}"
TEMPLATE_STORAGE="${TEMPLATE_STORAGE:-local}"
CORES="${CORES:-2}"
RAM="${RAM:-1024}"
DISK="${DISK:-8}"
BRIDGE="${BRIDGE:-vmbr0}"
LXC_TAG="${LXC_TAG:-openbao}" # stable identifier for the container
OPENBAO_VERSION="${OPENBAO_VERSION:-latest}" # "latest" or e.g. "v2.0.3"
# GitHub releases endpoint for the openbao/openbao repo (used to resolve "latest").
OPENBAO_RELEASES_URL="${OPENBAO_RELEASES_URL:-https://api.github.com/repos/openbao/openbao/releases}"
OPENBAO_LISTEN_ADDR="${OPENBAO_LISTEN_ADDR:-127.0.0.1:8200}"
# Public API address advertised to clients (also used for OIDC / UI redirects).
# Defaults to the local listener; override with the tailnet URL once known,
# e.g. OPENBAO_API_ADDR="https://openbao.<tailnet>.ts.net".
OPENBAO_API_ADDR="${OPENBAO_API_ADDR:-http://${OPENBAO_LISTEN_ADDR}}"
# Optional: pre-authorise the LXC's Tailscale non-interactively.
# Generate at https://login.tailscale.com/admin/settings/keys
TS_AUTHKEY="${TS_AUTHKEY:-}"
# SCRIPT_URL is what the host-side flow pipes into the LXC. Override it when
# testing from a non-main branch, e.g.
# SCRIPT_URL="https://gitea.arnodo.fr/.../branch/feat/lxc-OpenBao/openbao/install.sh"
SCRIPT_URL="${SCRIPT_URL:-https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/openbao/install.sh}"
VERSION_FILE="${VERSION_FILE:-/opt/openbao_version.txt}"
BAO_USER="openbao"
BAO_CONFIG_DIR="/etc/openbao"
BAO_DATA_DIR="/var/lib/openbao"
# --- Colors ---
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Logs go to stderr so callers can safely use $(fn) without capturing log noise.
log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
# ============================================================
# Generic helpers
# ============================================================
require_root() {
if [[ "$(id -u)" -ne 0 ]]; then
log_error "This script must be run as root (current uid: $(id -u))."
log_error "On Proxmox, launch it from the host shell or via the Web UI shell, both of which run as root."
exit 1
fi
}
# OpenBao publishes release assets named with the raw `uname -m` arch
# (e.g. x86_64, aarch64), not Go-style amd64/arm64.
get_arch() {
case "$(uname -m)" in
x86_64) echo "x86_64" ;;
aarch64) echo "aarch64" ;;
*) log_error "Unsupported architecture: $(uname -m)"; exit 1 ;;
esac
}
# Resolve "latest" -> concrete tag name, otherwise echo input unchanged.
resolve_openbao_version() {
local requested="$1"
if [[ "$requested" != "latest" ]]; then
echo "$requested"
return 0
fi
local tag
tag=$(curl -fsSL "${OPENBAO_RELEASES_URL}/latest" | jq -r '.tag_name')
if [[ -z "$tag" || "$tag" == "null" ]]; then
log_error "Failed to resolve latest OpenBao release from GitHub API."
exit 1
fi
echo "$tag"
}
# ============================================================
# Reusable: install or upgrade the bao binary in-place.
# Used by both fresh-install and update flows.
# Returns 0 on success, exits on hard error.
# ============================================================
install_or_upgrade_bao() {
local tag arch version url tmpdir current
tag=$(resolve_openbao_version "$OPENBAO_VERSION")
arch=$(get_arch)
version="${tag#v}"
current=""
if [[ -f "$VERSION_FILE" ]]; then
current=$(cat "$VERSION_FILE")
fi
if [[ "$current" == "$tag" && -x /usr/local/bin/bao ]]; then
log_info "OpenBao already at $tag, nothing to do."
return 0
fi
# Asset naming convention: bao_<version>_Linux_<arch>.tar.gz
url="https://github.com/openbao/openbao/releases/download/${tag}/bao_${version}_Linux_${arch}.tar.gz"
log_info "Downloading OpenBao ${tag} (${arch}) from ${url}..."
tmpdir=$(mktemp -d)
curl -fsSL "$url" -o "${tmpdir}/bao.tar.gz"
tar -xzf "${tmpdir}/bao.tar.gz" -C "$tmpdir"
if [[ ! -f "${tmpdir}/bao" ]]; then
log_error "Archive did not contain expected 'bao' binary."
rm -rf "$tmpdir"
exit 1
fi
# Stop service if running, swap binary atomically, then restart.
local service_was_running=0
if command -v rc-service >/dev/null 2>&1 && rc-service openbao status >/dev/null 2>&1; then
service_was_running=1
log_info "Stopping openbao service for upgrade..."
rc-service openbao stop || true
fi
if [[ -x /usr/local/bin/bao ]]; then
cp /usr/local/bin/bao "/usr/local/bin/bao.bak.$(date +%s)"
fi
install -m 0755 "${tmpdir}/bao" /usr/local/bin/bao
ln -sf /usr/local/bin/bao /usr/bin/bao
echo "$tag" > "$VERSION_FILE"
log_info "Installed: $(/usr/local/bin/bao --version 2>&1 | head -n1 || true)"
if [[ "$service_was_running" -eq 1 ]]; then
log_info "Restarting openbao service..."
rc-service openbao start
fi
rm -rf "$tmpdir"
}
# ============================================================
# Reusable: bring Tailscale up and publish OpenBao on the tailnet.
# Idempotent: re-running is a no-op once Tailscale is logged in and the
# serve mapping is already in place.
# ============================================================
configure_tailscale_proxy() {
if ! command -v tailscale >/dev/null 2>&1; then
log_warn "tailscale CLI not found, skipping reverse-proxy setup."
return 0
fi
# 1. Authenticate the node (if it isn't already).
local backend_state
backend_state=$(tailscale status --json 2>/dev/null | jq -r '.BackendState // "unknown"')
if [[ "$backend_state" != "Running" ]]; then
if [[ -n "$TS_AUTHKEY" ]]; then
log_info "Bringing Tailscale up with provided auth key..."
tailscale up --authkey "$TS_AUTHKEY" --ssh --hostname "$HOSTNAME_LXC" \
|| log_warn "tailscale up failed — run it manually inside the LXC."
else
log_warn "Tailscale not authenticated and TS_AUTHKEY was not supplied."
log_warn "Finish setup inside the LXC with: tailscale up --ssh"
log_warn "Then publish OpenBao with: tailscale serve --bg --https=443 http://${OPENBAO_LISTEN_ADDR}"
return 0
fi
fi
# 2. Publish the local OpenBao listener on the tailnet (auto-HTTPS).
if tailscale serve status 2>/dev/null | grep -q "${OPENBAO_LISTEN_ADDR}"; then
log_info "Tailscale serve already publishes http://${OPENBAO_LISTEN_ADDR}."
else
log_info "Publishing OpenBao on the tailnet via 'tailscale serve' (HTTPS:443)..."
tailscale serve --bg --https=443 "http://${OPENBAO_LISTEN_ADDR}" \
|| log_warn "tailscale serve failed — enable HTTPS on your tailnet and retry."
fi
local fqdn
fqdn=$(tailscale status --json 2>/dev/null | jq -r '.Self.DNSName // ""' | sed 's/\.$//')
if [[ -n "$fqdn" ]]; then
log_info "OpenBao should now be reachable at: https://${fqdn}"
if [[ "$OPENBAO_API_ADDR" != "https://${fqdn}" ]]; then
log_warn "OPENBAO_API_ADDR is '${OPENBAO_API_ADDR}'."
log_warn "For OIDC / UI redirects, set it to 'https://${fqdn}' and re-run, or edit ${BAO_CONFIG_DIR}/config.hcl."
fi
fi
}
# ============================================================
# Proxmox-host helpers
# ============================================================
# Detect newest Alpine LXC template available from the Proxmox repos.
detect_latest_alpine_template() {
local tmpl
tmpl=$(pveam available --section system 2>/dev/null \
| awk '/^system[[:space:]]+alpine-/ {print $2}' \
| sort -V \
| tail -n1)
if [[ -z "$tmpl" ]]; then
log_warn "Could not query pveam; falling back to a known-good Alpine template."
tmpl="alpine-3.22-default_20250617_amd64.tar.xz"
fi
log_info "Selected Alpine template: $tmpl"
echo "$tmpl"
}
# Find an existing LXC by tag or hostname. Echoes CTID, returns 1 if none.
find_existing_lxc() {
local id host tags
while read -r id _; do
[[ -z "$id" || "$id" == "VMID" ]] && continue
host=$(pct config "$id" 2>/dev/null | awk -F': ' '/^hostname:/ {print $2}' || true)
tags=$(pct config "$id" 2>/dev/null | awk -F': ' '/^tags:/ {print $2}' || true)
if [[ "$host" == "$HOSTNAME_LXC" ]] || [[ ",${tags//;/,}," == *",${LXC_TAG},"* ]]; then
echo "$id"
return 0
fi
done < <(pct list | awk 'NR>1 {print $1}')
return 1
}
ensure_template_present() {
local tmpl="$1"
if ! pveam list "$TEMPLATE_STORAGE" 2>/dev/null | grep -q "$tmpl"; then
log_info "Downloading template ${tmpl} to storage ${TEMPLATE_STORAGE}..."
pveam update >/dev/null
pveam download "$TEMPLATE_STORAGE" "$tmpl"
else
log_info "Template ${tmpl} already present on ${TEMPLATE_STORAGE}."
fi
}
# Pick next available CTID if user did not provide one.
allocate_ctid() {
pvesh get /cluster/nextid 2>/dev/null \
|| pvesh get /cluster/resources --type vm --output-format json 2>/dev/null \
| jq '[.[].vmid] | max + 1' \
|| echo 100
}
# Inject the script into the container and execute it in the requested mode.
# Forwards the relevant runtime configuration through the environment so the
# inner invocation produces the same config the user requested on the host.
exec_in_lxc() {
local ctid="$1"
local mode="$2" # --install or --update
# Ensure base tooling exists inside the container before piping the script.
pct exec "$ctid" -- sh -c "apk add --no-cache bash curl jq ca-certificates >/dev/null 2>&1"
curl -fsSL "$SCRIPT_URL" \
| pct exec "$ctid" -- env \
SCRIPT_URL="$SCRIPT_URL" \
OPENBAO_VERSION="$OPENBAO_VERSION" \
OPENBAO_HOSTNAME="$HOSTNAME_LXC" \
OPENBAO_LISTEN_ADDR="$OPENBAO_LISTEN_ADDR" \
OPENBAO_API_ADDR="$OPENBAO_API_ADDR" \
TS_AUTHKEY="$TS_AUTHKEY" \
bash -s -- "$mode"
}
# ============================================================
# MODE: Proxmox host — create LXC + install
# ============================================================
create_lxc() {
log_info "=== OpenBao — LXC creation ==="
if [[ -z "$TEMPLATE" ]]; then
TEMPLATE=$(detect_latest_alpine_template)
else
log_info "Using user-provided template: $TEMPLATE"
fi
ensure_template_present "$TEMPLATE"
if [[ -z "$CTID" ]]; then
CTID=$(allocate_ctid)
log_info "Auto-selected CTID: $CTID"
fi
log_info "Creating LXC ${CTID} (${HOSTNAME_LXC})..."
pct create "$CTID" "${TEMPLATE_STORAGE}:vztmpl/${TEMPLATE}" \
--hostname "$HOSTNAME_LXC" \
--cores "$CORES" \
--memory "$RAM" \
--rootfs "${STORAGE}:${DISK}" \
--net0 "name=eth0,bridge=${BRIDGE},ip=dhcp" \
--unprivileged 1 \
--features "nesting=1" \
--tags "infra-script,${LXC_TAG}" \
--onboot 1 \
--start 0
# Tailscale needs /dev/net/tun inside the unprivileged container.
log_info "Adding /dev/net/tun passthrough for Tailscale..."
cat >> "/etc/pve/lxc/${CTID}.conf" <<EOF
lxc.cgroup2.devices.allow: c 10:200 rwm
lxc.mount.entry: /dev/net dev/net none bind,create=dir
lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file
EOF
log_info "Starting LXC ${CTID}..."
pct start "$CTID"
# Wait for network to come up
local tries=0
until pct exec "$CTID" -- sh -c "ip -4 addr show eth0 | grep -q 'inet '" 2>/dev/null; do
tries=$((tries + 1))
if (( tries > 20 )); then
log_error "LXC ${CTID} did not acquire an IP after 20s."
exit 1
fi
sleep 1
done
log_info "Running installer inside LXC ${CTID}..."
exec_in_lxc "$CTID" "--install"
local ip
ip=$(pct exec "$CTID" -- ip -4 addr show eth0 2>/dev/null | awk '/inet /{print $2}' | cut -d/ -f1 || true)
echo ""
log_info "========================================="
log_info "LXC ${CTID} created successfully!"
log_info "========================================="
echo ""
echo " Hostname : ${HOSTNAME_LXC}"
echo " IP : ${ip:-pending}"
echo " API : http://${ip:-<ip>}:8200"
echo ""
echo "Next steps:"
echo " pct enter ${CTID}"
echo " export VAULT_ADDR=http://127.0.0.1:8200"
echo " bao operator init # initialise & retrieve unseal keys + root token"
echo " bao operator unseal # repeat with the unseal keys"
echo ""
}
# ============================================================
# MODE: Proxmox host — update existing LXC
# ============================================================
update_lxc() {
local ctid="$1"
log_info "=== OpenBao — updating existing LXC ${ctid} ==="
if ! pct status "$ctid" | grep -q running; then
log_info "Starting LXC ${ctid}..."
pct start "$ctid"
sleep 3
fi
log_info "Refreshing Alpine packages inside LXC ${ctid}..."
pct exec "$ctid" -- sh -c "apk update >/dev/null && apk upgrade >/dev/null"
log_info "Upgrading bao binary inside LXC ${ctid}..."
exec_in_lxc "$ctid" "--update"
log_info "Update of LXC ${ctid} complete."
}
# ============================================================
# MODE: inside LXC — fresh install of OpenBao
# ============================================================
install_inside_lxc() {
log_info "=== OpenBao — installation ==="
log_info "Updating package index..."
apk update >/dev/null
apk upgrade >/dev/null
log_info "Installing dependencies..."
apk add --no-cache bash curl jq ca-certificates gcompat openrc logrotate tailscale >/dev/null
log_info "Enabling tailscaled..."
rc-update add tailscale default >/dev/null 2>&1 || true
rc-service tailscale start >/dev/null 2>&1 || log_warn "tailscaled failed to start (is /dev/net/tun mapped into the LXC?)"
install_or_upgrade_bao
log_info "Creating ${BAO_USER} system user..."
if ! id "$BAO_USER" >/dev/null 2>&1; then
addgroup -S "$BAO_USER" 2>/dev/null || true
adduser -S -D -H -h "$BAO_DATA_DIR" -s /sbin/nologin -G "$BAO_USER" "$BAO_USER"
fi
log_info "Provisioning directories..."
mkdir -p "$BAO_CONFIG_DIR" "$BAO_DATA_DIR/data"
chown -R "${BAO_USER}:${BAO_USER}" "$BAO_DATA_DIR"
chmod 750 "$BAO_DATA_DIR"
if [[ ! -f "${BAO_CONFIG_DIR}/config.hcl" ]]; then
log_info "Writing default ${BAO_CONFIG_DIR}/config.hcl..."
# OpenBao listens on loopback only; Tailscale (running in the same LXC)
# acts as the reverse proxy and terminates TLS via tailnet certificates.
# https://openbao.org/docs/configuration/
cat > "${BAO_CONFIG_DIR}/config.hcl" <<EOF
ui = true
disable_mlock = true
storage "raft" {
path = "${BAO_DATA_DIR}/data"
node_id = "${HOSTNAME_LXC}"
}
listener "tcp" {
address = "${OPENBAO_LISTEN_ADDR}"
tls_disable = 1
}
api_addr = "${OPENBAO_API_ADDR}"
cluster_addr = "http://127.0.0.1:8201"
EOF
chown root:"$BAO_USER" "${BAO_CONFIG_DIR}/config.hcl"
chmod 640 "${BAO_CONFIG_DIR}/config.hcl"
else
log_info "Existing ${BAO_CONFIG_DIR}/config.hcl preserved."
fi
log_info "Installing OpenRC service..."
cat > /etc/init.d/openbao <<'EOF'
#!/sbin/openrc-run
name="OpenBao"
description="OpenBao secrets manager"
command="/usr/local/bin/bao"
command_args="server -config=/etc/openbao/config.hcl"
command_user="openbao:openbao"
command_background=true
pidfile="/run/${RC_SVCNAME}.pid"
directory="/var/lib/openbao"
output_log="/var/log/openbao.log"
error_log="/var/log/openbao.log"
depend() {
need net
after net
}
start_pre() {
checkpath --directory --owner openbao:openbao --mode 0750 /var/lib/openbao
checkpath --directory --owner openbao:openbao --mode 0750 /var/lib/openbao/data
checkpath --file --owner openbao:openbao --mode 0644 /var/log/openbao.log
}
EOF
chmod +x /etc/init.d/openbao
rc-update add openbao default >/dev/null
cat > /etc/logrotate.d/openbao <<'EOF'
/var/log/openbao.log {
daily
rotate 7
compress
missingok
notifempty
copytruncate
}
EOF
ln -sf /usr/sbin/logrotate /etc/periodic/daily/logrotate 2>/dev/null || true
log_info "Starting openbao service..."
rc-service openbao start || log_warn "openbao failed to start — inspect /var/log/openbao.log"
log_info "Enabling console auto-login on tty1..."
# Alpine ships busybox getty by default; agetty (from util-linux) is what
# supports --autologin.
apk add --no-cache agetty >/dev/null 2>&1 || apk add --no-cache util-linux >/dev/null
# Replace any existing tty1 entry, then append our autologin line. Doing it
# in two steps (delete + append) is more robust than an in-place sed against
# a pattern that may drift across Alpine releases.
sed -i '/^tty1::/d' /etc/inittab
echo 'tty1::respawn:/sbin/agetty --autologin root --noclear 38400 tty1' >> /etc/inittab
# Tell PID 1 to re-read /etc/inittab so the change takes effect without a reboot.
kill -HUP 1 2>/dev/null || true
# Kick any getty/agetty still attached to tty1 so init respawns it *now* with
# the new line — otherwise the first web-console session lands on the stale
# process and the operator has to type `exit` once before autologin kicks in.
pkill -KILL -f '(getty|agetty).*tty1' 2>/dev/null || true
configure_tailscale_proxy
log_info "Configuring MOTD..."
# /etc/profile.d/ runs for every interactive login shell — works for both
# the auto-login tty and Tailscale SSH. Quoted heredoc: every variable is
# resolved at login time, not at install time.
cat > /etc/profile.d/00-openbao.sh <<'MOTD'
TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' '
/"Self"/ { in_self=1 }
in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit }
')
[[ -z "$TS_FQDN" ]] && TS_FQDN="$(hostname).ts.net"
BAO_VERSION=$(cat /opt/openbao_version.txt 2>/dev/null || echo "unknown")
# `bao status` exit codes: 0 = unsealed, 2 = sealed, anything else = error.
VAULT_ADDR=http://127.0.0.1:8200 /usr/local/bin/bao status >/dev/null 2>&1
case $? in
0) SEAL_STATE="unsealed" ;;
2) SEAL_STATE="SEALED (run: bao operator unseal)" ;;
*) SEAL_STATE="unreachable" ;;
esac
echo ""
echo " ___ ____ "
echo " / _ \ _ __ ___ _ __ | __ ) __ _ ___ "
echo "| | | | '_ \ / _ \ '_ \| _ \ / _\` |/ _ \\"
echo "| |_| | |_) | __/ | | | |_) | (_| | (_) |"
echo " \___/| .__/ \___|_| |_|____/ \__,_|\___/"
echo " |_| "
echo ""
echo "OpenBao Secrets Manager (${BAO_VERSION})"
echo "─────────────────────────────────────────"
echo "Access:"
echo " • API (local) : http://127.0.0.1:8200"
echo " • Tailnet : https://${TS_FQDN}"
echo " • Seal status : ${SEAL_STATE}"
echo ""
echo "Useful commands:"
echo " export VAULT_ADDR=http://127.0.0.1:8200"
echo " bao status"
echo " bao operator init # first-time only"
echo " bao operator unseal # after every restart"
echo " rc-service openbao status"
echo " tail -f /var/log/openbao.log"
echo "─────────────────────────────────────────"
echo ""
MOTD
chmod +x /etc/profile.d/00-openbao.sh
log_info "Cleaning up..."
rm -rf /var/cache/apk/*
echo ""
log_info "========================================="
log_info "OpenBao installation complete!"
log_info "========================================="
echo ""
echo "Initialise the server with:"
echo " export VAULT_ADDR=http://127.0.0.1:8200"
echo " bao operator init"
echo " bao operator unseal # repeat with each unseal key share"
echo ""
}
# ============================================================
# MODE: inside LXC — update only
# ============================================================
update_inside_lxc() {
log_info "=== OpenBao — update ==="
apk update >/dev/null
apk upgrade >/dev/null
install_or_upgrade_bao
configure_tailscale_proxy
log_info "Update complete."
}
# ============================================================
# Main — dispatch on explicit mode flag or auto-detect context
# ============================================================
main() {
case "${1:-}" in
--install)
install_inside_lxc
return
;;
--update)
update_inside_lxc
return
;;
esac
if command -v pct >/dev/null 2>&1; then
# Running on a Proxmox host
require_root
local existing=""
if existing=$(find_existing_lxc); then
log_info "Found existing OpenBao LXC (CTID ${existing}, hostname/tag match) — switching to update mode."
update_lxc "$existing"
else
create_lxc
fi
else
# Inside a container (no Proxmox tooling)
require_root
if [[ -x /usr/local/bin/bao ]]; then
update_inside_lxc
else
install_inside_lxc
fi
fi
}
main "$@"
-62
View File
@@ -1,62 +0,0 @@
# Proxy Server
Deploys a secure reverse proxy with Tailscale + Nginx Proxy Manager.
## Quick Start
```bash
curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/proxy/install.sh | bash
```
## Components
- **Tailscale**: Private network access (SSH, admin panel)
- **Nginx Proxy Manager**: Public reverse proxy (HTTP/HTTPS)
- **UFW**: Firewall (only 80/443 exposed publicly)
- **fail2ban** + **unattended-upgrades**: Basic hardening
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `PROXY_HOSTNAME` | `proxy` | Server hostname |
| `TZ` | `Europe/Paris` | Timezone |
Example:
```bash
PROXY_HOSTNAME=myproxy TZ=America/New_York curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/proxy/install.sh | bash
```
## What it does
1. Sets hostname
2. Installs base packages (vim, fail2ban, unattended-upgrades, at)
3. Installs and connects Tailscale (will prompt for authentication)
4. Configures sysctl for exit-node capability
5. Installs Docker
6. Configures UFW (80/443 public, everything else via Tailscale only)
7. Deploys Nginx Proxy Manager
8. Exposes NPM admin panel via Tailscale serve
9. Temporarily opens SSH port 22 for 5 minutes (safety net)
## SSH Safety Net
During installation, SSH port 22 is temporarily opened for 5 minutes to prevent lockout if you're connected via public IP. After 5 minutes, it will be automatically closed and only Tailscale SSH will work.
```bash
# List scheduled jobs
sudo atq
# Cancel the scheduled SSH closure (replace N with job number)
sudo atrm N
# Manually close SSH port 22 if needed
sudo ufw delete allow 22/tcp
```
## Post-install
- Access NPM admin: `https://proxy.<your-tailnet>.ts.net`
- Default credentials: `admin@example.com` / `changeme`
- Optionally approve exit-node in Tailscale admin console
-360
View File
@@ -1,360 +0,0 @@
#!/bin/bash
# install.sh - Automated deployment of Proxy Server with Tailscale + Traefik v3 + Fail2ban
# Usage: curl -fsSL https://gitea.arnodo.fr/Damien/infra-scripts/raw/branch/main/proxy/install.sh | bash
set -euo pipefail
# Colors for logging
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Pre-flight checks
check_root() {
if [[ $EUID -eq 0 ]]; then
log_error "Do not run as root directly. Use a user with sudo privileges."
exit 1
fi
if ! sudo -v; then
log_error "User must have sudo privileges."
exit 1
fi
}
check_debian() {
if ! grep -qi debian /etc/os-release 2>/dev/null; then
log_warn "This script is optimized for Debian. Continuing anyway..."
fi
}
# Configuration variables (can be overridden via environment)
PROXY_HOSTNAME="${PROXY_HOSTNAME:-proxy}"
TRAEFIK_DIR="$HOME/traefik"
# ACME_EMAIL is required for Let's Encrypt certificate issuance notifications.
# Export it before running: export ACME_EMAIL=you@example.com
ACME_EMAIL="${ACME_EMAIL:-}"
# Optional: pre-authorize Tailscale non-interactively (recommended for curl|bash).
# Generate at https://login.tailscale.com/admin/settings/keys
TS_AUTHKEY="${TS_AUTHKEY:-}"
main() {
log_info "=== Proxy Server Deployment (Traefik v3) ==="
check_root
check_debian
# Prompt for ACME email if not set. Only attempt interactive prompt when a
# TTY is available — when invoked via `curl … | bash`, stdin is the pipe
# and reading from /dev/tty may also fail (e.g. non-interactive runners).
if [[ -z "$ACME_EMAIL" ]]; then
if [[ -r /dev/tty ]]; then
log_warn "ACME_EMAIL is not set in the environment."
read -rp "Enter your ACME email address: " ACME_EMAIL < /dev/tty || true
fi
if [[ -z "$ACME_EMAIL" ]]; then
log_error "ACME_EMAIL is required. Export it before running:"
log_error " export ACME_EMAIL=you@example.com"
exit 1
fi
fi
if [[ "$(hostname)" != "$PROXY_HOSTNAME" ]]; then
log_info "Setting hostname to: $PROXY_HOSTNAME"
echo "$PROXY_HOSTNAME" | sudo tee /etc/hostname > /dev/null
sudo hostnamectl set-hostname "$PROXY_HOSTNAME"
else
log_info "Hostname already set to $PROXY_HOSTNAME, skipping."
fi
log_info "Installing base packages..."
sudo apt update -qq
sudo apt install -y -qq vim ca-certificates curl gnupg lsb-release fail2ban unattended-upgrades ufw ethtool networkd-dispatcher > /dev/null
log_info "Installing Tailscale..."
curl -fsSL https://tailscale.com/install.sh | sh
log_info "Configuring sysctl for exit-node support..."
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf > /dev/null
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf > /dev/null
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf > /dev/null
log_info "Configuring ethtool for Tailscale UDP GRO forwarding..."
# Determine the default-route interface and disable rx-gro-list / enable
# rx-udp-gro-forwarding to avoid the Tailscale throughput warning.
NETDEV=$(ip -o route show default | awk '{print $5; exit}')
if [[ -z "$NETDEV" ]]; then
log_error "Could not determine default network interface."
exit 1
fi
sudo ethtool -K "$NETDEV" rx-udp-gro-forwarding on rx-gro-list off
# Persist across reboots via networkd-dispatcher
sudo mkdir -p /etc/networkd-dispatcher/routable.d
printf '#!/bin/sh\nethtool -K %s rx-udp-gro-forwarding on rx-gro-list off\n' "$NETDEV" \
| sudo tee /etc/networkd-dispatcher/routable.d/50-tailscale > /dev/null
sudo chmod 755 /etc/networkd-dispatcher/routable.d/50-tailscale
# Connect to Tailscale only if not already logged in (idempotent re-runs).
if ! sudo tailscale status >/dev/null 2>&1; then
log_info "Connecting to Tailscale..."
if [[ -n "$TS_AUTHKEY" ]]; then
sudo tailscale up --ssh --advertise-exit-node --authkey="$TS_AUTHKEY"
else
log_warn "TS_AUTHKEY not set — interactive browser auth required."
sudo tailscale up --ssh --advertise-exit-node
fi
else
log_info "Tailscale already connected, skipping."
fi
log_info "Installing Docker..."
sudo mkdir -m 0755 -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update -qq
sudo apt install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin > /dev/null
log_info "Adding current user to docker group..."
sudo usermod -aG docker "$USER"
log_info "Configuring UFW firewall..."
# Idempotent: only reset if our marker rules are absent. This preserves any
# rules added later by the operator on re-runs.
if ! sudo ufw status | grep -q "tailscale0"; then
sudo ufw --force reset > /dev/null
sudo ufw default deny incoming > /dev/null
sudo ufw default allow outgoing > /dev/null
# Allow HTTP/HTTPS from the public internet (handled by Traefik)
sudo ufw allow 80/tcp > /dev/null
sudo ufw allow 443/tcp > /dev/null
# Allow all traffic on Tailscale interface (dashboard, metrics, SSH, admin — everything internal)
sudo ufw allow in on tailscale0 > /dev/null
# Port 22 is intentionally NOT opened publicly; Tailscale SSH covers management access
sudo ufw --force enable > /dev/null
else
log_info "UFW already configured, skipping reset."
fi
log_info "Configuring Fail2ban for Traefik..."
# Create the log file and fail2ban socket dir now so:
# - fail2ban can open the log file when the jail loads.
# - docker doesn't bind-mount /var/run/fail2ban as an empty dir if the
# exporter container starts before fail2ban writes its socket.
sudo mkdir -p /var/log/traefik /var/run/fail2ban
sudo chown "$USER":"$USER" /var/log/traefik
sudo touch /var/log/traefik/access.log
sudo tee /etc/fail2ban/filter.d/traefik.conf > /dev/null << 'EOF'
[Definition]
# Match JSON log lines where ClientHost is the offending IP and DownstreamStatus
# is an auth/abuse status (401, 403, 429) or a server error (5xx).
# Legitimate 404s on missing assets are excluded so dev traffic doesn't ban users.
# Two patterns cover both possible field orderings in the JSON.
failregex = ^.*"ClientHost":"<HOST>".*"DownstreamStatus":(401|403|429|5[0-9]{2})
^.*"DownstreamStatus":(401|403|429|5[0-9]{2}).*"ClientHost":"<HOST>"
ignoreregex =
EOF
sudo tee /etc/fail2ban/jail.d/traefik.conf > /dev/null << 'EOF'
[traefik-auth]
enabled = true
filter = traefik
logpath = /var/log/traefik/access.log
maxretry = 10
findtime = 5m
bantime = 1h
action = iptables-multiport[name=traefik, port="80,443", protocol=tcp]
EOF
sudo systemctl restart fail2ban
log_info "Creating Traefik stack under $TRAEFIK_DIR..."
mkdir -p "$TRAEFIK_DIR/conf.d"
# acme.json must be 600 or Traefik refuses to use it
touch "$TRAEFIK_DIR/acme.json"
chmod 600 "$TRAEFIK_DIR/acme.json"
# --- docker-compose.yml ---
# SECURITY: The dashboard/API entrypoint is bound to 127.0.0.1 ONLY.
# Combined with `api.insecure: true` in traefik.yml, the dashboard has no
# authentication — it is only reachable via the host loopback and exposed
# selectively over the tailnet through `tailscale serve`. DO NOT change
# this port binding to 0.0.0.0 or any non-loopback address.
cat > "$TRAEFIK_DIR/docker-compose.yml" << 'EOF'
services:
traefik:
image: traefik:v3
container_name: traefik
restart: unless-stopped
dns:
- 100.100.100.100
ports:
- "80:80"
- "443:443"
# MUST stay on 127.0.0.1: dashboard is unauthenticated (see traefik.yml).
- "127.0.0.1:8080:8080"
volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./conf.d:/etc/traefik/conf.d:ro
- ./acme.json:/acme.json
- /var/log/traefik:/var/log/traefik
- /etc/localtime:/etc/localtime:ro
fail2ban-exporter:
image: registry.gitlab.com/hctrdev/fail2ban-prometheus-exporter:latest
container_name: fail2ban-exporter
restart: unless-stopped
user: root
ports:
# Metrics reachable only via Tailscale (127.0.0.1 binding + UFW blocks public access)
- "127.0.0.1:9191:9191"
volumes:
# Mount the directory, not the socket file: avoids Docker creating a directory
# at the path when fail2ban is briefly down and recreating its socket.
- /var/run/fail2ban:/var/run/fail2ban
EOF
# --- traefik.yml (static config) ---
# Unquoted EOF: ${ACME_EMAIL} must expand at write time into the static config.
cat > "$TRAEFIK_DIR/traefik.yml" << EOF
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
traefik:
address: ":8080"
certificatesResolvers:
letsencrypt:
acme:
email: "${ACME_EMAIL}"
storage: /acme.json
httpChallenge:
entryPoint: web
providers:
file:
directory: /etc/traefik/conf.d
watch: true
metrics:
prometheus:
addEntryPointsLabels: true
addServicesLabels: true
addRoutersLabels: true
entryPoint: traefik
api:
dashboard: true
# insecure exposes the dashboard on the :8080 entrypoint without auth.
# This is acceptable ONLY because docker-compose.yml binds 8080 to 127.0.0.1.
# Public reach requires going through `tailscale serve` (tailnet-authenticated).
insecure: true
accessLog:
filePath: /var/log/traefik/access.log
format: json
EOF
# --- conf.d/gitea.yml (dynamic config) ---
cat > "$TRAEFIK_DIR/conf.d/gitea.yml" << 'EOF'
http:
routers:
gitea:
rule: "Host(`gitea.arnodo.fr`)"
entryPoints:
- websecure
service: gitea
tls:
certResolver: letsencrypt
services:
gitea:
loadBalancer:
servers:
- url: "http://gitea.taila5ad8.ts.net:3000"
EOF
log_info "Starting Traefik stack..."
# Use sg to apply the docker group without requiring a re-login.
# cd into the dir so paths inside the command don't break on spaces in $HOME.
(cd "$TRAEFIK_DIR" && sg docker -c "docker compose up -d")
# Idempotent: only register the serve mapping if it isn't already present.
# Use --json (stable contract) and capture stdout+stderr so any help/error
# output on older tailscale builds doesn't leak to the user's terminal.
if ! sudo tailscale serve status --json 2>&1 | grep -q '"127.0.0.1:8080"'; then
log_info "Exposing Traefik dashboard via Tailscale serve..."
sudo tailscale serve --bg http://localhost:8080
else
log_info "Tailscale serve already configured for dashboard, skipping."
fi
log_info "Configuring MOTD..."
# /etc/profile.d/ runs for every interactive login shell regardless of the SSH
# implementation (works for both Tailscale SSH and regular OpenSSH).
cat << 'MOTD' | sudo tee /etc/profile.d/00-proxy.sh > /dev/null
TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' '
/"Self"/ { in_self=1 }
in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit }
')
[[ -z "$TS_FQDN" ]] && TS_FQDN="$(hostname).ts.net"
echo ""
echo " ____ ____ _____ ____ __"
echo "| _ \| _ \ / _ \ \/ /\ \ / /"
echo "| |_) | |_) | | | \ / \ V /"
echo "| __/| _ <| |_| / \ | |"
echo "|_| |_| \_\\___/_/\_\ |_|"
echo ""
echo "Traefik v3 Reverse Proxy"
echo "─────────────────────────────────────────"
echo "Access:"
echo " • Dashboard : https://${TS_FQDN} (Tailscale)"
echo " • HTTP/HTTPS: Public ports 80/443"
echo ""
echo "Services:"
docker ps --format ' • {{.Names}} : {{.Status}}' 2>/dev/null || echo " Docker not running"
echo ""
echo "Useful commands:"
echo " cd ~/traefik && docker compose logs -f"
echo " sudo tailscale serve status"
echo "─────────────────────────────────────────"
echo ""
MOTD
TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' '
/"Self"/ { in_self=1 }
in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit }
' || echo "${PROXY_HOSTNAME}.ts.net")
echo ""
log_info "=========================================="
log_info "Deployment complete!"
log_info "=========================================="
echo ""
echo "Traefik dashboard : https://${TS_FQDN}"
echo "Stack directory : $TRAEFIK_DIR"
echo ""
echo "Note: Approve exit-node in Tailscale admin console if needed."
echo "Note: Fail2ban is running on the host; fail2ban-exporter exposes"
echo " metrics on port 9191 (Tailscale-only, not public)."
echo ""
}
main "$@"