diff --git a/.env.example b/.env.example index 861957b..0c35771 100644 --- a/.env.example +++ b/.env.example @@ -72,3 +72,16 @@ HOST_GID=1002 # GHCR namespace for prod images (ghcr.io//clusev): GHCR_OWNER=OWNER IMAGE_TAG=latest + +# ── Reverse proxy / TLS (prod — written by install.sh) ─────────────── +# Single knob: APP_DOMAIN empty -> Caddy serves plain HTTP on APP_PORT (bare-IP). +# APP_DOMAIN set -> Caddy gets a Let's Encrypt cert + forces HTTPS. +APP_DOMAIN= +APP_SCHEME=http +ACME_EMAIL= +# Derived from APP_DOMAIN by install.sh (":${APP_PORT}" vs "https://${APP_DOMAIN}"): +SITE_ADDRESS=:80 +# Prod image: locally-built tag, or a pinned GHCR digest (ghcr.io//clusev@sha256:..). +CLUSEV_IMAGE=clusev-app:prod +# HMAC key for the (v1.x) in-dashboard self-updater intent file — separate from APP_KEY. +UPDATE_HMAC_KEY= diff --git a/README.md b/README.md index 5ad1377..a1a1754 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,83 @@ -

Laravel Logo

+# Clusev -

-Build Status -Total Downloads -Latest Stable Version -License -

