From 92b0eeec90c5fb3c70a0857bc3b3b901a7d10057 Mon Sep 17 00:00:00 2001 From: Amir Yahaya Date: Sun, 17 May 2026 08:57:58 +0200 Subject: [PATCH] feat: initial installer scripts for manage server and license server --- README.md | 52 ++++++++++++++ license-server/compose.yaml | 70 +++++++++++++++++++ license-server/env.template | 61 +++++++++++++++++ license-server/install.sh | 129 +++++++++++++++++++++++++++++++++++ license-server/uninstall.sh | 55 +++++++++++++++ license-server/upgrade.sh | 64 ++++++++++++++++++ manage-server/compose.yaml | 75 +++++++++++++++++++++ manage-server/env.template | 70 +++++++++++++++++++ manage-server/install.sh | 131 ++++++++++++++++++++++++++++++++++++ manage-server/uninstall.sh | 58 ++++++++++++++++ manage-server/upgrade.sh | 64 ++++++++++++++++++ 11 files changed, 829 insertions(+) create mode 100644 README.md create mode 100644 license-server/compose.yaml create mode 100644 license-server/env.template create mode 100755 license-server/install.sh create mode 100755 license-server/uninstall.sh create mode 100755 license-server/upgrade.sh create mode 100644 manage-server/compose.yaml create mode 100644 manage-server/env.template create mode 100755 manage-server/install.sh create mode 100755 manage-server/uninstall.sh create mode 100755 manage-server/upgrade.sh diff --git a/README.md b/README.md new file mode 100644 index 0000000..a6015a6 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +# Triton Installers + +Production installers for Triton server components. Container-based (Docker or Podman), idempotent — safe to re-run. + +## Manage Server + +```bash +curl -fsSL https://raw.githubusercontent.com/primatekuntech/triton-install/main/manage-server/install.sh | sudo bash -s -- \ + --license-server-pubkey \ + --license-server-url https://license.yourvendor.com \ + --gateway-hostname manage.customer.com +``` + +| Flag | Description | +|------|-------------| +| `--license-server-pubkey` | Ed25519 public key (64 hex chars). **Required.** | +| `--license-server-url` | URL of your License Server. | +| `--license-token` | Pre-fill activation token (else use setup wizard). | +| `--gateway-hostname` | Agent mTLS hostname (defaults to current FQDN). | +| `--manage-host-ip` | Host LAN IP for "+ This machine". | +| `--image` | Pin a specific image tag. | +| `--no-tls` | Skip TLS check (dev only). | + +## License Server + +```bash +curl -fsSL https://raw.githubusercontent.com/primatekuntech/triton-install/main/license-server/install.sh | sudo bash -s -- \ + --admin-email admin@yourcompany.com +``` + +| Flag | Description | +|------|-------------| +| `--admin-email` | Initial superadmin email. | +| `--public-url` | Public URL of the license server. | +| `--image` | Pin a specific image tag. | +| `--no-tls` | Skip TLS check (dev only). | + +## Other commands + +```bash +# Upgrade +sudo bash manage-server/upgrade.sh + +# Uninstall +sudo bash manage-server/uninstall.sh +``` + +## Requirements + +- Linux (amd64 or arm64) +- Docker or Podman +- Port 443 open (HTTPS) diff --git a/license-server/compose.yaml b/license-server/compose.yaml new file mode 100644 index 0000000..11fca2d --- /dev/null +++ b/license-server/compose.yaml @@ -0,0 +1,70 @@ +# Triton License Server — standalone compose file. +# +# Self-contained: bundles its own PostgreSQL. Designed to run on a host +# that ONLY hosts the licence server. For combined dev installs, see +# the root /compose.yaml. +# +# Reads .env from the same directory (this file's parent). The deploy +# install.sh writes that .env from env.template. + +services: + + postgres: + image: docker.io/library/postgres:18-alpine + container_name: triton-license-db + hostname: triton-license-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-triton} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-triton_license} + volumes: + - triton-license-db-data:/var/lib/postgresql + ports: + # Bind to localhost only — never expose Postgres to the public internet. + - "127.0.0.1:${POSTGRES_PORT:-5436}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-triton} -d ${POSTGRES_DB:-triton_license}"] + interval: 5s + timeout: 3s + retries: 20 + + license-server: + image: ${TRITON_LICENSE_IMAGE:-ghcr.io/amiryahaya/triton-licenseserver:latest} + container_name: triton-licenseserver + hostname: triton-licenseserver + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # Required + TRITON_LICENSE_SERVER_DB_URL: postgres://${POSTGRES_USER:-triton}:${POSTGRES_PASSWORD}@triton-license-db:5432/${POSTGRES_DB:-triton_license}?sslmode=disable + TRITON_LICENSE_SERVER_SIGNING_KEY: ${TRITON_LICENSE_SERVER_SIGNING_KEY} + TRITON_LICENSE_SERVER_ADMIN_EMAIL: ${TRITON_LICENSE_SERVER_ADMIN_EMAIL} + TRITON_LICENSE_SERVER_ADMIN_PASSWORD: ${TRITON_LICENSE_SERVER_ADMIN_PASSWORD} + # Optional — TLS termination at this container; omit if you proxy. + TRITON_LICENSE_SERVER_TLS_CERT: ${TRITON_LICENSE_SERVER_TLS_CERT:-} + TRITON_LICENSE_SERVER_TLS_KEY: ${TRITON_LICENSE_SERVER_TLS_KEY:-} + TRITON_LICENSE_SERVER_ALLOW_INSECURE: ${TRITON_LICENSE_SERVER_ALLOW_INSECURE:-} + TRITON_LICENSE_SERVER_LISTEN: ${TRITON_LICENSE_SERVER_LISTEN:-:8081} + TRITON_LICENSE_SERVER_STALE_THRESHOLD: ${TRITON_LICENSE_SERVER_STALE_THRESHOLD:-336h} + # Optional — public URL pushed to clients (used in invite emails). + TRITON_LICENSE_SERVER_PUBLIC_URL: ${TRITON_LICENSE_SERVER_PUBLIC_URL:-} + # Optional — Resend.com API key for sending invite emails. + RESEND_API_KEY: ${RESEND_API_KEY:-} + RESEND_FROM_EMAIL: ${RESEND_FROM_EMAIL:-} + # Worker binaries directory inside the container. + # The host directory (TRITON_LICENSE_SERVER_HOST_BIN_DIR) is mounted here. + TRITON_LICENSE_SERVER_BIN_DIR: /data/binaries + volumes: + # Mount TLS cert + key into the container if you set the env vars above. + - ${TLS_CERT_HOST_DIR:-/etc/triton/tls}:/etc/triton/tls:ro + # Worker binaries — persistent across container rebuilds. + - ${TRITON_LICENSE_SERVER_HOST_BIN_DIR:-/opt/triton/binaries}:/data/binaries + ports: + - "${TRITON_LICENSE_SERVER_HOST_PORT:-8081}:8081" + +volumes: + triton-license-db-data: + name: triton-license-db-data diff --git a/license-server/env.template b/license-server/env.template new file mode 100644 index 0000000..63c1bae --- /dev/null +++ b/license-server/env.template @@ -0,0 +1,61 @@ +# Triton License Server environment template. +# Copy to .env in this directory; install.sh does that automatically. +# +# Required values are flagged. Generated values get auto-filled by install.sh +# when run without flags. + +# ─── PostgreSQL (auto-generated) ───────────────────────────────────────── +POSTGRES_USER=triton +POSTGRES_PASSWORD=__GENERATED_BY_INSTALL_SH__ # openssl rand -hex 24 +POSTGRES_DB=triton_license +POSTGRES_PORT=5436 # localhost-bound + +# ─── License Server core (REQUIRED) ────────────────────────────────────── +# Ed25519 keypair as 128 hex chars (seed||pub). Generated once at install +# time. Back this up — losing it forces every customer to re-activate. +TRITON_LICENSE_SERVER_SIGNING_KEY=__GENERATED_BY_INSTALL_SH__ + +# Initial superadmin seeded on first boot. After login, rotate this. +TRITON_LICENSE_SERVER_ADMIN_EMAIL=admin@example.com +TRITON_LICENSE_SERVER_ADMIN_PASSWORD=__GENERATED_BY_INSTALL_SH__ + +# ─── Listener ──────────────────────────────────────────────────────────── +TRITON_LICENSE_SERVER_LISTEN=:8081 +TRITON_LICENSE_SERVER_HOST_PORT=8081 # host port to publish + +# ─── TLS (recommended for production) ──────────────────────────────────── +# Two paths: +# A) Reverse proxy terminates TLS — leave these blank, set ALLOW_INSECURE=1. +# B) Container terminates TLS — set CERT + KEY (paths inside container) +# and mount your /etc/triton/tls directory via TLS_CERT_HOST_DIR. +TRITON_LICENSE_SERVER_TLS_CERT= +TRITON_LICENSE_SERVER_TLS_KEY= +TRITON_LICENSE_SERVER_ALLOW_INSECURE= +TLS_CERT_HOST_DIR=/etc/triton/tls + +# ─── Operations ────────────────────────────────────────────────────────── +# Hours after which a license is considered stale (offline grace window). +# 336h = 14 days. Increase for longer offline tolerance. +TRITON_LICENSE_SERVER_STALE_THRESHOLD=336h + +# Public URL of this server. Used in invite emails and pushed to clients +# during validation. Leave blank if no email/external clients yet. +TRITON_LICENSE_SERVER_PUBLIC_URL= + +# ─── Email (optional) ──────────────────────────────────────────────────── +# Resend.com keys for sending platform-admin invites + temp passwords. +# Leave blank to disable email — invites are still issued, the password +# just isn't auto-mailed. +RESEND_API_KEY= +RESEND_FROM_EMAIL= + +# ─── Worker binaries ───────────────────────────────────────────────────── +# Host directory bind-mounted into the container at /data/binaries. +# Binaries (triton-agent, triton-sshagent, triton-portscan) are stored as +# files — not in the DB — so this directory must survive container rebuilds. +# Created automatically by install.sh if it does not exist. +TRITON_LICENSE_SERVER_HOST_BIN_DIR=/opt/triton/binaries + +# ─── Image ─────────────────────────────────────────────────────────────── +# Override to pin a specific build. Default tracks :latest from ghcr.io. +TRITON_LICENSE_IMAGE=ghcr.io/amiryahaya/triton-licenseserver:latest diff --git a/license-server/install.sh b/license-server/install.sh new file mode 100755 index 0000000..47c0843 --- /dev/null +++ b/license-server/install.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# install.sh — Triton License Server installer. +# +# Idempotent. Generates secrets on first run, reuses .env on subsequent runs. +# Container-based via Podman or Docker (auto-detected). +# +# Usage: +# sudo bash install.sh # interactive defaults +# sudo bash install.sh --admin-email a@b.com # set initial admin email +# sudo bash install.sh --image TAG # pin a specific image +# sudo bash install.sh --public-url URL # set TRITON_LICENSE_SERVER_PUBLIC_URL +# sudo bash install.sh --no-tls # skip TLS check (dev) +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd "$SCRIPT_DIR" + +info() { printf '[license-server] %s\n' "$*"; } +die() { printf '[license-server] error: %s\n' "$*" >&2; exit 1; } + +# ── arg parsing ────────────────────────────────────────────────────────── +ADMIN_EMAIL="" +PUBLIC_URL="" +IMAGE="" +NO_TLS=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --admin-email) ADMIN_EMAIL="$2"; shift 2 ;; + --public-url) PUBLIC_URL="$2"; shift 2 ;; + --image) IMAGE="$2"; shift 2 ;; + --no-tls) NO_TLS=1; shift ;; + -h|--help) + grep '^#' "$0" | sed 's/^# //;s/^#//'; exit 0 ;; + *) + die "unknown flag: $1 (try --help)" ;; + esac +done + +[[ $EUID -eq 0 ]] || die "must run as root" + +# ── runtime detection (podman > docker) ────────────────────────────────── +if command -v podman-compose >/dev/null 2>&1; then + COMPOSE=(podman-compose) + RUNTIME=podman +elif podman compose version >/dev/null 2>&1; then + COMPOSE=(podman compose) + RUNTIME=podman +elif docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) + RUNTIME=docker +else + die "no compose runtime found. Install podman-compose or docker compose." +fi +info "using runtime: $RUNTIME ($(command -v $RUNTIME))" + +# ── .env bootstrap ─────────────────────────────────────────────────────── +ENV_FILE="$SCRIPT_DIR/.env" +if [[ ! -f "$ENV_FILE" ]]; then + info "writing .env from env.template (first install)" + cp env.template "$ENV_FILE" + chmod 600 "$ENV_FILE" + + # Auto-generate secrets. + PG_PASS=$(openssl rand -hex 24) + + # Ed25519 keypair: seed (32B) || pub (32B) = 64B = 128 hex chars. + # openssl emits PKCS#8 PEM; pull the seed out, re-derive the pub half via Go-style + # ed25519.NewKeyFromSeed at server startup. We just need the 128-hex format here. + TMP_PEM=$(mktemp) + trap 'rm -f "$TMP_PEM"' EXIT + openssl genpkey -algorithm ed25519 -out "$TMP_PEM" 2>/dev/null + SEED_HEX=$(openssl pkey -in "$TMP_PEM" -text -noout 2>/dev/null \ + | awk '/priv:/{found=1; next} found && /pub:/{exit} found' \ + | tr -d ' :\n' | head -c 64) + PUB_HEX=$(openssl pkey -in "$TMP_PEM" -pubout -outform DER 2>/dev/null \ + | tail -c 32 | xxd -p -c 64 | tr -d '\n') + SIGNING_KEY="${SEED_HEX}${PUB_HEX}" + + [[ ${#SIGNING_KEY} -eq 128 ]] || die "Ed25519 keygen produced bad length (${#SIGNING_KEY})" + + ADMIN_PASS=$(openssl rand -base64 24 | tr -d '\n=+/' | head -c 28) + + sed -i \ + -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$PG_PASS|" \ + -e "s|^TRITON_LICENSE_SERVER_SIGNING_KEY=.*|TRITON_LICENSE_SERVER_SIGNING_KEY=$SIGNING_KEY|" \ + -e "s|^TRITON_LICENSE_SERVER_ADMIN_PASSWORD=.*|TRITON_LICENSE_SERVER_ADMIN_PASSWORD=$ADMIN_PASS|" \ + "$ENV_FILE" + + [[ -n "$ADMIN_EMAIL" ]] && sed -i "s|^TRITON_LICENSE_SERVER_ADMIN_EMAIL=.*|TRITON_LICENSE_SERVER_ADMIN_EMAIL=$ADMIN_EMAIL|" "$ENV_FILE" + [[ -n "$PUBLIC_URL" ]] && sed -i "s|^TRITON_LICENSE_SERVER_PUBLIC_URL=.*|TRITON_LICENSE_SERVER_PUBLIC_URL=$PUBLIC_URL|" "$ENV_FILE" + [[ -n "$IMAGE" ]] && sed -i "s|^TRITON_LICENSE_IMAGE=.*|TRITON_LICENSE_IMAGE=$IMAGE|" "$ENV_FILE" + [[ $NO_TLS -eq 1 ]] && sed -i "s|^TRITON_LICENSE_SERVER_ALLOW_INSECURE=.*|TRITON_LICENSE_SERVER_ALLOW_INSECURE=1|" "$ENV_FILE" + + info ".env created at $ENV_FILE" + info "INITIAL ADMIN PASSWORD: $ADMIN_PASS" + info " rotate after first login (Account → Change password)" +else + info "reusing existing .env at $ENV_FILE" +fi + +# ── binary directory ───────────────────────────────────────────────────── +BIN_DIR_HOST=$(grep -E '^TRITON_LICENSE_SERVER_HOST_BIN_DIR=' "$ENV_FILE" | cut -d= -f2) +BIN_DIR_HOST="${BIN_DIR_HOST:-/opt/triton/binaries}" +if [[ ! -d "$BIN_DIR_HOST" ]]; then + info "creating binary directory: $BIN_DIR_HOST" + mkdir -p "$BIN_DIR_HOST" + chmod 755 "$BIN_DIR_HOST" +fi +info "binary directory: $BIN_DIR_HOST" + +# ── start ──────────────────────────────────────────────────────────────── +info "starting containers..." +"${COMPOSE[@]}" --env-file "$ENV_FILE" up -d + +info "waiting for license server to become healthy..." +HOST_PORT=$(grep -E '^TRITON_LICENSE_SERVER_HOST_PORT=' "$ENV_FILE" | cut -d= -f2) +HOST_PORT=${HOST_PORT:-8081} + +for i in $(seq 1 30); do + if curl -sf "http://localhost:${HOST_PORT}/api/v1/health" >/dev/null 2>&1; then + info "license server is up: http://localhost:${HOST_PORT}" + break + fi + sleep 2 +done + +info "done. Admin UI: http://localhost:${HOST_PORT}/ui/" +info " login as: $(grep ADMIN_EMAIL "$ENV_FILE" | cut -d= -f2)" +info " see manage-server.md to wire a manage server to this licence server." diff --git a/license-server/uninstall.sh b/license-server/uninstall.sh new file mode 100755 index 0000000..747cb6f --- /dev/null +++ b/license-server/uninstall.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# uninstall.sh — stop and remove License Server containers. +# +# By default, KEEPS the PostgreSQL volume (license data). Pass --purge-data +# to delete the volume as well — irreversible. +# +# Usage: +# sudo bash uninstall.sh # stop + remove containers, keep DB volume +# sudo bash uninstall.sh --purge-data # also delete DB volume (DESTRUCTIVE) +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd "$SCRIPT_DIR" + +info() { printf '[license-server] %s\n' "$*"; } +die() { printf '[license-server] error: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "must run as root" + +PURGE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --purge-data) PURGE=1; shift ;; + -h|--help) grep '^#' "$0" | sed 's/^# //;s/^#//'; exit 0 ;; + *) die "unknown flag: $1" ;; + esac +done + +if command -v podman-compose >/dev/null 2>&1; then COMPOSE=(podman-compose) +elif podman compose version >/dev/null 2>&1; then COMPOSE=(podman compose) +elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) +else die "no compose runtime found"; fi + +if [[ -f .env ]]; then + info "stopping containers..." + "${COMPOSE[@]}" --env-file .env down +else + info ".env not found, attempting raw container cleanup..." + podman rm -f triton-licenseserver triton-license-db 2>/dev/null || true +fi + +if [[ $PURGE -eq 1 ]]; then + info "DESTRUCTIVE: removing license DB volume..." + read -r -p " Are you sure? Type 'yes' to confirm: " CONFIRM + [[ "$CONFIRM" == "yes" ]] || die "aborted" + podman volume rm -f triton-license-db-data 2>/dev/null \ + || docker volume rm -f triton-license-db-data 2>/dev/null \ + || true + info " DB volume removed" + info " .env and signing key still on disk at $SCRIPT_DIR/.env — delete manually if desired" +else + info "DB volume retained (run with --purge-data to delete)" +fi + +info "uninstall complete" diff --git a/license-server/upgrade.sh b/license-server/upgrade.sh new file mode 100755 index 0000000..fbe2578 --- /dev/null +++ b/license-server/upgrade.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# upgrade.sh — pull the latest license-server image and restart. +# +# DB schema migrations run on startup automatically. No app data is touched. +# +# Usage: +# sudo bash upgrade.sh # pull :latest, recreate containers +# sudo bash upgrade.sh --image TAG # pin a specific image +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd "$SCRIPT_DIR" + +info() { printf '[license-server] %s\n' "$*"; } +die() { printf '[license-server] error: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "must run as root" +[[ -f .env ]] || die ".env not found — run install.sh first" + +IMAGE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --image) IMAGE="$2"; shift 2 ;; + -h|--help) grep '^#' "$0" | sed 's/^# //;s/^#//'; exit 0 ;; + *) die "unknown flag: $1" ;; + esac +done + +if [[ -n "$IMAGE" ]]; then + sed -i "s|^TRITON_LICENSE_IMAGE=.*|TRITON_LICENSE_IMAGE=$IMAGE|" .env + info "pinned image to $IMAGE" +fi + +if command -v podman-compose >/dev/null 2>&1; then COMPOSE=(podman-compose) +elif podman compose version >/dev/null 2>&1; then COMPOSE=(podman compose) +elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) +else die "no compose runtime found"; fi + +info "pre-upgrade DB backup..." +DUMP_DIR=/var/backups/triton +mkdir -p "$DUMP_DIR" +DUMP_FILE="$DUMP_DIR/license-pre-upgrade-$(date +%F-%H%M%S).sql.gz" +podman exec triton-license-db pg_dump -U triton triton_license 2>/dev/null \ + | gzip > "$DUMP_FILE" || die "pg_dump failed — aborting upgrade" +info " saved: $DUMP_FILE" + +info "pulling latest image..." +"${COMPOSE[@]}" --env-file .env pull license-server + +info "recreating license-server container..." +"${COMPOSE[@]}" --env-file .env up -d --no-deps license-server + +HOST_PORT=$(grep -E '^TRITON_LICENSE_SERVER_HOST_PORT=' .env | cut -d= -f2) +HOST_PORT=${HOST_PORT:-8081} + +info "waiting for new container to become healthy..." +for i in $(seq 1 30); do + if curl -sf "http://localhost:${HOST_PORT}/api/v1/license/health" >/dev/null 2>&1; then + info "upgrade complete" + exit 0 + fi + sleep 2 +done +die "new container did not become healthy in 60s — check logs and consider rollback" diff --git a/manage-server/compose.yaml b/manage-server/compose.yaml new file mode 100644 index 0000000..4b67c1d --- /dev/null +++ b/manage-server/compose.yaml @@ -0,0 +1,75 @@ +# Triton Manage Server — standalone compose file. +# +# Self-contained: bundles its own PostgreSQL for both the manage schema +# and the AES-256-GCM credential vault. Designed to run on a host that +# only hosts the manage server. +# +# Reads .env from the same directory (this file's parent). + +services: + + postgres: + image: docker.io/library/postgres:18-alpine + container_name: triton-manage-db + hostname: triton-manage-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-triton} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-triton_manage} + volumes: + - triton-manage-db-data:/var/lib/postgresql + ports: + - "127.0.0.1:${POSTGRES_PORT:-5435}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-triton} -d ${POSTGRES_DB:-triton_manage}"] + interval: 5s + timeout: 3s + retries: 20 + + manage-server: + image: ${TRITON_MANAGE_IMAGE:-ghcr.io/amiryahaya/triton-manageserver:latest} + container_name: triton-manageserver + hostname: triton-manageserver + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # Required + TRITON_MANAGE_DB_URL: postgres://${POSTGRES_USER:-triton}:${POSTGRES_PASSWORD}@triton-manage-db:5432/${POSTGRES_DB:-triton_manage}?sslmode=disable + TRITON_MANAGE_JWT_SIGNING_KEY: ${TRITON_MANAGE_JWT_SIGNING_KEY} + TRITON_MANAGE_LICENSE_SERVER_PUBKEY: ${TRITON_MANAGE_LICENSE_SERVER_PUBKEY} + # Listener + TRITON_MANAGE_LISTEN: ${TRITON_MANAGE_LISTEN:-:8082} + TRITON_MANAGE_GATEWAY_LISTEN: ${TRITON_MANAGE_GATEWAY_LISTEN:-:8443} + TRITON_MANAGE_GATEWAY_HOSTNAME: ${TRITON_MANAGE_GATEWAY_HOSTNAME:-localhost} + TRITON_MANAGE_GATEWAY_URL: ${TRITON_MANAGE_GATEWAY_URL:-} + TRITON_MANAGE_HOST_IP: ${TRITON_MANAGE_HOST_IP:-} + TRITON_MANAGE_HOST_HOSTNAME: ${TRITON_MANAGE_HOST_HOSTNAME:-} + # License server connection (for binary sync + heartbeat) + TRITON_LICENSE_SERVER_URL: ${TRITON_LICENSE_SERVER_URL:-} + TRITON_LICENSE_TOKEN: ${TRITON_LICENSE_TOKEN:-} + TRITON_LICENSE_KEY: ${TRITON_LICENSE_KEY:-} + # Worker plumbing + TRITON_MANAGE_WORKER_KEY: ${TRITON_MANAGE_WORKER_KEY} + TRITON_MANAGE_BIN_DIR: /bins + TRITON_MANAGE_PARALLELISM: ${TRITON_MANAGE_PARALLELISM:-10} + # Credential vault (PostgreSQL AES-256-GCM) + TRITON_VAULT_KEY: ${TRITON_VAULT_KEY:-} + # TLS (optional — usually a reverse proxy terminates TLS instead) + TRITON_MANAGE_TLS_CERT: ${TRITON_MANAGE_TLS_CERT:-} + TRITON_MANAGE_TLS_KEY: ${TRITON_MANAGE_TLS_KEY:-} + TRITON_MANAGE_SESSION_TTL: ${TRITON_MANAGE_SESSION_TTL:-24h} + volumes: + - triton-manage-bins:/bins + - ${TLS_CERT_HOST_DIR:-/etc/triton/tls}:/etc/triton/tls:ro + ports: + - "${TRITON_MANAGE_HOST_PORT:-8082}:8082" + - "${TRITON_MANAGE_GATEWAY_HOST_PORT:-8443}:8443" + +volumes: + triton-manage-db-data: + name: triton-manage-db-data + triton-manage-bins: + name: triton-manage-bins diff --git a/manage-server/env.template b/manage-server/env.template new file mode 100644 index 0000000..65d16ae --- /dev/null +++ b/manage-server/env.template @@ -0,0 +1,70 @@ +# Triton Manage Server environment template. +# Copy to .env in this directory; install.sh does that automatically. +# +# Required values are flagged. Generated values get auto-filled by install.sh. + +# ─── PostgreSQL (auto-generated) ───────────────────────────────────────── +POSTGRES_USER=triton +POSTGRES_PASSWORD=__GENERATED_BY_INSTALL_SH__ +POSTGRES_DB=triton_manage +POSTGRES_PORT=5435 + +# ─── Manage Server core (REQUIRED) ─────────────────────────────────────── +# 32-byte HS256 secret as 64 hex chars. Generated once at install. +# Rotating this invalidates every active session — users re-login. +TRITON_MANAGE_JWT_SIGNING_KEY=__GENERATED_BY_INSTALL_SH__ + +# Public half of the License Server's Ed25519 keypair as 64 hex chars. +# Get this from the License Server operator: it's the last 64 hex +# characters of TRITON_LICENSE_SERVER_SIGNING_KEY. +TRITON_MANAGE_LICENSE_SERVER_PUBKEY=__SET_BY_INSTALL_FLAG__ + +# ─── Listener ──────────────────────────────────────────────────────────── +TRITON_MANAGE_LISTEN=:8082 +TRITON_MANAGE_HOST_PORT=8082 + +# Agent gateway (mTLS). Hostname must be reachable from agents. +TRITON_MANAGE_GATEWAY_LISTEN=:8443 +TRITON_MANAGE_GATEWAY_HOST_PORT=8443 +TRITON_MANAGE_GATEWAY_HOSTNAME=manage.example.com +# Full URL pushed to enrolled agents. Defaults to https://${HOSTNAME}:${PORT}. +TRITON_MANAGE_GATEWAY_URL= + +# Host LAN IP/hostname for "+ This machine" auto-registration. Required in +# containers because the auto-detect picks up the container's own IP. +TRITON_MANAGE_HOST_IP= +TRITON_MANAGE_HOST_HOSTNAME= + +# ─── License Server connection (REQUIRED to activate) ──────────────────── +# URL of YOUR vendor's License Server. +TRITON_LICENSE_SERVER_URL=https://license.vendor.example.com +# License token issued by the vendor (paste into setup wizard, or here). +TRITON_LICENSE_TOKEN= +# Optional fallback key embedded in binary at build time. Usually empty. +TRITON_LICENSE_KEY= + +# ─── Workers ───────────────────────────────────────────────────────────── +# Shared secret presented by sshagent / portscan workers when claiming jobs. +TRITON_MANAGE_WORKER_KEY=__GENERATED_BY_INSTALL_SH__ + +# Concurrent scan jobs (1–50). Higher = more CPU + RAM. +TRITON_MANAGE_PARALLELISM=10 + +# ─── Credential vault ──────────────────────────────────────────────────── +# PostgreSQL AES-256-GCM vault. Back this up — losing the key makes +# all stored host credentials unreadable. +TRITON_VAULT_KEY=__GENERATED_BY_INSTALL_SH__ + +# ─── TLS (recommended for production) ──────────────────────────────────── +# Two paths: +# A) Reverse proxy terminates TLS — leave these blank. +# B) Container terminates TLS — set CERT + KEY paths inside the container. +TRITON_MANAGE_TLS_CERT= +TRITON_MANAGE_TLS_KEY= +TLS_CERT_HOST_DIR=/etc/triton/tls + +# ─── Sessions ──────────────────────────────────────────────────────────── +TRITON_MANAGE_SESSION_TTL=24h + +# ─── Image ─────────────────────────────────────────────────────────────── +TRITON_MANAGE_IMAGE=ghcr.io/amiryahaya/triton-manageserver:latest diff --git a/manage-server/install.sh b/manage-server/install.sh new file mode 100755 index 0000000..5587544 --- /dev/null +++ b/manage-server/install.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# install.sh — Triton Manage Server installer. +# +# Idempotent. Generates secrets on first run, reuses .env afterwards. +# Container-based via Podman or Docker (auto-detected). +# +# Usage: +# sudo bash install.sh \ +# --license-server-pubkey HEX \ +# --license-server-url https://license.yourvendor.com \ +# --gateway-hostname manage.customer.com +# +# Flags: +# --license-server-pubkey HEX Ed25519 public half (64 hex chars). REQUIRED. +# Last 64 chars of vendor's TRITON_LICENSE_SERVER_SIGNING_KEY. +# --license-server-url URL URL of vendor's License Server. +# --license-token TOKEN Pre-fill activation token (else use the setup wizard). +# --gateway-hostname HOST Agent mTLS hostname (defaults to current FQDN). +# --manage-host-ip IP Host LAN IP — used for "+ This machine". +# --image TAG Pin a specific manage-server image. +# --no-tls Skip the TLS-required sanity check (dev). +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd "$SCRIPT_DIR" + +info() { printf '[manage-server] %s\n' "$*"; } +die() { printf '[manage-server] error: %s\n' "$*" >&2; exit 1; } + +# ── arg parsing ────────────────────────────────────────────────────────── +LIC_PUBKEY="" +LIC_URL="" +LIC_TOKEN="" +GATEWAY_HOST="" +HOST_IP="" +IMAGE="" +NO_TLS=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --license-server-pubkey) LIC_PUBKEY="$2"; shift 2 ;; + --license-server-url) LIC_URL="$2"; shift 2 ;; + --license-token) LIC_TOKEN="$2"; shift 2 ;; + --gateway-hostname) GATEWAY_HOST="$2"; shift 2 ;; + --manage-host-ip) HOST_IP="$2"; shift 2 ;; + --image) IMAGE="$2"; shift 2 ;; + --no-tls) NO_TLS=1; shift ;; + -h|--help) grep '^#' "$0" | sed 's/^# //;s/^#//'; exit 0 ;; + *) die "unknown flag: $1 (try --help)" ;; + esac +done + +[[ $EUID -eq 0 ]] || die "must run as root" + +# ── runtime detection ──────────────────────────────────────────────────── +if command -v podman-compose >/dev/null 2>&1; then + COMPOSE=(podman-compose) + RUNTIME=podman +elif podman compose version >/dev/null 2>&1; then + COMPOSE=(podman compose) + RUNTIME=podman +elif docker compose version >/dev/null 2>&1; then + COMPOSE=(docker compose) + RUNTIME=docker +else + die "no compose runtime found. Install podman-compose or docker compose." +fi +info "using runtime: $RUNTIME" + +# ── .env bootstrap ─────────────────────────────────────────────────────── +ENV_FILE="$SCRIPT_DIR/.env" +if [[ ! -f "$ENV_FILE" ]]; then + [[ -n "$LIC_PUBKEY" ]] || die "--license-server-pubkey required on first install" + [[ ${#LIC_PUBKEY} -eq 64 ]] || die "license-server-pubkey must be 64 hex chars" + + info "writing .env from env.template" + cp env.template "$ENV_FILE" + chmod 600 "$ENV_FILE" + + PG_PASS=$(openssl rand -hex 24) + JWT_KEY=$(openssl rand -hex 32) + WORKER_KEY=$(openssl rand -hex 16) + VAULT_KEY=$(openssl rand -hex 32) + + sed -i \ + -e "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$PG_PASS|" \ + -e "s|^TRITON_MANAGE_JWT_SIGNING_KEY=.*|TRITON_MANAGE_JWT_SIGNING_KEY=$JWT_KEY|" \ + -e "s|^TRITON_MANAGE_WORKER_KEY=.*|TRITON_MANAGE_WORKER_KEY=$WORKER_KEY|" \ + -e "s|^TRITON_MANAGE_LICENSE_SERVER_PUBKEY=.*|TRITON_MANAGE_LICENSE_SERVER_PUBKEY=$LIC_PUBKEY|" \ + -e "s|^TRITON_VAULT_KEY=.*|TRITON_VAULT_KEY=$VAULT_KEY|" \ + "$ENV_FILE" + info "vault key generated (PostgreSQL AES-256-GCM)" + + [[ -n "$LIC_URL" ]] && sed -i "s|^TRITON_LICENSE_SERVER_URL=.*|TRITON_LICENSE_SERVER_URL=$LIC_URL|" "$ENV_FILE" + [[ -n "$LIC_TOKEN" ]] && sed -i "s|^TRITON_LICENSE_TOKEN=.*|TRITON_LICENSE_TOKEN=$LIC_TOKEN|" "$ENV_FILE" + [[ -n "$GATEWAY_HOST" ]] && sed -i "s|^TRITON_MANAGE_GATEWAY_HOSTNAME=.*|TRITON_MANAGE_GATEWAY_HOSTNAME=$GATEWAY_HOST|" "$ENV_FILE" + [[ -n "$HOST_IP" ]] && sed -i "s|^TRITON_MANAGE_HOST_IP=.*|TRITON_MANAGE_HOST_IP=$HOST_IP|" "$ENV_FILE" + [[ -n "$IMAGE" ]] && sed -i "s|^TRITON_MANAGE_IMAGE=.*|TRITON_MANAGE_IMAGE=$IMAGE|" "$ENV_FILE" + + info ".env created at $ENV_FILE" + info " back this up: it contains the JWT signing key, worker key, and vault key" +else + info "reusing existing .env at $ENV_FILE" +fi + +# ── start ──────────────────────────────────────────────────────────────── +info "starting containers..." +"${COMPOSE[@]}" --env-file "$ENV_FILE" up -d + +# ── wait for health ────────────────────────────────────────────────────── +HOST_PORT=$(grep -E '^TRITON_MANAGE_HOST_PORT=' "$ENV_FILE" | cut -d= -f2) +HOST_PORT=${HOST_PORT:-8082} + +info "waiting for manage server to become healthy on :${HOST_PORT}..." +for i in $(seq 1 30); do + CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${HOST_PORT}/" || echo "000") + # 302 (redirect to setup or login) means the server is up. + if [[ "$CODE" == "302" || "$CODE" == "200" ]]; then + info "manage server is up: http://localhost:${HOST_PORT}" + break + fi + sleep 2 +done + +info "" +info "Next steps:" +info " 1. Open http://localhost:${HOST_PORT} (or your public URL)" +info " 2. Complete the setup wizard: create the admin user, paste the licence token" +info " 3. Configure TLS via reverse proxy (see prerequisites.md)" +info "" +info " License Server URL: $(grep ^TRITON_LICENSE_SERVER_URL= $ENV_FILE | cut -d= -f2-)" +info " Gateway hostname: $(grep ^TRITON_MANAGE_GATEWAY_HOSTNAME= $ENV_FILE | cut -d= -f2)" diff --git a/manage-server/uninstall.sh b/manage-server/uninstall.sh new file mode 100755 index 0000000..52d87af --- /dev/null +++ b/manage-server/uninstall.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# uninstall.sh — stop and remove Manage Server containers. +# +# By default, KEEPS the PostgreSQL volume (scan history, hosts, users). +# Pass --purge-data to delete the volumes as well — irreversible. +# +# Usage: +# sudo bash uninstall.sh # stop + remove containers, keep DB +# sudo bash uninstall.sh --purge-data # also delete DB + binaries volume +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd "$SCRIPT_DIR" + +info() { printf '[manage-server] %s\n' "$*"; } +die() { printf '[manage-server] error: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "must run as root" + +PURGE=0 +while [[ $# -gt 0 ]]; do + case "$1" in + --purge-data) PURGE=1; shift ;; + -h|--help) grep '^#' "$0" | sed 's/^# //;s/^#//'; exit 0 ;; + *) die "unknown flag: $1" ;; + esac +done + +if command -v podman-compose >/dev/null 2>&1; then COMPOSE=(podman-compose) +elif podman compose version >/dev/null 2>&1; then COMPOSE=(podman compose) +elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) +else die "no compose runtime found"; fi + +if [[ -f .env ]]; then + info "stopping containers..." + "${COMPOSE[@]}" --env-file .env down +else + info ".env not found, attempting raw container cleanup..." + podman rm -f triton-manageserver triton-manage-db 2>/dev/null || true +fi + +if [[ $PURGE -eq 1 ]]; then + info "DESTRUCTIVE: removing manage server volumes..." + info " this deletes: scan history, hosts, users, worker binaries" + read -r -p " Are you sure? Type 'yes' to confirm: " CONFIRM + [[ "$CONFIRM" == "yes" ]] || die "aborted" + for vol in triton-manage-db-data triton-manage-bins; do + podman volume rm -f "$vol" 2>/dev/null \ + || docker volume rm -f "$vol" 2>/dev/null \ + || true + done + info " volumes removed" + info " .env still on disk at $SCRIPT_DIR/.env — delete manually if desired" +else + info "DB + bins volumes retained (run with --purge-data to delete)" +fi + +info "uninstall complete" diff --git a/manage-server/upgrade.sh b/manage-server/upgrade.sh new file mode 100755 index 0000000..ffc2668 --- /dev/null +++ b/manage-server/upgrade.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# upgrade.sh — pull the latest manage-server image and restart. +# +# Takes a pre-upgrade pg_dump. DB schema migrations run on startup. +# +# Usage: +# sudo bash upgrade.sh # latest from ghcr.io +# sudo bash upgrade.sh --image TAG # pin a specific image +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &>/dev/null && pwd)" +cd "$SCRIPT_DIR" + +info() { printf '[manage-server] %s\n' "$*"; } +die() { printf '[manage-server] error: %s\n' "$*" >&2; exit 1; } + +[[ $EUID -eq 0 ]] || die "must run as root" +[[ -f .env ]] || die ".env not found — run install.sh first" + +IMAGE="" +while [[ $# -gt 0 ]]; do + case "$1" in + --image) IMAGE="$2"; shift 2 ;; + -h|--help) grep '^#' "$0" | sed 's/^# //;s/^#//'; exit 0 ;; + *) die "unknown flag: $1" ;; + esac +done + +if [[ -n "$IMAGE" ]]; then + sed -i "s|^TRITON_MANAGE_IMAGE=.*|TRITON_MANAGE_IMAGE=$IMAGE|" .env + info "pinned image to $IMAGE" +fi + +if command -v podman-compose >/dev/null 2>&1; then COMPOSE=(podman-compose) +elif podman compose version >/dev/null 2>&1; then COMPOSE=(podman compose) +elif docker compose version >/dev/null 2>&1; then COMPOSE=(docker compose) +else die "no compose runtime found"; fi + +info "pre-upgrade DB backup..." +mkdir -p /var/backups/triton +DUMP_FILE="/var/backups/triton/manage-pre-upgrade-$(date +%F-%H%M%S).sql.gz" +podman exec triton-manage-db pg_dump -U triton triton_manage 2>/dev/null \ + | gzip > "$DUMP_FILE" || die "pg_dump failed — aborting upgrade" +info " saved: $DUMP_FILE" + +info "pulling latest image..." +"${COMPOSE[@]}" --env-file .env pull manage-server + +info "recreating manage-server container..." +"${COMPOSE[@]}" --env-file .env up -d --no-deps manage-server + +HOST_PORT=$(grep -E '^TRITON_MANAGE_HOST_PORT=' .env | cut -d= -f2) +HOST_PORT=${HOST_PORT:-8082} + +info "waiting for new container to become healthy..." +for i in $(seq 1 30); do + CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://localhost:${HOST_PORT}/" || echo "000") + if [[ "$CODE" == "302" || "$CODE" == "200" ]]; then + info "upgrade complete" + exit 0 + fi + sleep 2 +done +die "new container did not become healthy in 60s — check logs"