diff --git a/compose/authelia.yaml b/compose/authelia.yaml new file mode 100644 index 0000000..66b3d40 --- /dev/null +++ b/compose/authelia.yaml @@ -0,0 +1,11 @@ +services: + authelia: + image: authelia/authelia:latest + container_name: authelia + restart: unless-stopped + network_mode: host + volumes: + - /opt/nextworkspace/config/authelia/:/config/ + - /opt/nextworkspace/data/authelia/:/data/ + environment: + - TZ=UTC diff --git a/compose/caddy.yaml b/compose/caddy.yaml new file mode 100644 index 0000000..224b442 --- /dev/null +++ b/compose/caddy.yaml @@ -0,0 +1,12 @@ +services: + caddy: + image: caddy:latest + container_name: caddy + restart: unless-stopped + network_mode: host + volumes: + - /opt/nextworkspace/config/caddy/:/etc/caddy/ + - /opt/nextworkspace/data/caddy/:/data/ + - /opt/nextworkspace/logs/caddy/:/var/log/caddy/ + environment: + - TZ=UTC diff --git a/compose/zoraxy.yaml b/compose/zoraxy.yaml deleted file mode 100644 index 27e42c5..0000000 --- a/compose/zoraxy.yaml +++ /dev/null @@ -1,18 +0,0 @@ -services: - zoraxy: - image: zoraxydocker/zoraxy:latest - container_name: zoraxy - restart: unless-stopped - network_mode: host - volumes: - - /opt/nextworkspace/config/zoraxy/:/opt/zoraxy/config/ - - /opt/nextworkspace/config/zoraxy/www/:/opt/zoraxy/www/ - - /opt/nextworkspace/data/zoraxy/:/opt/zoraxy/data/ - - /opt/nextworkspace/logs/zoraxy/:/opt/zoraxy/log/ - - /var/run/docker.sock:/var/run/docker.sock - environment: - - PORT=8000 - - NOAUTH=false - - DOCKER=true - - WEBROOT=/opt/zoraxy/www/ - - TZ=UTC diff --git a/config/authelia/configuration.yml b/config/authelia/configuration.yml new file mode 100644 index 0000000..b11fd4f --- /dev/null +++ b/config/authelia/configuration.yml @@ -0,0 +1,53 @@ +############################################################### +# Authelia configuration # +############################################################### +host: 127.0.0.1 +port: 9091 + +log: + level: info + +theme: dark + +certificates_directory: /config/certs/ + +jwt_secret: {JWT_SECRET} + +default_redirection_url: https://app.{DOMAIN}/ + +totp: + issuer: nextworkspace + +access_control: + default_policy: deny + rules: + - domain: "auth.{DOMAIN}" + policy: bypass + - domain: "app.{DOMAIN}" + policy: one_factor + +authentication_backend: + file: + path: /config/users_database.yml + +session: + name: nextworkspace_session + secret: {SESSION_SECRET} + domain: {DOMAIN} + same_site: lax + expiration: 1h + inactivity: 5m + remember_me_duration: 1M + +regulation: + max_retries: 5 + find_time: 2m + ban_time: 5m + +storage: + local: + path: /data/db.sqlite + +notifier: + filesystem: + filename: /config/notification.yml diff --git a/config/authelia/users_database.yml b/config/authelia/users_database.yml new file mode 100644 index 0000000..65e7012 --- /dev/null +++ b/config/authelia/users_database.yml @@ -0,0 +1,8 @@ +users: + master: + disabled: false + displayname: "Master Admin" + password: "{ADMIN_PASSWORD_HASH}" + email: "{TLS_EMAIL}" + groups: + - admins diff --git a/config/caddy/Caddyfile b/config/caddy/Caddyfile new file mode 100644 index 0000000..de12a01 --- /dev/null +++ b/config/caddy/Caddyfile @@ -0,0 +1,30 @@ +# Global options +{ + email {TLS_EMAIL} + admin off +} + +# Authelia OIDC provider +auth.{DOMAIN} { + reverse_proxy 127.0.0.1:9091 +} + +# Main workspace (forward auth with Authelia) +app.{DOMAIN} { + # Forward auth to Authelia verify endpoint + forward_auth 127.0.0.1:9091 { + uri /api/verify?rd=https://auth.{DOMAIN}/ + + # Copy auth headers to upstream + copy_headers Remote-User Remote-Name Remote-Email + } + + # Serve launcher + proxy upstream apps + reverse_proxy 127.0.0.1:9000 +} + +# Public landing page +www.{DOMAIN} { + root * /opt/nextworkspace/www + file_server +} diff --git a/config/zoraxy/conf/proxy/app.nextwks.eu.config b/config/zoraxy/conf/proxy/app.nextwks.eu.config deleted file mode 100644 index 80f1fe4..0000000 --- a/config/zoraxy/conf/proxy/app.nextwks.eu.config +++ /dev/null @@ -1,12 +0,0 @@ -{ - "ProxyType": 1, - "RootOrMatchingDomain": "app.nextwks.eu", - "ActiveOrigins": [{ - "OriginIpOrDomain": "127.0.0.1:9000", - "RequireTLS": false, - "Weight": 1, - "MaxConn": 0 - }], - "Disabled": false, - "AuthenticationProvider": {"AuthMethod": 0} -} diff --git a/config/zoraxy/conf/proxy/dns.nextwks.eu.config b/config/zoraxy/conf/proxy/dns.nextwks.eu.config deleted file mode 100644 index bb7bde2..0000000 --- a/config/zoraxy/conf/proxy/dns.nextwks.eu.config +++ /dev/null @@ -1,13 +0,0 @@ -{ - "ProxyType": 1, - "RootOrMatchingDomain": "dns.nextwks.eu", - "ActiveOrigins": [{ - "OriginIpOrDomain": "127.0.0.1:8000", - "RequireTLS": false, - "Weight": 1, - "MaxConn": 0 - }], - "Disabled": false, - "BypassGlobalTLS": true, - "AuthenticationProvider": {"AuthMethod": 0} -} diff --git a/deploy.sh b/deploy.sh index 5c042cc..b077cc9 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_DIR="/opt/NextWks" -REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git" TARGET_DIR="/opt/nextworkspace" BACKUP_DIR="/opt/backup" SERVICE_NAME="nextworkspace" @@ -10,310 +10,121 @@ BINARY_NAME="nextworkspace" HEALTH_CHECK_RETRIES=10 HEALTH_CHECK_INTERVAL=2 -# --- Load .env from backup vault (written by install.sh) --- +# --- Load .env --- if [ -f "$BACKUP_DIR/.env" ]; then - set -a - source "$BACKUP_DIR/.env" - set +a - DOMAIN="${DOMAIN:-nextwks.eu}" + set -a; source "$BACKUP_DIR/.env"; set +a elif [ -f "$TARGET_DIR/.env" ]; then - set -a - source "$TARGET_DIR/.env" - set +a - DOMAIN="${DOMAIN:-nextwks.eu}" -else - DOMAIN="${DOMAIN:-nextwks.eu}" + set -a; source "$TARGET_DIR/.env"; set +a fi +DOMAIN="${DOMAIN:-nextwks.eu}" +APP_SUB="${APP_SUB:-app}" -# Subdomain prefixes (hardcoded — change here if needed) -APP_SUB="app" -DNS_SUB="" -WWW_SUB="" -AUTH_SUB="" - -# --- Mode detection --- +# --- Mode --- GREENFIELD=false -if [ "${1:-}" = "--destroy" ]; then - GREENFIELD=true - echo "[MODE] Greenfield deploy (--destroy)" -elif [ ! -d "$TARGET_DIR" ]; then - GREENFIELD=true - echo "[MODE] Greenfield deploy (target missing)" -else - echo "[MODE] Smart update (target exists)" -fi +if [ "${1:-}" = "--destroy" ]; then GREENFIELD=true; echo "[MODE] Greenfield deploy (--destroy)" +elif [ ! -d "$TARGET_DIR" ]; then GREENFIELD=true; echo "[MODE] Greenfield deploy (target missing)" +else echo "[MODE] Smart update (target exists)"; fi -# --- Common: ensure repo exists, pull + build --- -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -if [ ! -d "$REPO_DIR" ]; then - echo "[1/6] Cloning repository..." - git clone "$REPO_URL" "$REPO_DIR" -elif [ -d "$REPO_DIR/.git" ]; then - echo "[1/6] Pulling latest code..." - cd "$REPO_DIR" && git pull -else - echo "[1/6] Using existing code..." -fi -cd "$REPO_DIR" 2>/dev/null || mkdir -p "$REPO_DIR" +# --- Pull + build --- +cd "$REPO_DIR" +echo "[1/5] Pulling latest code..." +git pull 2>/dev/null || true -echo "[2/6] Building binary and helper tool..." +echo "[2/5] Building binary..." export PATH=$PATH:/usr/local/go/bin go build -o "$BINARY_NAME" . -# Build helper tool from script directory (has correct go.mod with all deps) -TOOL_BIN="/tmp/nextwks-tool" -TOOL_SRC="$SCRIPT_DIR/tools/nextwks-tool" -if [ -d "$TOOL_SRC" ]; then - cd "$TOOL_SRC" - go build -o "$TOOL_BIN" . 2>&1 && echo "[OK] Helper tool built" || echo "[WARN] Helper tool build failed" - cd "$REPO_DIR" -fi - -# Install lego if not present (for LE certificate management) -if ! command -v lego &>/dev/null && [ -f "$TOOL_BIN" ]; then - echo "[*] Installing lego ACME client..." - "$TOOL_BIN" install-lego 2>&1 || echo "[WARN] Lego install failed" -fi - -# --- Greenfield path --- +# --- Greenfield --- if [ "$GREENFIELD" = true ]; then - - # Step 3: Full teardown — stop, disable, remove all services - echo "[3/6] Full service teardown..." + echo "[3/5] Full teardown..." systemctl stop $SERVICE_NAME 2>/dev/null || true systemctl disable $SERVICE_NAME 2>/dev/null || true rm -f /etc/systemd/system/$SERVICE_NAME.service systemctl daemon-reload - podman rm -f zoraxy 2>/dev/null || true - # Wait for port 80 to be released - for i in $(seq 1 10); do - if ! ss -tlnp | grep -q ':80 '; then - break - fi - echo " Waiting for port 80 to be released... ($i/10)" - sleep 1 - done + podman rm -f caddy authelia 2>/dev/null || true - # Wipe production directory if [ -d "$TARGET_DIR" ]; then chattr -R -i "$TARGET_DIR" 2>/dev/null || true rm -rf "$TARGET_DIR" fi - # Step 4: Run helper tool — port 80 is free, can obtain LE certs via HTTP-01 - echo "[4/6] Checking certificates..." - mkdir -p "$BACKUP_DIR/certificates" - # Build list of active subdomains (skip empty ones) - ALL_SUBS="" - for S in $APP_SUB $DNS_SUB $WWW_SUB; do - [ -n "$S" ] && ALL_SUBS="${ALL_SUBS:+$ALL_SUBS,}${S}.${DOMAIN}" - done - if [ -f "$TOOL_BIN" ] && [ -n "$ALL_SUBS" ]; then - DRY="" - [ "${LE_DRY_RUN:-false}" = "true" ] && DRY="--dry-run" - "$TOOL_BIN" cert \ - --domains "$ALL_SUBS" \ - --email "${TLS_EMAIL:-admin@${DOMAIN}}" \ - --backup-dir "$BACKUP_DIR/certificates" $DRY 2>&1 || true - fi - - # Step 5: Build production directory structure - echo "[5/6] Building production directory structure..." - mkdir -p "$TARGET_DIR/config/nextworkspace" - mkdir -p "$TARGET_DIR/config/zoraxy/conf/proxy" - mkdir -p "$TARGET_DIR/config/zoraxy/conf/certs" - mkdir -p "$TARGET_DIR/config/zoraxy/www/html" - mkdir -p "$TARGET_DIR/data/zoraxy" + echo "[4/5] Building production directories..." + mkdir -p "$TARGET_DIR/config/caddy" + mkdir -p "$TARGET_DIR/config/authelia" + mkdir -p "$TARGET_DIR/data/caddy" + mkdir -p "$TARGET_DIR/data/authelia" mkdir -p "$TARGET_DIR/compose" + mkdir -p "$TARGET_DIR/www" + mkdir -p "$TARGET_DIR/config/nextworkspace" mkdir -p "$TARGET_DIR/logs" - # Copy .env from backup vault + # Copy .env from backup if [ -f "$BACKUP_DIR/.env" ]; then cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env" chmod 600 "$TARGET_DIR/.env" - echo "[INFO] .env deployed from backup" fi - # Copy certificates from backup vault to Zoraxy cert dir - for S in $APP_SUB $DNS_SUB $WWW_SUB; do - [ -z "$S" ] && continue - FQDN="${S}.${DOMAIN}" - CERT_SRC="$BACKUP_DIR/certificates/${FQDN}/fullchain.pem" - KEY_SRC="$BACKUP_DIR/certificates/${FQDN}/privkey.pem" - if [ -f "$CERT_SRC" ] && [ -f "$KEY_SRC" ]; then - cp "$CERT_SRC" "$TARGET_DIR/config/zoraxy/conf/certs/${FQDN}.pem" - cp "$KEY_SRC" "$TARGET_DIR/config/zoraxy/conf/certs/${FQDN}.key" - echo "[INFO] Cert deployed: ${FQDN}" + # Generate secrets if missing + JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}" + SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}" + ADMIN_PASSWORD_HASH="${ADMIN_PASSWORD_HASH:-}" + + # Generate bcrypt password hash for Authelia + HASH_TOOL="$(dirname "$0")/tools/hash-password/main.go" + if [ -z "$ADMIN_PASSWORD_HASH" ] && [ -n "${ADMIN_PASSWORD:-}" ] && [ -f "$HASH_TOOL" ]; then + ADMIN_PASSWORD_HASH=$(cd "$(dirname "$0")" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "") + if [ -n "$ADMIN_PASSWORD_HASH" ] && [ -f "$BACKUP_DIR/.env" ]; then + echo "ADMIN_PASSWORD_HASH='$ADMIN_PASSWORD_HASH'" >> "$BACKUP_DIR/.env" fi - done + fi + + # Generate Caddyfile + sed -e "s/{DOMAIN}/$DOMAIN/g" -e "s/{TLS_EMAIL}/${TLS_EMAIL:-admin@$DOMAIN}/g" \ + "$SCRIPT_DIR/config/caddy/Caddyfile" > "$TARGET_DIR/config/caddy/Caddyfile" + + # Generate Authelia config + sed -e "s/{DOMAIN}/$DOMAIN/g" \ + -e "s/{JWT_SECRET}/$JWT_SECRET/g" \ + -e "s/{SESSION_SECRET}/$SESSION_SECRET/g" \ + "$SCRIPT_DIR/config/authelia/configuration.yml" > "$TARGET_DIR/config/authelia/configuration.yml" + + # Generate users database + sed -e "s/{ADMIN_PASSWORD_HASH}/$ADMIN_PASSWORD_HASH/g" \ + -e "s/{TLS_EMAIL}/${TLS_EMAIL:-admin@$DOMAIN}/g" \ + "$SCRIPT_DIR/config/authelia/users_database.yml" > "$TARGET_DIR/config/authelia/users_database.yml" + + # Copy compose files + cp "$SCRIPT_DIR/compose/caddy.yaml" "$TARGET_DIR/compose/caddy.yaml" + cp "$SCRIPT_DIR/compose/authelia.yaml" "$TARGET_DIR/compose/authelia.yaml" # Copy binary - echo "[6/6] Deploying..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" if [ -f "$REPO_DIR/VERSION" ]; then cp "$REPO_DIR/VERSION" "$TARGET_DIR/VERSION" - echo "[INFO] Version: $(cat $TARGET_DIR/VERSION)" fi - # Write ACME config + BoltDB (before Zoraxy starts, db not locked) - mkdir -p "$TARGET_DIR/config/zoraxy/conf" - cat > "$TARGET_DIR/config/zoraxy/conf/acme_conf.json" <&1 || true - "$TOOL_BIN" db --db "$TARGET_DIR/data/zoraxy/sys.db" \ - --set "zorxauth:options:{\"enable_auth_gateway\":true,\"sso_redirect_url\":\"https://${APP_SUB}.${DOMAIN}/\"}" 2>&1 || true - echo " [OK] Admin seeded into BoltDB" + # Copy www landing page + if [ -d "$SCRIPT_DIR/config/www" ]; then + cp -r "$SCRIPT_DIR/config/www"/* "$TARGET_DIR/www/" fi - # Start Zoraxy - cp compose/zoraxy.yaml "$TARGET_DIR/compose/zoraxy.yaml" - podman rm -f zoraxy 2>/dev/null || true - podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || echo "[WARN] Zoraxy deploy had issues" + # Deploy Caddy + Authelia + echo "[5/5] Deploying Caddy and Authelia..." + podman-compose -f "$TARGET_DIR/compose/caddy.yaml" up -d 2>&1 || echo "[WARN] Caddy deploy had issues" + podman-compose -f "$TARGET_DIR/compose/authelia.yaml" up -d 2>&1 || echo "[WARN] Authelia deploy had issues" - # Wait for Zoraxy to be ready - for i in $(seq 1 15); do - if curl -sf --max-time 3 "http://127.0.0.1:8000/" > /dev/null 2>&1; then break; fi - sleep 2 - done + # Basic launcher config + cp -r "$SCRIPT_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/" 2>/dev/null || true - # Register certificates using Go tool (handles CSRF/session properly) - echo "[*] Registering certificates via Go tool..." - REG_TOOL="$REPO_DIR/tools/register-certs/register-certs" - # Build list of domains - DOMAIN_LIST="" - for S in $APP_SUB $DNS_SUB $WWW_SUB; do - [ -n "$S" ] && DOMAIN_LIST="$DOMAIN_LIST ${S}.${DOMAIN}" - done - if [ -n "$DOMAIN_LIST" ]; then - if [ ! -f "$REG_TOOL" ]; then - cd "$REPO_DIR/tools/register-certs" && go build -o register-certs . 2>/dev/null && cd "$REPO_DIR" - fi - if [ -f "$REG_TOOL" ]; then - "$REG_TOOL" "${ADMIN_USERNAME:-master}" "${ADMIN_PASSWORD:-9Aku7MfklZU9ldnZ}" $DOMAIN_LIST 2>&1 || true - fi - fi - - # Stop Zoraxy to write proxy configs - - cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${APP_SUB}.$DOMAIN.config" < "$TARGET_DIR/config/zoraxy/conf/proxy/${DNS_SUB}.$DOMAIN.config" < "$TARGET_DIR/config/zoraxy/conf/proxy/${WWW_SUB}.$DOMAIN.config" < "$TARGET_DIR/config/zoraxy/conf/proxy/${AUTH_SUB}.$DOMAIN.config" </dev/null || true - - # Copy landing page + launcher config - cp -r config/www/* "$TARGET_DIR/config/zoraxy/www/html/" - cp -r config/nextworkspace/* "$TARGET_DIR/config/nextworkspace/" - - # Generate apps.yaml - echo "[*] Generating apps.yaml..." - cat > "$TARGET_DIR/config/nextworkspace/apps.yaml" </dev/null || true - - # --- Phase 4: Start Zoraxy with all configs --- - echo "[*] Phase 4: Starting Zoraxy with custom configs..." - podman start zoraxy 2>/dev/null || podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || true - sleep 2 + # Copy apps.yaml template + cp "$SCRIPT_DIR/config/nextworkspace/apps.yaml" "$TARGET_DIR/config/nextworkspace/apps.yaml" 2>/dev/null || true # Write systemd service - echo "[*] Writing systemd service..." cat > /etc/systemd/system/$SERVICE_NAME.service </dev/null || true - - echo "[4/6] Swapping binary..." + echo "[4/5] Swapping binary..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" - - echo "[5/6] Refreshing configs..." - if [ -f config/nextworkspace/apps.yaml ]; then - cp config/nextworkspace/apps.yaml "$TARGET_DIR/config/nextworkspace/apps.yaml" - fi - if [ -d config/zoraxy/conf/proxy ]; then - cp config/zoraxy/conf/proxy/* "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/dev/null || true - fi - - echo "[6/6] Restarting Zoraxy and launcher..." - podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" restart 2>&1 || true + echo "[5/5] Restarting services..." + podman restart caddy authelia 2>/dev/null || true systemctl restart $SERVICE_NAME fi @@ -364,7 +165,4 @@ for i in $(seq 1 $HEALTH_CHECK_RETRIES); do done echo "[FAIL] Health check failed — launcher did not respond on port 9000" -echo "[DEBUG] Checking systemd service status..." -systemctl status $SERVICE_NAME --no-pager 2>&1 | head -10 || true -journalctl -u $SERVICE_NAME --no-pager -n 10 2>&1 || true exit 1 diff --git a/go.mod b/go.mod index 71a0d05..49a880b 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( ) require ( + golang.org/x/crypto v0.53.0 // indirect golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect ) diff --git a/go.sum b/go.sum index b5b1fd6..cc99635 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU= go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= diff --git a/main.go b/main.go index a337c08..5ca9fe5 100644 --- a/main.go +++ b/main.go @@ -74,11 +74,11 @@ func loadApps(configDir string) ([]AppEntry, error) { return appsFile.Apps, nil } -// --- Auth middleware (trusts X-Forwarded-User from Zoraxy Auth) --- +// --- Auth middleware (trusts Remote-User from Caddy forward auth) --- func authMiddleware(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - user := r.Header.Get("X-Forwarded-User") + user := r.Header.Get("Remote-User") if user == "" { http.Error(w, "Unauthorized", http.StatusUnauthorized) return @@ -99,7 +99,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc { tmpl := template.Must(template.New("launcher").Parse(launcherHTML)) return func(w http.ResponseWriter, r *http.Request) { - user := r.Header.Get("X-Forwarded-User") + user := r.Header.Get("Remote-User") data := struct { AppName string Description string @@ -123,7 +123,7 @@ func proxyToUpstream(upstream string) http.HandlerFunc { } proxy := httputil.NewSingleHostReverseProxy(target) return func(w http.ResponseWriter, r *http.Request) { - r.Header.Set("X-Forwarded-User", r.Header.Get("X-Forwarded-User")) + r.Header.Set("Remote-User", r.Header.Get("Remote-User")) proxy.ServeHTTP(w, r) } } diff --git a/tools/hash-password/hash-password b/tools/hash-password/hash-password new file mode 100755 index 0000000..1bffb4a Binary files /dev/null and b/tools/hash-password/hash-password differ diff --git a/tools/hash-password/main.go b/tools/hash-password/main.go new file mode 100644 index 0000000..a3d6572 --- /dev/null +++ b/tools/hash-password/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "os" + + "golang.org/x/crypto/bcrypt" +) + +func main() { + if len(os.Args) < 2 { + fmt.Fprintln(os.Stderr, "Usage: hash-password ") + os.Exit(1) + } + hash, err := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + fmt.Print(string(hash)) +} diff --git a/tools/nextwks-tool/main.go b/tools/nextwks-tool/main.go deleted file mode 100644 index 6434c1f..0000000 --- a/tools/nextwks-tool/main.go +++ /dev/null @@ -1,381 +0,0 @@ -package main - -import ( - "context" - "crypto/rand" - "crypto/rsa" - "crypto/x509" - "crypto/x509/pkix" - "encoding/pem" - "flag" - "fmt" - "log" - "math/big" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - "go.etcd.io/bbolt" -) - -func main() { - if len(os.Args) < 2 { - log.Fatalf("Usage: %s [flags]", os.Args[0]) - } - - switch os.Args[1] { - case "cert": - runCert(os.Args[2:]) - case "db": - runDB(os.Args[2:]) - case "install-lego": - runInstallLego() - default: - log.Fatalf("Unknown command: %s (use cert, db, or install-lego)", os.Args[1]) - } -} - -// --- Lego install --- - -func runInstallLego() { - arch, err := exec.Command("uname", "-m").Output() - if err != nil { - log.Fatalf("Failed to detect architecture: %v", err) - } - archStr := strings.TrimSpace(string(arch)) - archStr = strings.ReplaceAll(archStr, "x86_64", "amd64") - archStr = strings.ReplaceAll(archStr, "aarch64", "arm64") - - // Get latest version - out, err := exec.Command("curl", "-fsSL", - "https://api.github.com/repos/go-acme/lego/releases/latest").Output() - if err != nil { - log.Fatalf("Failed to fetch latest lego version: %v", err) - } - version := extractLegoVersion(string(out)) - if version == "" { - log.Fatalf("Could not parse latest lego version") - } - - tarball := fmt.Sprintf("lego_%s_linux_%s.tar.gz", version, archStr) - url := fmt.Sprintf("https://github.com/go-acme/lego/releases/download/%s/%s", version, tarball) - - log.Printf("Downloading lego %s for %s...", version, archStr) - if err := exec.Command("curl", "-fLO", url).Run(); err != nil { - log.Fatalf("Failed to download lego: %v", err) - } - - log.Printf("Installing to /usr/local/bin/...") - if err := exec.Command("sudo", "tar", "-xzf", tarball, "-C", "/usr/local/bin", "lego").Run(); err != nil { - log.Fatalf("Failed to extract lego: %v", err) - } - - os.Remove(tarball) - log.Printf("[OK] lego %s installed at /usr/local/bin/lego", version) -} - -func extractLegoVersion(body string) string { - // Simple parser for the GitHub API response - marker := `"tag_name": "` - idx := strings.Index(body, marker) - if idx < 0 { - return "" - } - rest := body[idx+len(marker):] - end := strings.Index(rest, `"`) - if end < 0 { - return "" - } - return rest[:end] -} - -// --- Cert command --- - -func runCert(args []string) { - fs := flag.NewFlagSet("cert", flag.ExitOnError) - domainsStr := fs.String("domains", "", "Comma-separated domain list") - email := fs.String("email", "", "ACME email") - backupDir := fs.String("backup-dir", "/opt/backup/certificates", "Backup directory for certs") - legoDir := fs.String("lego-dir", "/opt/backup", "Lego working directory") - dryRun := fs.Bool("dry-run", false, "Skip LE, generate self-signed if no backup") - fs.Parse(args) - - if *domainsStr == "" || *email == "" { - log.Fatal("--domains and --email are required") - } - - domains := strings.Split(*domainsStr, ",") - for i := range domains { - domains[i] = strings.TrimSpace(domains[i]) - } - - if err := os.MkdirAll(*backupDir, 0755); err != nil { - log.Fatalf("Failed to create backup dir: %v", err) - } - - // Check if all domains have valid LE certs in backup or lego cache - needIssue := false - for _, domain := range domains { - certFile := findCertFile(domain, *backupDir, *legoDir) - if certFile == "" { - needIssue = true - break - } - if !isCertFromLE(certFile) { - log.Printf("[INFO] %s: cert is self-signed, reissuing", domain) - needIssue = true - break - } - if isCertExpired(certFile, 30*24*time.Hour) { - log.Printf("[INFO] %s: cert expires soon, reissuing", domain) - needIssue = true - break - } - } - - if needIssue { - if *dryRun { - log.Printf("[INFO] Dry-run mode — generating self-signed fallback certs") - generateSelfSigned(domains, *backupDir) - return - } - // Try lego first - legoPath, legoErr := exec.LookPath("lego") - if legoErr == nil { - log.Printf("[INFO] Requesting LE certificates via lego...") - if err := obtainCertsLego(domains, *email, *backupDir, *legoDir, legoPath); err != nil { - log.Printf("[WARN] lego failed: %v", err) - log.Printf("[INFO] Generating self-signed fallback certs") - generateSelfSigned(domains, *backupDir) - } - } else { - log.Printf("[WARN] lego not found (install with 'nextwks-tool install-lego')") - log.Printf("[INFO] Generating self-signed fallback certs") - generateSelfSigned(domains, *backupDir) - } - } else { - log.Printf("[OK] All certificates found in backup (dry-run)") - for _, domain := range domains { - certFile := filepath.Join(*backupDir, domain, "fullchain.pem") - expiry := getCertExpiry(certFile) - log.Printf(" %s — expires %s", domain, expiry.Format(time.RFC3339)) - } - } -} - -func obtainCertsLego(domains []string, email, backupDir, legoDir, legoPath string) error { - if err := os.MkdirAll(legoDir, 0755); err != nil { - return fmt.Errorf("creating lego dir: %w", err) - } - - certDir := filepath.Join(legoDir, "certificates") - - for _, domain := range domains { - log.Printf(" Requesting cert for %s...", domain) - args := []string{"run", - "--http", - "--http.address=:80", - "--path", legoDir, - "--accept-tos", - "-m", email, - "-d", domain, - } - - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, legoPath, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - log.Printf(" [WARN] lego failed for %s: %v", domain, err) - // Don't try remaining domains — caller will generate self-signed fallbacks - return fmt.Errorf("lego failed for %s: %w", domain, err) - } - - // Small delay so port 80 is released between runs - time.Sleep(2 * time.Second) - - // Copy cert to per-domain backup - crtSrc := filepath.Join(certDir, domain+".crt") - keySrc := filepath.Join(certDir, domain+".key") - domainDir := filepath.Join(backupDir, domain) - os.MkdirAll(domainDir, 0755) - - if fileExists(crtSrc) && fileExists(keySrc) { - copyFile(crtSrc, filepath.Join(domainDir, "fullchain.pem")) - copyFile(keySrc, filepath.Join(domainDir, "privkey.pem")) - log.Printf("[OK] Certificate obtained for %s", domain) - } - } - return nil -} - -// --- DB command --- - -func runDB(args []string) { - fs := flag.NewFlagSet("db", flag.ExitOnError) - dbPath := fs.String("db", "", "Path to BoltDB file") - set := fs.String("set", "", "bucket:key:json-value") - fs.Parse(args) - - if *dbPath == "" || *set == "" { - log.Fatal("--db and --set are required") - } - - parts := strings.SplitN(*set, ":", 3) - if len(parts) != 3 { - log.Fatalf("Invalid --set format. Use bucket:key:json-value") - } - bucket := parts[0] - key := parts[1] - value := parts[2] - - db, err := bbolt.Open(*dbPath, 0600, &bbolt.Options{Timeout: 1 * time.Second}) - if err != nil { - log.Fatalf("Failed to open BoltDB: %v", err) - } - defer db.Close() - - if err := db.Update(func(tx *bbolt.Tx) error { - b, err := tx.CreateBucketIfNotExists([]byte(bucket)) - if err != nil { - return err - } - return b.Put([]byte(key), []byte(value)) - }); err != nil { - log.Fatalf("Failed to write to BoltDB: %v", err) - } - - log.Printf("[OK] Wrote %s:%s to %s", bucket, key, *dbPath) -} - -// --- Helpers --- - -func findCertFile(domain, backupDir, legoDir string) string { - // Check backup first - candidates := []string{ - filepath.Join(backupDir, domain, "fullchain.pem"), - filepath.Join(legoDir, "certificates", domain+".crt"), - } - // Lego issues SAN cert named after first domain — check in lego cache - entries, _ := os.ReadDir(filepath.Join(legoDir, "certificates")) - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".crt") && !strings.Contains(e.Name(), ".issuer.") { - candidates = append(candidates, filepath.Join(legoDir, "certificates", e.Name())) - break - } - } - for _, c := range candidates { - if fileExists(c) && isCertFromLE(c) { - return c - } - } - return "" -} - -func fileExists(path string) bool { - _, err := os.Stat(path) - return err == nil -} - -func isCertFromLE(certFile string) bool { - data, err := os.ReadFile(certFile) - if err != nil { - return false - } - block, _ := pem.Decode(data) - if block == nil { - return false - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return false - } - if len(cert.Issuer.Organization) > 0 { - return strings.Contains(cert.Issuer.Organization[0], "Let's Encrypt") - } - return false -} - -func isCertExpired(certFile string, threshold time.Duration) bool { - data, err := os.ReadFile(certFile) - if err != nil { - return true - } - block, _ := pem.Decode(data) - if block == nil { - return true - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return true - } - return time.Now().Add(threshold).After(cert.NotAfter) -} - -func getCertExpiry(certFile string) time.Time { - data, err := os.ReadFile(certFile) - if err != nil { - return time.Time{} - } - block, _ := pem.Decode(data) - if block == nil { - return time.Time{} - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return time.Time{} - } - return cert.NotAfter -} - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return err - } - return os.WriteFile(dst, data, 0644) -} - -func generateSelfSigned(domains []string, backupDir string) error { - for _, domain := range domains { - key, err := rsa.GenerateKey(rand.Reader, 2048) - if err != nil { - return err - } - - serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) - tmpl := &x509.Certificate{ - SerialNumber: serial, - Subject: pkix.Name{CommonName: domain}, - NotBefore: time.Now(), - NotAfter: time.Now().Add(365 * 24 * time.Hour), - KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, - ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, - } - - certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) - if err != nil { - return err - } - - domainDir := filepath.Join(backupDir, domain) - os.MkdirAll(domainDir, 0755) - - certFile := filepath.Join(domainDir, "fullchain.pem") - keyFile := filepath.Join(domainDir, "privkey.pem") - - f, _ := os.Create(certFile) - pem.Encode(f, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}) - f.Close() - - f, _ = os.Create(keyFile) - pem.Encode(f, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) - f.Close() - - log.Printf("[INFO] Self-signed cert generated for %s", domain) - } - return nil -} diff --git a/tools/register-certs/main.go b/tools/register-certs/main.go deleted file mode 100644 index 95ec9e2..0000000 --- a/tools/register-certs/main.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io" - "mime/multipart" - "net/http" - "net/http/cookiejar" - "net/url" - "os" - "path/filepath" - "regexp" - "strings" -) - -func main() { - if len(os.Args) < 4 { - fmt.Fprintf(os.Stderr, "Usage: %s [domain...]\n", os.Args[0]) - os.Exit(1) - } - username := os.Args[1] - password := os.Args[2] - domains := os.Args[3:] - - jar, _ := cookiejar.New(nil) - client := &http.Client{Jar: jar} - - // Step 1: Fetch login page to get CSRF token - resp, err := client.Get("http://127.0.0.1:8000/login.html") - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: fetching login page: %v\n", err) - os.Exit(1) - } - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() - - csrf := extractCSRF(string(body)) - if csrf == "" { - fmt.Fprintf(os.Stderr, "FAIL: could not extract CSRF token\n") - os.Exit(1) - } - - // Step 2: Login - form := url.Values{"username": {username}, "password": {password}} - req, _ := http.NewRequest("POST", "http://127.0.0.1:8000/api/auth/login", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("X-CSRF-Token", csrf) - - resp, err = client.Do(req) - if err != nil { - fmt.Fprintf(os.Stderr, "FAIL: login request: %v\n", err) - os.Exit(1) - } - body, _ = io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode != 200 || !strings.Contains(strings.ToLower(string(body)), `"ok"`) { - fmt.Fprintf(os.Stderr, "FAIL: login failed (status=%d): %s\n", resp.StatusCode, strings.TrimSpace(string(body))) - os.Exit(1) - } - fmt.Printf("OK: Logged in as %s\n", username) - - // Step 3: Upload cert for each domain using multipart/form-data - certsDir := "/opt/nextworkspace/config/zoraxy/conf/certs" - success := true - for _, domain := range domains { - // Try .pem first, then .crt - pemPath := filepath.Join(certsDir, domain+".pem") - crtPath := filepath.Join(certsDir, domain+".crt") - keyPath := filepath.Join(certsDir, domain+".key") - - certData, err := os.ReadFile(pemPath) - if err != nil { - certData, err = os.ReadFile(crtPath) - if err != nil { - fmt.Printf("SKIP: %s (no cert file)\n", domain) - continue - } - } - keyData, err := os.ReadFile(keyPath) - if err != nil { - fmt.Printf("WARN: %s (no key file), uploading cert only\n", domain) - } - - // Build multipart form - var buf bytes.Buffer - w := multipart.NewWriter(&buf) - w.WriteField("domain", domain) - - certWriter, _ := w.CreateFormFile("cert", domain+".pem") - certWriter.Write(certData) - - if keyData != nil { - keyWriter, _ := w.CreateFormFile("key", domain+".key") - keyWriter.Write(keyData) - } - w.Close() - - // Get fresh CSRF - resp, err := client.Get("http://127.0.0.1:8000/login.html") - if err != nil { - fmt.Printf("WARN: %s csrf fetch failed: %v\n", domain, err) - continue - } - b, _ := io.ReadAll(resp.Body) - resp.Body.Close() - csrf = extractCSRF(string(b)) - if csrf == "" { - fmt.Printf("WARN: %s no CSRF token\n", domain) - continue - } - - req, _ := http.NewRequest("POST", "http://127.0.0.1:8000/api/cert/upload", &buf) - req.Header.Set("Content-Type", w.FormDataContentType()) - req.Header.Set("X-CSRF-Token", csrf) - - resp, err = client.Do(req) - if err != nil { - fmt.Printf("FAIL: %s request failed: %v\n", domain, err) - success = false - continue - } - b, _ = io.ReadAll(resp.Body) - resp.Body.Close() - - if resp.StatusCode == 200 { - fmt.Printf("OK: %s cert uploaded\n", domain) - } else { - fmt.Printf("FAIL: %s (status=%d): %s\n", domain, resp.StatusCode, strings.TrimSpace(string(b))) - success = false - } - } - - if !success { - os.Exit(1) - } -} - -func extractCSRF(html string) string { - idx := strings.Index(html, "zoraxy.csrf.Token") - if idx < 0 { - return "" - } - sub := html[idx:] - re := regexp.MustCompile(`content="([^"]+)"`) - m := re.FindStringSubmatch(sub) - if len(m) > 1 { - return m[1] - } - return "" -}