+Self-hosted control panel to administer a **fleet of Linux servers from one dashboard**, +agentless over SSH. Clusev is the control-plane (UI + orchestration); it talks to real servers +with **phpseclib** (exec + SFTP) — it never reimplements daemons. Security-first: 2FA, encrypted +SSH-credential vault, and a full audit log. Multi-server is free and never paywalled. -## About Laravel +> Status: **v1** — dashboard/live metrics, systemd services, SFTP file manager, server details, +> auth + 2FA. UI copy is German; meta-docs are English. -Laravel is a web application framework with expressive, elegant syntax. We believe development must be an enjoyable and creative experience to be truly fulfilling. Laravel takes the pain out of development by easing common tasks used in many web projects, such as: +**Stack:** Laravel 13 · Livewire 3 · Tailwind v4 · Reverb (realtime) · Redis · MariaDB · phpseclib3. +Everything runs in Docker. See `CLAUDE.md` for architecture and `rules.md` for the hard conventions. -- [Simple, fast routing engine](https://laravel.com/docs/routing). -- [Powerful dependency injection container](https://laravel.com/docs/container). -- Multiple back-ends for [session](https://laravel.com/docs/session) and [cache](https://laravel.com/docs/cache) storage. -- Expressive, intuitive [database ORM](https://laravel.com/docs/eloquent). -- Database agnostic [schema migrations](https://laravel.com/docs/migrations). -- [Robust background job processing](https://laravel.com/docs/queues). -- [Real-time event broadcasting](https://laravel.com/docs/broadcasting). +--- -Laravel is accessible, powerful, and provides tools required for large, robust applications. +## Development -## Learning Laravel - -Laravel has the most extensive and thorough [documentation](https://laravel.com/docs) and video tutorial library of all modern web application frameworks, making it a breeze to get started with the framework. - -In addition, [Laracasts](https://laracasts.com) contains thousands of video tutorials on a range of topics including Laravel, modern PHP, unit testing, and JavaScript. Boost your skills by digging into our comprehensive video library. - -You can also watch bite-sized lessons with real-world projects on [Laravel Learn](https://laravel.com/learn), where you will be guided through building a Laravel application from scratch while learning PHP fundamentals. - -## Agentic Development - -Laravel's predictable structure and conventions make it ideal for AI coding agents like Claude Code, Cursor, and GitHub Copilot. Install [Laravel Boost](https://laravel.com/docs/ai) to supercharge your AI workflow: +The host needs only Docker (no PHP/Composer/Node). The dev `app` container runs php-fpm + nginx + +Vite via supervisor; `reverb`, `queue`, `mariadb`, `redis` are their own services. ```bash -composer require laravel/boost --dev - -php artisan boost:install +cp .env.example .env # then set DB_PASSWORD / DB_ROOT_PASSWORD +docker compose up -d --build # app on :80, Vite HMR on :5173 +docker compose run --rm --no-deps -u "${HOST_UID:-1002}:${HOST_GID:-1002}" app php artisan key:generate +docker compose exec app php artisan migrate --seed ``` -Boost provides your agent 15+ tools and skills that help agents build Laravel applications while following best practices. +Run any tooling inside the container, e.g. +`docker compose exec app php artisan make:livewire Servers/Show` (class-based — never Volt). -## Contributing +--- -Thank you for considering contributing to the Laravel framework! The contribution guide can be found in the [Laravel documentation](https://laravel.com/docs/contributions). +## Production install -## Code of Conduct +One host, only Docker + a sudo user. **Caddy** is the single host-exposed service: auto-TLS when a +domain is set, plain HTTP on the bare IP otherwise. `install.sh` is idempotent — it generates +secrets once (never regenerates), brings up the prod stack, migrates, and creates the first admin +with a **one-time random password printed only on the terminal**. -In order to ensure that the Laravel community is welcoming to all, please review and abide by the [Code of Conduct](https://laravel.com/docs/contributions#code-of-conduct). +```bash +git clone https://git.bave.dev/boban/clusev.git && cd clusev +./install.sh +``` -## Security Vulnerabilities +Prompts (non-interactive: set `CLUSEV_DOMAIN` / `CLUSEV_ADMIN_EMAIL` in the environment): -If you discover a security vulnerability within Laravel, please send an e-mail to Taylor Otwell via [taylor@laravel.com](mailto:taylor@laravel.com). All security vulnerabilities will be promptly addressed. +``` +Domain (empty = access by IP over HTTP): clusev.example.com +Admin e-mail (login + Let's Encrypt): admin@example.com +``` + +The closing banner shows the URL + the one-time admin password. On first login Clusev **forces** a +password change and 2FA enrolment before the panel unlocks. + +### One knob: `APP_DOMAIN` + +| `APP_DOMAIN` | proxy | URL | Reverb | +|---|---|---|---| +| *(empty)* | Caddy serves plain HTTP on `APP_PORT` | `http://` | `ws:///app/*` | +| `clusev.example.com` | Caddy gets a Let's Encrypt cert, forces HTTPS | `https://…` | `wss://…/app/*` | + +`SITE_ADDRESS`, `APP_URL`, `REVERB_*` are **derived** from it by the installer — nothing hardcoded. +Bare-IP mode serves 2FA/audit over cleartext HTTP; the installer warns loudly. Let's Encrypt needs +publicly reachable 80/443 — a private (RFC1918) target needs a DNS-01 Caddy build instead. + +### Manual deploy (for operators who don't `curl | bash`) + +```bash +docker compose -f docker-compose.prod.yml build +docker compose -f docker-compose.prod.yml up -d +docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force +docker compose -f docker-compose.prod.yml exec -u app app php artisan clusev:install --email=admin@example.com +``` + +> In-dashboard updates (signed intent file + host-side updater, digest-pinned, cosign-verified, +> 2FA-gated, with backup + rollback) are designed in `docs/install-update-design.md` and land in +> v1.x — they are intentionally **not** shipped yet. + +--- ## License -The Laravel framework is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +AGPL core + commercial Pro modules (open-core). Multi-server fleet management is always free. diff --git a/app/Console/Commands/Install.php b/app/Console/Commands/Install.php new file mode 100644 index 0000000..a373380 --- /dev/null +++ b/app/Console/Commands/Install.php @@ -0,0 +1,61 @@ +exists()) { + $this->info('Clusev ist bereits installiert — kein Admin angelegt.'); + + return self::SUCCESS; + } + + $host = parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'localhost'; + $email = (string) ($this->option('email') ?: "admin@{$host}"); + + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error("Ungueltige Admin-E-Mail: {$email}"); + + return self::FAILURE; + } + + $password = Str::password(20); + + $user = User::create([ + 'name' => (string) ($this->option('name') ?: 'Administrator'), + 'email' => $email, + 'password' => $password, // 'hashed' cast hashes on save + 'must_change_password' => true, // forces rotate + 2FA on first login + ]); + + // One-time password marker — the ONLY place the plaintext appears. + // install.sh greps these two lines for the closing banner; never stored. + $this->newLine(); + $this->line('CLUSEV_ADMIN_EMAIL='.$user->email); + $this->line('CLUSEV_ADMIN_PASSWORD='.$password); + $this->newLine(); + $this->info('Admin angelegt. Passwort erscheint nur jetzt und wird nicht gespeichert.'); + + return self::SUCCESS; + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 3aa715a..089e99a 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,14 +1,18 @@ # Clusev — PROD stack. Plain Docker Compose, no Portainer. -# Image from GHCR (set GHCR_OWNER + IMAGE_TAG in .env), or build on the VM: +# +# Caddy is the ONLY host-exposed service (auto-TLS when APP_DOMAIN is set; plain +# HTTP on the bare IP otherwise). app/reverb are internal-only on the clusev net. +# +# Bootstrap + operate with ./install.sh. Manual deploy: # docker compose -f docker-compose.prod.yml build -# Deploy: -# docker compose -f docker-compose.prod.yml pull # docker compose -f docker-compose.prod.yml up -d -# docker compose -f docker-compose.prod.yml exec app php artisan migrate --force +# docker compose -f docker-compose.prod.yml exec -u app app php artisan migrate --force name: clusev +# Locally-built tag by default; override CLUSEV_IMAGE with a GHCR digest +# (ghcr.io//clusev@sha256:...) once CI signs + publishes images. x-image: &image - image: "ghcr.io/${GHCR_OWNER:-OWNER}/clusev:${IMAGE_TAG:-latest}" + image: "${CLUSEV_IMAGE:-clusev-app:prod}" restart: unless-stopped env_file: - .env @@ -22,8 +26,8 @@ services: context: . dockerfile: Dockerfile target: prod - ports: - - "${APP_PORT:-80}:80" + expose: + - "80" depends_on: mariadb: condition: service_healthy @@ -33,8 +37,8 @@ services: reverb: <<: *image command: php artisan reverb:start --host=0.0.0.0 --port=8080 - ports: - - "${REVERB_HOST_PORT:-8080}:8080" + expose: + - "8080" depends_on: redis: condition: service_started @@ -48,6 +52,27 @@ services: redis: condition: service_started + caddy: + image: caddy:2-alpine # pin by @sha256 digest before the first prod push + restart: unless-stopped + ports: + - "${APP_PORT:-80}:80" + - "443:443" + - "443:443/udp" # HTTP/3 + environment: + SITE_ADDRESS: "${SITE_ADDRESS:-:80}" + # never empty: bare-IP mode has no cert, so this address is unused + ACME_EMAIL: "${ACME_EMAIL:-admin@localhost}" + volumes: + - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data # ACME account + certs — MUST persist across recreate + - caddy-config:/config + depends_on: + - app + - reverb + networks: + - clusev + mariadb: image: mariadb:11 restart: unless-stopped @@ -65,11 +90,12 @@ services: retries: 30 networks: - clusev + # no host ports in prod — DB is reachable only on the internal clusev network redis: image: redis:7-alpine restart: unless-stopped - command: redis-server --appendonly yes + command: redis-server --appendonly yes --requirepass "${REDIS_PASSWORD:?set REDIS_PASSWORD in .env}" volumes: - redis-data:/data networks: @@ -78,6 +104,8 @@ services: volumes: mariadb-data: redis-data: + caddy-data: + caddy-config: networks: clusev: diff --git a/docker/caddy/Caddyfile b/docker/caddy/Caddyfile new file mode 100644 index 0000000..a80e53d --- /dev/null +++ b/docker/caddy/Caddyfile @@ -0,0 +1,30 @@ +# Clusev reverse proxy — one template, both modes (driven by $SITE_ADDRESS). +# +# SITE_ADDRESS=:80 -> plain HTTP on the bare IP, no cert +# SITE_ADDRESS=https://clusev.host.tld -> auto Let's Encrypt, :443, auto-redirect +# +# Reverb (Pusher protocol) rides the same address over /app/* (WS) and /apps/* +# (event API), proxied to reverb:8080 — no extra exposed port, no mixed content. + +{ + # ACME_EMAIL is never empty at runtime: compose defaults it to admin@localhost + # in bare-IP mode, where no cert is issued so the address is simply unused. + email {$ACME_EMAIL} +} + +{$SITE_ADDRESS} { + @reverb path /app/* /apps/* + reverse_proxy @reverb reverb:8080 + + reverse_proxy app:80 + + encode zstd gzip + + header { + # conservative hardening; HSTS only meaningful under HTTPS + X-Content-Type-Options nosniff + X-Frame-Options DENY + Referrer-Policy strict-origin-when-cross-origin + -Server + } +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..4050125 --- /dev/null +++ b/install.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +# Clusev installer — idempotent. Generates secrets, brings up the prod stack, +# migrates the schema, and creates the first admin with a one-time random +# password. Safe to re-run: existing secrets are preserved, never regenerated. +# +# ./install.sh # interactive (prompts for domain + admin email) +# CLUSEV_DOMAIN=app.host.tld CLUSEV_ADMIN_EMAIL=admin@host.tld ./install.sh +set -euo pipefail + +cd "$(dirname "$0")" + +COMPOSE="docker compose -f docker-compose.prod.yml" +ENV_FILE=".env" + +# ── output helpers (German, no emoji) ──────────────────────────────── +bold() { printf '\033[1m%s\033[0m\n' "$*"; } +info() { printf ' %s\n' "$*"; } +warn() { printf '\033[33m ! %s\033[0m\n' "$*"; } +die() { printf '\033[31m x %s\033[0m\n' "$*" >&2; exit 1; } +phase() { printf '\n\033[1m[%s] %s\033[0m\n' "$1" "$2"; } + +# ── .env mutators ──────────────────────────────────────────────────── +# force_kv: always write (config/derived). set_kv: write only when empty/placeholder. +force_kv() { + local key="$1" val="$2" + if grep -qE "^${key}=" "$ENV_FILE"; then + sed -i "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} +set_kv() { + local key="$1" val="$2" cur="" + if grep -qE "^${key}=" "$ENV_FILE"; then + cur="$(grep -E "^${key}=" "$ENV_FILE" | head -n1 | cut -d= -f2-)" + case "$cur" in + ""|null|changeme|change-me-strong|change-me-strong-root) force_kv "$key" "$val" ;; + *) : ;; # keep an existing real value — secrets are never regenerated + esac + else + printf '%s=%s\n' "$key" "$val" >> "$ENV_FILE" + fi +} +rand_hex() { openssl rand -hex "${1:-32}"; } + +# ── [1/7] preflight ────────────────────────────────────────────────── +phase 1/7 "Voraussetzungen pruefen" +command -v docker >/dev/null 2>&1 || die "docker nicht gefunden." +docker compose version >/dev/null 2>&1 || die "docker compose (v2) nicht gefunden." +command -v openssl >/dev/null 2>&1 || die "openssl nicht gefunden." +info "docker + compose + openssl vorhanden" + +# ── inputs (interactive at a TTY, else env / bare-IP fallback) ─────── +DOMAIN="${CLUSEV_DOMAIN:-}" +ADMIN_EMAIL="${CLUSEV_ADMIN_EMAIL:-}" +HTTP_PORT="${CLUSEV_HTTP_PORT:-80}" +if [ -t 0 ]; then + echo; bold "Clusev Installer"; echo "================" + printf '%s' "Domain (leer = Zugriff per IP ueber HTTP): "; read -r in_domain || true + [ -n "${in_domain:-}" ] && DOMAIN="$in_domain" + if [ -z "$DOMAIN" ]; then + printf '%s' "HTTP-Port (Standard 80): "; read -r in_port || true + [ -n "${in_port:-}" ] && HTTP_PORT="$in_port" + fi + printf '%s' "Admin E-Mail (Login + Let's Encrypt): "; read -r in_email || true + [ -n "${in_email:-}" ] && ADMIN_EMAIL="$in_email" +fi + +HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}')"; [ -n "$HOST_IP" ] || HOST_IP="127.0.0.1" + +# derive every proxy/url var from the single APP_DOMAIN knob +if [ -n "$DOMAIN" ]; then + [ -n "$ADMIN_EMAIL" ] || die "Bei gesetzter Domain ist eine Admin E-Mail fuer Let's Encrypt Pflicht." + APP_SCHEME="https"; APP_PORT="$HTTP_PORT" + SITE_ADDRESS="https://${DOMAIN}" + APP_URL="https://${DOMAIN}" + REVERB_HOST="$DOMAIN"; REVERB_PORT="443"; REVERB_SCHEME="https" + case "$HOST_IP" in + 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|127.*) + warn "Host-IP ${HOST_IP} ist privat (RFC1918). Let's Encrypt braucht oeffentlich erreichbare 80/443 — sonst schlaegt das Zertifikat fehl (dann DNS-01-Build noetig)." ;; + esac +else + APP_SCHEME="http"; APP_PORT="$HTTP_PORT" + SITE_ADDRESS=":${HTTP_PORT}" + if [ "$HTTP_PORT" = "80" ]; then APP_URL="http://${HOST_IP}"; else APP_URL="http://${HOST_IP}:${HTTP_PORT}"; fi + REVERB_HOST="$HOST_IP"; REVERB_PORT="$HTTP_PORT"; REVERB_SCHEME="http" + warn "Bare-IP-Modus: Panel inkl. 2FA/Audit laeuft ueber KLARTEXT-HTTP. Fuer Produktion Domain + TLS setzen." +fi + +# ── [2/7] .env + secrets (idempotent) ──────────────────────────────── +phase 2/7 "Secrets generieren" +if [ ! -f "$ENV_FILE" ]; then cp .env.example "$ENV_FILE"; info ".env aus .env.example erstellt"; +else info ".env vorhanden — bestehende Werte bleiben erhalten"; fi + +set_kv DB_PASSWORD "$(rand_hex 24)" +set_kv DB_ROOT_PASSWORD "$(rand_hex 24)" +set_kv REDIS_PASSWORD "$(rand_hex 24)" +set_kv REVERB_APP_ID "$(rand_hex 8)" +set_kv REVERB_APP_KEY "$(rand_hex 16)" +set_kv REVERB_APP_SECRET "$(rand_hex 32)" +set_kv UPDATE_HMAC_KEY "$(rand_hex 32)" + +force_kv APP_ENV production +force_kv APP_DEBUG false +force_kv APP_URL "$APP_URL" +force_kv APP_DOMAIN "$DOMAIN" +force_kv APP_SCHEME "$APP_SCHEME" +force_kv APP_PORT "$APP_PORT" +force_kv ACME_EMAIL "$ADMIN_EMAIL" +force_kv SITE_ADDRESS "$SITE_ADDRESS" +force_kv REVERB_HOST "$REVERB_HOST" +force_kv REVERB_PORT "$REVERB_PORT" +force_kv REVERB_SCHEME "$REVERB_SCHEME" +force_kv HOST_UID "$(id -u)" +force_kv HOST_GID "$(id -g)" +info "Secrets ok; Proxy/URL aus APP_DOMAIN abgeleitet" + +# ── [3/7] image ────────────────────────────────────────────────────── +phase 3/7 "Image bauen" +if [ "${CLUSEV_PULL:-0}" = "1" ]; then $COMPOSE pull; else $COMPOSE build; fi +info "Image bereit" + +cur_key="$(grep -E '^APP_KEY=' "$ENV_FILE" | head -n1 | cut -d= -f2-)" +case "$cur_key" in + ""|base64:) + NEW_KEY="$($COMPOSE run --rm --no-deps app php artisan key:generate --show 2>/dev/null | tr -d '\r' | tail -n1)" + [ -n "$NEW_KEY" ] || die "APP_KEY konnte nicht erzeugt werden." + force_kv APP_KEY "$NEW_KEY"; info "APP_KEY erzeugt" ;; + *) info "APP_KEY vorhanden" ;; +esac + +# ── [4/7] stack ────────────────────────────────────────────────────── +phase 4/7 "Stack starten" +$COMPOSE up -d +info "Container gestartet" + +# ── [5/7] wait for the database ────────────────────────────────────── +phase 5/7 "Datenbank bereit" +db_ready=0 +for _ in $(seq 1 60); do + if $COMPOSE exec -T mariadb healthcheck.sh --connect --innodb_initialized >/dev/null 2>&1; then + db_ready=1; info "MariaDB healthy"; break + fi + sleep 2 +done +[ "$db_ready" = 1 ] || die "MariaDB nicht rechtzeitig bereit." + +# ── [6/7] migrate + caches ─────────────────────────────────────────── +phase 6/7 "Migrationen" +$COMPOSE exec -T -u app app php artisan migrate --force +$COMPOSE exec -T -u app app php artisan config:cache >/dev/null +$COMPOSE exec -T -u app app php artisan route:cache >/dev/null +info "Schema migriert; Caches gebaut" + +# ── [7/7] first admin ──────────────────────────────────────────────── +phase 7/7 "Admin anlegen" +email_args=() +[ -n "$ADMIN_EMAIL" ] && email_args=(--email="$ADMIN_EMAIL") +INSTALL_OUT="$($COMPOSE exec -T -u app app php artisan clusev:install "${email_args[@]}" 2>&1 || true)" +ADMIN_PW="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_PASSWORD=//p' | head -n1)" +ADMIN_MAIL="$(printf '%s\n' "$INSTALL_OUT" | sed -n 's/^CLUSEV_ADMIN_EMAIL=//p' | head -n1)" +[ -n "$ADMIN_MAIL" ] || ADMIN_MAIL="$ADMIN_EMAIL" + +# ── closing banner (password shown ONLY here) ──────────────────────── +echo +echo "============================================================" +bold " Clusev ist bereit." +info "URL: ${APP_URL}" +info "Admin: ${ADMIN_MAIL:-}" +if [ -n "$ADMIN_PW" ]; then + info "Passwort: ${ADMIN_PW} (nur jetzt sichtbar, nicht gespeichert)" +else + info "Passwort: " +fi +info "Erster Login: Passwort aendern + 2FA einrichten (Pflicht)." +[ "$APP_SCHEME" = "http" ] && warn "HTTP ohne TLS — fuer Produktion eine Domain setzen und neu ausfuehren." +echo "============================================================"