feat: Caddy + Authelia replace Zoraxy

This commit is contained in:
Claus Lohmar 2026-07-08 09:54:39 +01:00
parent e52b7e6915
commit a287a67fd1
16 changed files with 217 additions and 857 deletions

11
compose/authelia.yaml Normal file
View file

@ -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

12
compose/caddy.yaml Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,8 @@
users:
master:
disabled: false
displayname: "Master Admin"
password: "{ADMIN_PASSWORD_HASH}"
email: "{TLS_EMAIL}"
groups:
- admins

30
config/caddy/Caddyfile Normal file
View file

@ -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
}

View file

@ -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}
}

View file

@ -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}
}

352
deploy.sh
View file

@ -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" <<EOF
{
"Enabled": true,
"Email": "${TLS_EMAIL}",
"RenewAll": true,
"FilesToRenew": [],
"DNSServers": ""
}
EOF
# Seed admin into BoltDB
if [ -f "$TOOL_BIN" ]; then
mkdir -p "$TARGET_DIR/data/zoraxy"
touch "$TARGET_DIR/data/zoraxy/sys.db"
"$TOOL_BIN" db --db "$TARGET_DIR/data/zoraxy/sys.db" \
--set "system:admin:{\"username\":\"${ADMIN_USERNAME:-master}\"}" 2>&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" <<ZORAXY_APP
{
"ProxyType": 1,
"RootOrMatchingDomain": "${APP_SUB}.$DOMAIN",
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:9000", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
}
ZORAXY_APP
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${DNS_SUB}.$DOMAIN.config" <<ZORAXY_DNS
{
"ProxyType": 1,
"RootOrMatchingDomain": "${DNS_SUB}.$DOMAIN",
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:8000", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
}
ZORAXY_DNS
if [ -n "$WWW_SUB" ]; then
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${WWW_SUB}.$DOMAIN.config" <<ZORAXY_WWW
{
"ProxyType": 1,
"RootOrMatchingDomain": "${WWW_SUB}.$DOMAIN",
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:9000", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
}
ZORAXY_WWW
fi
if [ -n "$AUTH_SUB" ]; then
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/${AUTH_SUB}.$DOMAIN.config" <<ZORAXY_AUTH
{
"ProxyType": 1,
"RootOrMatchingDomain": "${AUTH_SUB}.$DOMAIN",
"ActiveOrigins": [{"OriginIpOrDomain": "127.0.0.1:5489", "RequireTLS": false, "Weight": 1, "MaxConn": 0}],
"Disabled": false, "AuthenticationProvider": {"AuthMethod": 0}
}
ZORAXY_AUTH
fi
chattr -R +i "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/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" <<EOF
apps:
- name: "OpenCloud"
subtitle: "File Storage"
path: "/cloud"
upstream: "http://127.0.0.1:9100"
icon: "cloud"
- name: "Euro Office"
subtitle: "Collaborative Suite"
path: "/office"
upstream: "http://127.0.0.1:9200"
icon: "office"
- name: "ERPNext"
subtitle: "Enterprise ERP"
path: "/erp"
upstream: "http://127.0.0.1:9300"
icon: "erp"
- name: "Matrix Chat"
subtitle: "Team Communication"
path: "/chat"
upstream: "http://127.0.0.1:9400"
icon: "chat"
- name: "Jitsi"
subtitle: "Video Conferencing"
path: "/meet"
upstream: "http://127.0.0.1:9500"
icon: "video"
- name: "Webmail"
subtitle: "Email Client"
path: "/mail"
upstream: "http://127.0.0.1:9600"
icon: "mail"
- name: "AI Chat"
subtitle: "Open WebUI"
path: "/ai"
upstream: "http://127.0.0.1:9700"
icon: "bot"
- name: "Portainer"
subtitle: "Container Management"
path: "/admin"
upstream: "http://127.0.0.1:9800"
icon: "admin"
EOF
# Remove stale proxy.old dirs created by Zoraxy
rm -rf "$TARGET_DIR/config/zoraxy/conf/proxy.old" "$TARGET_DIR/config/zoraxy/conf/proxy-314.old" "$TARGET_DIR/config/zoraxy/conf/proxy-321.old" 2>/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 <<UNIT
[Unit]
Description=NextWorkspace Launcher
After=network.target
After=network.target caddy.service authelia.service
Wants=caddy.service authelia.service
[Service]
Environment=CONFIG_DIR=$TARGET_DIR/config/nextworkspace
@ -331,24 +142,14 @@ UNIT
systemctl daemon-reload
systemctl enable --now $SERVICE_NAME
# --- Smart update path ---
# --- Smart update ---
else
echo "[3/6] Stopping launcher..."
echo "[3/5] Stopping launcher..."
systemctl stop $SERVICE_NAME 2>/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

1
go.mod
View file

@ -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
)

2
go.sum
View file

@ -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=

View file

@ -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)
}
}

BIN
tools/hash-password/hash-password Executable file

Binary file not shown.

View file

@ -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 <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))
}

View file

@ -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 <cert|db|install-lego> [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
}

View file

@ -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 <username> <password> <domain> [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 ""
}