feat: Caddy + Authelia replace Zoraxy
This commit is contained in:
parent
e52b7e6915
commit
a287a67fd1
16 changed files with 217 additions and 857 deletions
11
compose/authelia.yaml
Normal file
11
compose/authelia.yaml
Normal 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
12
compose/caddy.yaml
Normal 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
|
||||||
|
|
@ -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
|
|
||||||
53
config/authelia/configuration.yml
Normal file
53
config/authelia/configuration.yml
Normal 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
|
||||||
8
config/authelia/users_database.yml
Normal file
8
config/authelia/users_database.yml
Normal 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
30
config/caddy/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -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}
|
|
||||||
}
|
|
||||||
|
|
@ -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
352
deploy.sh
|
|
@ -1,8 +1,8 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
REPO_DIR="/opt/NextWks"
|
REPO_DIR="/opt/NextWks"
|
||||||
REPO_URL="https://git.lohmar.co.uk/lexton-it/NextWks.git"
|
|
||||||
TARGET_DIR="/opt/nextworkspace"
|
TARGET_DIR="/opt/nextworkspace"
|
||||||
BACKUP_DIR="/opt/backup"
|
BACKUP_DIR="/opt/backup"
|
||||||
SERVICE_NAME="nextworkspace"
|
SERVICE_NAME="nextworkspace"
|
||||||
|
|
@ -10,310 +10,121 @@ BINARY_NAME="nextworkspace"
|
||||||
HEALTH_CHECK_RETRIES=10
|
HEALTH_CHECK_RETRIES=10
|
||||||
HEALTH_CHECK_INTERVAL=2
|
HEALTH_CHECK_INTERVAL=2
|
||||||
|
|
||||||
# --- Load .env from backup vault (written by install.sh) ---
|
# --- Load .env ---
|
||||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||||
set -a
|
set -a; source "$BACKUP_DIR/.env"; set +a
|
||||||
source "$BACKUP_DIR/.env"
|
|
||||||
set +a
|
|
||||||
DOMAIN="${DOMAIN:-nextwks.eu}"
|
|
||||||
elif [ -f "$TARGET_DIR/.env" ]; then
|
elif [ -f "$TARGET_DIR/.env" ]; then
|
||||||
set -a
|
set -a; source "$TARGET_DIR/.env"; set +a
|
||||||
source "$TARGET_DIR/.env"
|
|
||||||
set +a
|
|
||||||
DOMAIN="${DOMAIN:-nextwks.eu}"
|
|
||||||
else
|
|
||||||
DOMAIN="${DOMAIN:-nextwks.eu}"
|
|
||||||
fi
|
fi
|
||||||
|
DOMAIN="${DOMAIN:-nextwks.eu}"
|
||||||
|
APP_SUB="${APP_SUB:-app}"
|
||||||
|
|
||||||
# Subdomain prefixes (hardcoded — change here if needed)
|
# --- Mode ---
|
||||||
APP_SUB="app"
|
|
||||||
DNS_SUB=""
|
|
||||||
WWW_SUB=""
|
|
||||||
AUTH_SUB=""
|
|
||||||
|
|
||||||
# --- Mode detection ---
|
|
||||||
GREENFIELD=false
|
GREENFIELD=false
|
||||||
if [ "${1:-}" = "--destroy" ]; then
|
if [ "${1:-}" = "--destroy" ]; then GREENFIELD=true; echo "[MODE] Greenfield deploy (--destroy)"
|
||||||
GREENFIELD=true
|
elif [ ! -d "$TARGET_DIR" ]; then GREENFIELD=true; echo "[MODE] Greenfield deploy (target missing)"
|
||||||
echo "[MODE] Greenfield deploy (--destroy)"
|
else echo "[MODE] Smart update (target exists)"; fi
|
||||||
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 ---
|
# --- Pull + build ---
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
cd "$REPO_DIR"
|
||||||
if [ ! -d "$REPO_DIR" ]; then
|
echo "[1/5] Pulling latest code..."
|
||||||
echo "[1/6] Cloning repository..."
|
git pull 2>/dev/null || true
|
||||||
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"
|
|
||||||
|
|
||||||
echo "[2/6] Building binary and helper tool..."
|
echo "[2/5] Building binary..."
|
||||||
export PATH=$PATH:/usr/local/go/bin
|
export PATH=$PATH:/usr/local/go/bin
|
||||||
go build -o "$BINARY_NAME" .
|
go build -o "$BINARY_NAME" .
|
||||||
|
|
||||||
# Build helper tool from script directory (has correct go.mod with all deps)
|
# --- Greenfield ---
|
||||||
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 ---
|
|
||||||
if [ "$GREENFIELD" = true ]; then
|
if [ "$GREENFIELD" = true ]; then
|
||||||
|
echo "[3/5] Full teardown..."
|
||||||
# Step 3: Full teardown — stop, disable, remove all services
|
|
||||||
echo "[3/6] Full service teardown..."
|
|
||||||
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
||||||
systemctl disable $SERVICE_NAME 2>/dev/null || true
|
systemctl disable $SERVICE_NAME 2>/dev/null || true
|
||||||
rm -f /etc/systemd/system/$SERVICE_NAME.service
|
rm -f /etc/systemd/system/$SERVICE_NAME.service
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
podman rm -f zoraxy 2>/dev/null || true
|
podman rm -f caddy authelia 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
|
|
||||||
|
|
||||||
# Wipe production directory
|
|
||||||
if [ -d "$TARGET_DIR" ]; then
|
if [ -d "$TARGET_DIR" ]; then
|
||||||
chattr -R -i "$TARGET_DIR" 2>/dev/null || true
|
chattr -R -i "$TARGET_DIR" 2>/dev/null || true
|
||||||
rm -rf "$TARGET_DIR"
|
rm -rf "$TARGET_DIR"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Step 4: Run helper tool — port 80 is free, can obtain LE certs via HTTP-01
|
echo "[4/5] Building production directories..."
|
||||||
echo "[4/6] Checking certificates..."
|
mkdir -p "$TARGET_DIR/config/caddy"
|
||||||
mkdir -p "$BACKUP_DIR/certificates"
|
mkdir -p "$TARGET_DIR/config/authelia"
|
||||||
# Build list of active subdomains (skip empty ones)
|
mkdir -p "$TARGET_DIR/data/caddy"
|
||||||
ALL_SUBS=""
|
mkdir -p "$TARGET_DIR/data/authelia"
|
||||||
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"
|
|
||||||
mkdir -p "$TARGET_DIR/compose"
|
mkdir -p "$TARGET_DIR/compose"
|
||||||
|
mkdir -p "$TARGET_DIR/www"
|
||||||
|
mkdir -p "$TARGET_DIR/config/nextworkspace"
|
||||||
mkdir -p "$TARGET_DIR/logs"
|
mkdir -p "$TARGET_DIR/logs"
|
||||||
|
|
||||||
# Copy .env from backup vault
|
# Copy .env from backup
|
||||||
if [ -f "$BACKUP_DIR/.env" ]; then
|
if [ -f "$BACKUP_DIR/.env" ]; then
|
||||||
cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
|
cp "$BACKUP_DIR/.env" "$TARGET_DIR/.env"
|
||||||
chmod 600 "$TARGET_DIR/.env"
|
chmod 600 "$TARGET_DIR/.env"
|
||||||
echo "[INFO] .env deployed from backup"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Copy certificates from backup vault to Zoraxy cert dir
|
# Generate secrets if missing
|
||||||
for S in $APP_SUB $DNS_SUB $WWW_SUB; do
|
JWT_SECRET="${JWT_SECRET:-$(openssl rand -hex 32)}"
|
||||||
[ -z "$S" ] && continue
|
SESSION_SECRET="${SESSION_SECRET:-$(openssl rand -hex 32)}"
|
||||||
FQDN="${S}.${DOMAIN}"
|
ADMIN_PASSWORD_HASH="${ADMIN_PASSWORD_HASH:-}"
|
||||||
CERT_SRC="$BACKUP_DIR/certificates/${FQDN}/fullchain.pem"
|
|
||||||
KEY_SRC="$BACKUP_DIR/certificates/${FQDN}/privkey.pem"
|
# Generate bcrypt password hash for Authelia
|
||||||
if [ -f "$CERT_SRC" ] && [ -f "$KEY_SRC" ]; then
|
HASH_TOOL="$(dirname "$0")/tools/hash-password/main.go"
|
||||||
cp "$CERT_SRC" "$TARGET_DIR/config/zoraxy/conf/certs/${FQDN}.pem"
|
if [ -z "$ADMIN_PASSWORD_HASH" ] && [ -n "${ADMIN_PASSWORD:-}" ] && [ -f "$HASH_TOOL" ]; then
|
||||||
cp "$KEY_SRC" "$TARGET_DIR/config/zoraxy/conf/certs/${FQDN}.key"
|
ADMIN_PASSWORD_HASH=$(cd "$(dirname "$0")" && go run ./tools/hash-password/ "$ADMIN_PASSWORD" 2>/dev/null || echo "")
|
||||||
echo "[INFO] Cert deployed: ${FQDN}"
|
if [ -n "$ADMIN_PASSWORD_HASH" ] && [ -f "$BACKUP_DIR/.env" ]; then
|
||||||
|
echo "ADMIN_PASSWORD_HASH='$ADMIN_PASSWORD_HASH'" >> "$BACKUP_DIR/.env"
|
||||||
fi
|
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
|
# Copy binary
|
||||||
echo "[6/6] Deploying..."
|
|
||||||
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
if [ -f "$REPO_DIR/VERSION" ]; then
|
if [ -f "$REPO_DIR/VERSION" ]; then
|
||||||
cp "$REPO_DIR/VERSION" "$TARGET_DIR/VERSION"
|
cp "$REPO_DIR/VERSION" "$TARGET_DIR/VERSION"
|
||||||
echo "[INFO] Version: $(cat $TARGET_DIR/VERSION)"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Write ACME config + BoltDB (before Zoraxy starts, db not locked)
|
# Copy www landing page
|
||||||
mkdir -p "$TARGET_DIR/config/zoraxy/conf"
|
if [ -d "$SCRIPT_DIR/config/www" ]; then
|
||||||
cat > "$TARGET_DIR/config/zoraxy/conf/acme_conf.json" <<EOF
|
cp -r "$SCRIPT_DIR/config/www"/* "$TARGET_DIR/www/"
|
||||||
{
|
|
||||||
"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"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Start Zoraxy
|
# Deploy Caddy + Authelia
|
||||||
cp compose/zoraxy.yaml "$TARGET_DIR/compose/zoraxy.yaml"
|
echo "[5/5] Deploying Caddy and Authelia..."
|
||||||
podman rm -f zoraxy 2>/dev/null || true
|
podman-compose -f "$TARGET_DIR/compose/caddy.yaml" up -d 2>&1 || echo "[WARN] Caddy deploy had issues"
|
||||||
podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || echo "[WARN] Zoraxy 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
|
# Basic launcher config
|
||||||
for i in $(seq 1 15); do
|
cp -r "$SCRIPT_DIR/config/nextworkspace"/* "$TARGET_DIR/config/nextworkspace/" 2>/dev/null || true
|
||||||
if curl -sf --max-time 3 "http://127.0.0.1:8000/" > /dev/null 2>&1; then break; fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
# Register certificates using Go tool (handles CSRF/session properly)
|
# Copy apps.yaml template
|
||||||
echo "[*] Registering certificates via Go tool..."
|
cp "$SCRIPT_DIR/config/nextworkspace/apps.yaml" "$TARGET_DIR/config/nextworkspace/apps.yaml" 2>/dev/null || true
|
||||||
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
|
|
||||||
|
|
||||||
# Write systemd service
|
# Write systemd service
|
||||||
echo "[*] Writing systemd service..."
|
|
||||||
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
|
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NextWorkspace Launcher
|
Description=NextWorkspace Launcher
|
||||||
After=network.target
|
After=network.target caddy.service authelia.service
|
||||||
|
Wants=caddy.service authelia.service
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=CONFIG_DIR=$TARGET_DIR/config/nextworkspace
|
Environment=CONFIG_DIR=$TARGET_DIR/config/nextworkspace
|
||||||
|
|
@ -331,24 +142,14 @@ UNIT
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now $SERVICE_NAME
|
systemctl enable --now $SERVICE_NAME
|
||||||
|
|
||||||
# --- Smart update path ---
|
# --- Smart update ---
|
||||||
else
|
else
|
||||||
echo "[3/6] Stopping launcher..."
|
echo "[3/5] Stopping launcher..."
|
||||||
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
||||||
|
echo "[4/5] Swapping binary..."
|
||||||
echo "[4/6] Swapping binary..."
|
|
||||||
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
|
echo "[5/5] Restarting services..."
|
||||||
echo "[5/6] Refreshing configs..."
|
podman restart caddy authelia 2>/dev/null || true
|
||||||
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
|
|
||||||
systemctl restart $SERVICE_NAME
|
systemctl restart $SERVICE_NAME
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -364,7 +165,4 @@ for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "[FAIL] Health check failed — launcher did not respond on port 9000"
|
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
|
exit 1
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
2
go.sum
2
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=
|
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 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
|
||||||
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
|
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 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
|
|
||||||
8
main.go
8
main.go
|
|
@ -74,11 +74,11 @@ func loadApps(configDir string) ([]AppEntry, error) {
|
||||||
return appsFile.Apps, nil
|
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 {
|
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user := r.Header.Get("X-Forwarded-User")
|
user := r.Header.Get("Remote-User")
|
||||||
if user == "" {
|
if user == "" {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
|
|
@ -99,7 +99,7 @@ func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
||||||
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
|
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
user := r.Header.Get("X-Forwarded-User")
|
user := r.Header.Get("Remote-User")
|
||||||
data := struct {
|
data := struct {
|
||||||
AppName string
|
AppName string
|
||||||
Description string
|
Description string
|
||||||
|
|
@ -123,7 +123,7 @@ func proxyToUpstream(upstream string) http.HandlerFunc {
|
||||||
}
|
}
|
||||||
proxy := httputil.NewSingleHostReverseProxy(target)
|
proxy := httputil.NewSingleHostReverseProxy(target)
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
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)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
BIN
tools/hash-password/hash-password
Executable file
BIN
tools/hash-password/hash-password
Executable file
Binary file not shown.
21
tools/hash-password/main.go
Normal file
21
tools/hash-password/main.go
Normal 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))
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
@ -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 ""
|
|
||||||
}
|
|
||||||
Loading…
Reference in a new issue