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
|
||||
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
1
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
|
||||
)
|
||||
|
|
|
|||
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=
|
||||
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=
|
||||
|
|
|
|||
8
main.go
8
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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