diff --git a/image/Makefile b/image/Makefile index 6f7fc85..6fabad9 100644 --- a/image/Makefile +++ b/image/Makefile @@ -15,10 +15,6 @@ CONFIGURE_SH = $(SCRIPT_DIR)/configure.sh build: @echo ">>> Building $(IMAGE_FILE) ..." - @test -f $(OVERLAY_DIR)/root/gpg-key.asc || { \ - echo "ERROR: GPG key not found. Generate key first" >&2; \ - exit 1; \ - } alpine-make-vm-image \ --branch $(ALPINE_BRANCH) \ --image-format $(IMAGE_FORMAT) \ @@ -28,7 +24,7 @@ build: --fs-skel-dir $(OVERLAY_DIR) \ --fs-skel-chown root:root \ --script-chroot \ - --packages "python3 py3-yaml py3-pydantic git curl gnupg docker docker-cli-buildx docker-cli-compose cronie" \ + --packages "git curl docker docker-cli-buildx docker-cli-compose cronie" \ $(IMAGE_FILE) \ $(CONFIGURE_SH) @echo ">>> Image built: $(IMAGE_FILE)" diff --git a/image/configure.sh b/image/configure.sh index 6db77cc..96e1a9d 100755 --- a/image/configure.sh +++ b/image/configure.sh @@ -33,25 +33,15 @@ rc-update add acpid default rc-update add docker default rc-update add cronie default -step 'Import GPG key for root' -GPG_KEY_FILE="/root/gpg-key.asc" -if [ -f "$GPG_KEY_FILE" ]; then - echo "Found GPG key file: $GPG_KEY_FILE" - gpg --batch --import "$GPG_KEY_FILE" - # Mark the imported key as ultimately trusted (non-interactive) - fingerprint=$(gpg --batch --with-colons --fingerprint \ - | grep '^fpr:' | head -1 | cut -d: -f10) - if [ -n "$fingerprint" ]; then - echo "$fingerprint:6:" | gpg --batch --import-ownertrust - echo " * GPG key trusted: $fingerprint" - fi - rm -f "$GPG_KEY_FILE" -else - echo "WARNING: GPG key file not found at $GPG_KEY_FILE — skipping import" >&2 -fi - step 'Clean up APK cache' rm -rf /var/cache/apk/* || true +step 'Setup git user' +git config --user.email bearnet+keeper@sab.ee +git config --user.name "B.B.K.K.B.K.K" +adduser -S keeper +mkdir /users +chown keeper /users + echo '' echo '=== Configure script completed ===' diff --git a/image/overlay/daemon/update-keys.sh b/image/overlay/daemon/update-keys.sh new file mode 100755 index 0000000..193a153 --- /dev/null +++ b/image/overlay/daemon/update-keys.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# VARIABLES: _REVISION_ _REPO_ + +set -euo pipefail +mkdir -p /users && chown keeper /users && chmod 644 /users + +su keeper + +init_repo(){ + git clone -b _REVISION_ _REPO_ /users +} + +if [[ ! -d /users/.git ]]; then + init_repo +elif [[ -d /users && cd /users && ! git pull origin _REVISION_ ]]; then + init_repo +fi \ No newline at end of file diff --git a/image/overlay/daemon/update.sh b/image/overlay/daemon/update.sh deleted file mode 100755 index 54bdc40..0000000 --- a/image/overlay/daemon/update.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh - -mkdir -p /app -cd /app -rm -rf ./template ./snapshot -mkdir /app/template /app/snapshot - -set -euo pipefail - -git clone -b _REVISION_ _REPO_ template - -python3 /daemon/orchestrate.py \ - --root /app/template \ - --network cloud \ - --volume-parent /data/volumes \ - --snapshot-root /app/snapshot diff --git a/image/overlay/etc/init.d/auth-server b/image/overlay/etc/init.d/auth-server new file mode 100755 index 0000000..4e9c12e --- /dev/null +++ b/image/overlay/etc/init.d/auth-server @@ -0,0 +1,11 @@ +#!/sbin/openrc-run +command="/usr/bin/auth-server" +command_background=true +command_args="-addr 0.0.0.0:8080 -root /users" +command_user="keeper" + +pidfile="/run/${RC_SVCNAME}.pid" + +depend() { + need net +} \ No newline at end of file diff --git a/image/overlay/etc/init.d/bubble b/image/overlay/etc/init.d/bubble new file mode 100755 index 0000000..7bb6d40 --- /dev/null +++ b/image/overlay/etc/init.d/bubble @@ -0,0 +1,10 @@ +#!/sbin/openrc-run + +depend() { + need auth-server docker +} + +command="/usr/bin/bubble" +command_args="-config /daemon/config.yaml" +pidfile="/run/${RC_SVCNAME}.pid" +command_background=true diff --git a/image/overlay/var/spool/cron/crontabs/root b/image/overlay/var/spool/cron/crontabs/root index 461d9ce..e972907 100644 --- a/image/overlay/var/spool/cron/crontabs/root +++ b/image/overlay/var/spool/cron/crontabs/root @@ -1,2 +1,2 @@ # min hour day month weekday command -*/15 * * * * /usr/bin/sh /daemon/update.sh \ No newline at end of file +*/15 * * * * /usr/bin/sh /daemon/update-keys.sh \ No newline at end of file diff --git a/scripts/gen-gpg-key.sh b/scripts/gen-gpg-key.sh deleted file mode 100755 index 7d9b4ca..0000000 --- a/scripts/gen-gpg-key.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/bin/sh -# gen-gpg-key.sh — Generate a passwordless GPG key and export to overlay/. -# Run this BEFORE alpine-make-vm-image on the build host. -set -eu - -KEY_FILE="./bot-gpg-key.asc" -KEY_NAME=${KEY_NAME:-"VM Builder"} -KEY_EMAIL=${KEY_EMAIL:-"builder@localhost"} -echo "Gnerating GPG key on behalf of $KEY_NAME ($KEY_EMAIL)" -if ! command -v gpg >/dev/null 2>&1; then - echo "ERROR: gpg (gnupg) is required on the build host" >&2 - exit 1 -fi - -# Use an isolated temporary GNUPGHOME so the host keyring is never touched. -GNUPGHOME="$(mktemp -d /tmp/gpg-tmphome.XXXXXX)" -export GNUPGHOME -cleanup_home() { rm -rf "$GNUPGHOME"; } -trap cleanup_home EXIT - -# Ensure the target directory exists -mkdir -p "$(dirname "$KEY_FILE")" - -# Only generate if the key file doesn't already exist -if [ -f "$KEY_FILE" ]; then - echo "GPG key already exists: $KEY_FILE" - echo "Remove it first if you want to regenerate." - exit 0 -fi - -echo "=== Generating passwordless RSA 4096 GPG key ===" - -# Create a batch specification for unattended key generation. -# %no-protection means no passphrase. -BATCH_FILE="$(mktemp /tmp/gpg-batch.XXXXXX)" -cat > "$BATCH_FILE" <<'GPGBATCH' -%echo Generating RSA 4096 key... -Key-Type: RSA -Key-Length: 4096 -Subkey-Type: RSA -Subkey-Length: 4096 -Name-Real: KEY_NAME -Name-Email: KEY_EMAIL -Expire-Date: 0 -%no-protection -%commit -%echo Done -GPGBATCH - -sed -i "s/KEY_NAME/$KEY_NAME/g" "$BATCH_FILE" -sed -i "s/KEY_EMAIL/$KEY_EMAIL/g" "$BATCH_FILE" - -gpg --batch --yes --pinentry-mode loopback --generate-key "$BATCH_FILE" -rm -f "$BATCH_FILE" - -echo "" -echo "=== Exporting secret key to $KEY_FILE ===" - -gpg --batch --yes --pinentry-mode loopback --export-secret-keys --armor "$KEY_EMAIL" > "$KEY_FILE" - -# Also export just the public key for reference -gpg --batch --yes --pinentry-mode loopback --export --armor "$KEY_EMAIL" > "./bot-gpg-pubkey.asc" - -# Print fingerprint -gpg --batch --fingerprint "$KEY_EMAIL" || true diff --git a/scripts/orchestrate.py b/scripts/orchestrate.py deleted file mode 100755 index fa629f3..0000000 --- a/scripts/orchestrate.py +++ /dev/null @@ -1,277 +0,0 @@ -import argparse -import os -import re -import shutil -import subprocess -import sys - -from pydantic import BaseModel, ValidationError - -import yaml - - -class BuildSpec(BaseModel): - context: str - dockerfile: str - args: dict[str, str] = {} - - -class Service(BaseModel): - build: str | BuildSpec | None = None - image: str | None = None - volumes: list[str] | None = None - command: str | None = None - depends_on: list[str] | None = None - entrypoint: list[str] | None = None - env_file: str | None = None - environment: dict[str, str] | None = None - - -class ComposeSpec(BaseModel): - services: dict[str, Service] - - -parser = argparse.ArgumentParser() -parser.add_argument("--dry", action="store_true", default=False) -parser.add_argument("--root", default=".", type=str) -parser.add_argument("--snapshot-root", default="snapshot", type=str) -parser.add_argument("--network", default="cloud", type=str) -parser.add_argument("--volume-parent", type=str, required=True) - -args = parser.parse_args() -dry_run = args.dry -root = args.root -snapshot_root=args.snapshot_root -network = args.network -vol_parent = args.volume_parent - -has_error = False - - -def err(msg: str): - global has_error - has_error = True - print(msg) - - -userNamePattern = r"^[a-z0-9]+$" - -users: list[tuple[int, str]] = [] -for userDir in os.listdir(f"{root}/users"): - spl = userDir.split("-", 1) - if len(spl) != 2: - err(f"ERR: Valid priority isn't set for userDir {spl}") - continue - elif not spl[0].isdigit(): - err(f"ERR: UserDir {spl} has an invalid priority.") - continue - elif not re.match(userNamePattern, spl[1]): - err(f"ERR: Name {spl[1]} doesn't match {userNamePattern}") - continue - users.append((int(spl[0]), spl[1])) - -users.sort(key=lambda x: x[0]) - -serviceNamePattern = r"^[a-z0-9]+$" - -def validate_compose_spec(spec: ComposeSpec, workdir: str): - invalid_services = [serviceName for serviceName, _ in spec.services.items() if - not re.match(serviceNamePattern, serviceName)] - if len(invalid_services) > 0: - err(f"ERR: Invalid service names: {', '.join([x for x in invalid_services])}") - for invalid_service in invalid_services: - spec.services.pop(invalid_service) - for key, svc in spec.services.items(): - validate_service_spec(svc, workdir, key) - - depended_services = [(svc.depends_on or []) for _name, svc in spec.services.items()] - depended_services = [item for sublist in depended_services for item in sublist] - for serviceName in depended_services: - if serviceName not in spec.services: - err(f"ERR: Service {serviceName} is depended on but not defined in the compose spec") - - - -# Fields recognized by our Pydantic models — anything else in the YAML -# is silently dropped by Pydantic and could interfere with our injection. -_TOP_LEVEL_KNOWN = {"services"} -_SERVICE_KNOWN = {"build", "image", "volumes", "command", "depends_on", - "entrypoint", "env_file", "environment"} -_BUILD_KNOWN = {"context", "dockerfile", "args"} - - -def detect_injected_fields(path_: str, data: dict): - """Warn about YAML keys not captured by the Pydantic model — these would - be silently dropped and could interfere with network injection.""" - if not isinstance(data, dict): - return - extra_toplevel = set(data.keys()) - _TOP_LEVEL_KNOWN - if extra_toplevel: - err(f"WARN: {path_} has unexpected top-level keys (ignored by model): " - f"{', '.join(sorted(extra_toplevel))}. " - f"These may interfere with injected networks/config.") - services = data.get("services") - if not isinstance(services, dict): - return - for svc_name, svc_data in services.items(): - if not isinstance(svc_data, dict): - continue - extra_svc = set(svc_data.keys()) - _SERVICE_KNOWN - if extra_svc: - err(f"WARN: {path_} service '{svc_name}' has unexpected keys " - f"(ignored by model): {', '.join(sorted(extra_svc))}") - build = svc_data.get("build") - if isinstance(build, dict): - extra_build = set(build.keys()) - _BUILD_KNOWN - if extra_build: - err(f"WARN: {path_} service '{svc_name}' build block has " - f"unexpected keys: {', '.join(sorted(extra_build))}") - - -for prio, name in users: - workdir = f"{root}/users/{prio}-{name}/" - path = f"{workdir}compose.yml" - with open(path) as csf: - data = yaml.safe_load(csf) - detect_injected_fields(path, data) - try: - spec = ComposeSpec(**data) - except ValidationError as e: - err(f"Cannot validate compose spec at {path}") - print(e.errors()) - continue - validate_compose_spec(spec, workdir) - - -def validate_env_file(path_: str) -> bool: - try: - with open(path_, "r") as f: - for idx, raw in enumerate(f, start=1): - line = raw.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[len("export "):].strip() - if "=" not in line: - err(f"ERR: Invalid line {idx} in env file {path_}: no '=' found") - return False - key, _ = line.split("=", 1) - key = key.strip() - if not re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", key): - err(f"ERR: Invalid env var name '{key}' in {path_} at line {idx}") - return False - return True - except Exception as e: - err(f"ERR: Cannot read env_file {path_}: {e}") - return False - - -def validate_service_spec(serv: Service, workdir: str, name: str): - if serv.image is None and serv.build is None: - err(f"ERR: Service {name} doesn't have an image or build spec") - if isinstance(serv.build, str) and not os.path.exists(f"{workdir}/{serv.build}/Dockerfile"): - err(f"ERR: Service {name}: Dockerfile doesn't exist at {workdir}/{serv.build}/Dockerfile") - for volume in (serv.volumes or []): - spl = volume.split(":") - if (len(spl) != 2 and len(spl) != 3) or \ - not os.path.normpath(spl[0]).startswith(vol_parent) or \ - (len(spl) == 3 and spl[2] != "ro"): - err(f"ERR: Invalid volume spec {volume} in service {name}") - if serv.env_file and not os.path.exists(f"{workdir}/{serv.env_file}") \ - and not os.path.exists(f"{workdir}/{serv.env_file}.gpg"): - err(f"ERR: Service {name}: env_file {serv.env_file} (or .gpg) doesn't exist in {workdir}") - elif serv.env_file and os.path.exists(f"{workdir}/{serv.env_file}"): - validate_env_file(f"{workdir}/{serv.env_file}") - if isinstance(serv.build, BuildSpec) and not os.path.exists(f"{workdir}/{serv.build.dockerfile}"): - err(f"ERR: Service {name}: Dockerfile {serv.build.dockerfile} doesn't exist in {workdir}") - -if has_error: - print("Errors found during validation. Exiting.") - sys.exit(1) - -# ── Snapshot: copy userdir + inject networks into compose.yml ────────── -print(f"Snapshot target: {snapshot_root}") - -snapshot_dirs: list[tuple[str, str]] = [] # (dst_dir, name) for compose-up later - -for prio, name in users: - src_dir = f"{root}/users/{prio}-{name}" - dst_dir = f"{snapshot_root}/{prio}-{name}" - - if dry_run: - print(f" [dry] would snapshot {src_dir} -> {dst_dir}") - print(f" [dry] inject networks: .networks.{network}.external=true, " - f".networks.{name}.name={name}, services[].networks=[{network}, {name}]") - snapshot_dirs.append((dst_dir, name)) - continue - - print(f" Snapshotting {src_dir} -> {dst_dir}") - try: - if os.path.exists(dst_dir): - shutil.rmtree(dst_dir) - os.makedirs(dst_dir, exist_ok=True) - - for item in os.listdir(src_dir): - src_item = os.path.join(src_dir, item) - dst_item = os.path.join(dst_dir, item) - if item == "compose.yml": - # Rewrite with injected network configuration - with open(src_item) as f: - compose_data = yaml.safe_load(f) - compose_data.setdefault("networks", {}) - compose_data["networks"][network] = {"external": True} - compose_data["networks"][name] = {"name": name} - for svc_name, svc in (compose_data.get("services") or {}).items(): - svc["networks"] = [network, name] - print(f" Injected networks into service '{svc_name}': " - f"[{network}, {name}]") - with open(dst_item, "w") as f: - yaml.dump(compose_data, f, default_flow_style=False, sort_keys=False) - print(f" Wrote {dst_item} (networks injected)") - elif item.endswith(".gpg"): - # Decrypt .gpg file → output without .gpg suffix - plain_name = item[:-4] # strip ".gpg" - dst_plain = os.path.join(dst_dir, plain_name) - print(f" Decrypting {item} -> {plain_name}") - subprocess.run( - ["gpg", "--decrypt", "--batch", "--output", dst_plain, src_item], - check=True, - ) - elif os.path.isdir(src_item): - shutil.copytree(src_item, dst_item) - else: - shutil.copy2(src_item, dst_item) - - snapshot_dirs.append((dst_dir, name)) - print(f" Done snapshotting {name}") - except Exception as e: - err(f"ERR: Snapshot failed for {name}: {e}") - -# ── Compose up in each snapshot ──────────────────────────────────────── -if has_error: - print("Snapshot errors detected — skipping compose up.") - sys.exit(1) - -if dry_run: - for dst_dir, name in snapshot_dirs: - print(f" [dry] would run: docker compose -f {dst_dir}/compose.yml " - f"up --remove-orphans --build --detach") -else: - for dst_dir, name in snapshot_dirs: - print(f" Starting services for {name} in {dst_dir} …") - try: - subprocess.run( - ["docker", "compose", "-f", f"{dst_dir}/compose.yml", - "up", "--remove-orphans", "--build", "--detach"], - cwd=dst_dir, check=True, - ) - print(f" ✓ {name} started") - except subprocess.CalledProcessError as e: - err(f"ERR: docker compose up failed for {name}: {e}") - -if has_error: - print("Errors during compose up. Exiting.") - sys.exit(1) - - -