feat: Zoraxy proxy + launcher rewrite
This commit is contained in:
parent
811c0c75f7
commit
7fdf6692df
9 changed files with 263 additions and 287 deletions
16
compose/zoraxy.yaml
Normal file
16
compose/zoraxy.yaml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
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/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
|
||||||
|
- TZ=UTC
|
||||||
16
config/zoraxy/proxy/(ST).app.nextwks.eu.config
Normal file
16
config/zoraxy/proxy/(ST).app.nextwks.eu.config
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "app.nextwks.eu",
|
||||||
|
"ActiveOrigins": [
|
||||||
|
{
|
||||||
|
"OriginIpOrDomain": "host.docker.internal:9000",
|
||||||
|
"RequireTLS": false,
|
||||||
|
"Weight": 1,
|
||||||
|
"MaxConn": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Disabled": false,
|
||||||
|
"AuthenticationProvider": {
|
||||||
|
"AuthMethod": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
9
config/zoraxy/proxy/root.config
Normal file
9
config/zoraxy/proxy/root.config
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "*",
|
||||||
|
"ActiveOrigins": [],
|
||||||
|
"Disabled": false,
|
||||||
|
"AuthenticationProvider": {
|
||||||
|
"AuthMethod": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
server:
|
|
||||||
port: 80
|
|
||||||
host: "0.0.0.0"
|
|
||||||
|
|
||||||
tls:
|
|
||||||
enabled: true # ← was false, now true
|
|
||||||
email: "den@2-4-h.net" # ← Let's Encrypt account email
|
|
||||||
|
|
||||||
domain: "nextwks.eu" # Base domain for subdomain routing + certs
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
# Subdomain → registered app mapping
|
|
||||||
# app : app handler name (reserved)
|
|
||||||
# future: mail, drive, calendar, etc.
|
|
||||||
apps:
|
|
||||||
app: core
|
|
||||||
49
deploy.sh
49
deploy.sh
|
|
@ -2,7 +2,7 @@
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
REPO_DIR="/opt/NextWks"
|
REPO_DIR="/opt/NextWks"
|
||||||
TARGET_DIR="/opt/workspace"
|
TARGET_DIR="/opt/nextworkspace"
|
||||||
SERVICE_NAME="nextworkspace"
|
SERVICE_NAME="nextworkspace"
|
||||||
BINARY_NAME="nextworkspace"
|
BINARY_NAME="nextworkspace"
|
||||||
HEALTH_CHECK_RETRIES=10
|
HEALTH_CHECK_RETRIES=10
|
||||||
|
|
@ -35,23 +35,32 @@ if [ "$GREENFIELD" = true ]; then
|
||||||
rm -rf "$TARGET_DIR"
|
rm -rf "$TARGET_DIR"
|
||||||
|
|
||||||
echo "[4/8] Creating target directories..."
|
echo "[4/8] Creating target directories..."
|
||||||
mkdir -p "$TARGET_DIR/configs/core/certs"
|
mkdir -p "$TARGET_DIR/config/nextworkspace"
|
||||||
|
mkdir -p "$TARGET_DIR/config/zoraxy/proxy"
|
||||||
|
mkdir -p "$TARGET_DIR/data/zoraxy"
|
||||||
|
mkdir -p "$TARGET_DIR/compose"
|
||||||
|
mkdir -p "$TARGET_DIR/logs/zoraxy"
|
||||||
|
|
||||||
echo "[5/8] Copying binary..."
|
echo "[5/8] Copying binary..."
|
||||||
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
|
|
||||||
echo "[6/8] Copying config files..."
|
echo "[6/8] Copying config files..."
|
||||||
cp configs/core/config.yaml "$TARGET_DIR/configs/core/config.yaml"
|
cp -r config/nextworkspace/* "$TARGET_DIR/config/nextworkspace/"
|
||||||
cp configs/core/proxies.yaml "$TARGET_DIR/configs/core/proxies.yaml"
|
cp -r config/zoraxy/* "$TARGET_DIR/config/zoraxy/"
|
||||||
|
cp compose/zoraxy.yaml "$TARGET_DIR/compose/zoraxy.yaml"
|
||||||
|
|
||||||
echo "[7/8] Writing systemd service..."
|
echo "[7/8] Deploying Zoraxy..."
|
||||||
|
podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" up -d 2>&1 || echo "[WARN] Zoraxy deploy had issues (see above)"
|
||||||
|
|
||||||
|
echo "[8/8] Writing systemd service and starting launcher..."
|
||||||
|
mkdir -p "$TARGET_DIR"
|
||||||
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
|
cat > /etc/systemd/system/$SERVICE_NAME.service <<UNIT
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=NextWorkspace
|
Description=NextWorkspace Launcher
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Environment=CONFIG_DIR=$TARGET_DIR/configs/core
|
Environment=CONFIG_DIR=$TARGET_DIR/config/nextworkspace
|
||||||
ExecStart=$TARGET_DIR/$BINARY_NAME
|
ExecStart=$TARGET_DIR/$BINARY_NAME
|
||||||
WorkingDirectory=$TARGET_DIR
|
WorkingDirectory=$TARGET_DIR
|
||||||
Restart=always
|
Restart=always
|
||||||
|
|
@ -62,36 +71,42 @@ Group=root
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
UNIT
|
UNIT
|
||||||
|
|
||||||
echo "[8/8] Starting service..."
|
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now $SERVICE_NAME
|
systemctl enable --now $SERVICE_NAME
|
||||||
|
|
||||||
# --- Smart update path ---
|
# --- Smart update path ---
|
||||||
else
|
else
|
||||||
echo "[3/8] Stopping service..."
|
echo "[3/8] Stopping launcher..."
|
||||||
systemctl stop $SERVICE_NAME
|
systemctl stop $SERVICE_NAME 2>/dev/null || true
|
||||||
|
|
||||||
echo "[4/8] Swapping binary..."
|
echo "[4/8] Swapping binary..."
|
||||||
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME"
|
||||||
|
|
||||||
echo "[5/8] Refreshing configs..."
|
echo "[5/8] Refreshing configs..."
|
||||||
cp configs/core/config.yaml "$TARGET_DIR/configs/core/config.yaml"
|
if [ -f config/nextworkspace/apps.yaml ]; then
|
||||||
cp configs/core/proxies.yaml "$TARGET_DIR/configs/core/proxies.yaml"
|
cp config/nextworkspace/apps.yaml "$TARGET_DIR/config/nextworkspace/apps.yaml"
|
||||||
|
fi
|
||||||
|
if [ -d config/zoraxy/proxy ]; then
|
||||||
|
cp config/zoraxy/proxy/* "$TARGET_DIR/config/zoraxy/proxy/" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
echo "[6/8] Restarting service..."
|
echo "[6/8] Restarting Zoraxy..."
|
||||||
|
podman-compose -f "$TARGET_DIR/compose/zoraxy.yaml" restart 2>&1 || true
|
||||||
|
|
||||||
|
echo "[7/8] Starting launcher..."
|
||||||
systemctl restart $SERVICE_NAME
|
systemctl restart $SERVICE_NAME
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Health check (both modes) ---
|
# --- Health check ---
|
||||||
echo "[*] Running health check..."
|
echo "[*] Running health check..."
|
||||||
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
for i in $(seq 1 $HEALTH_CHECK_RETRIES); do
|
||||||
if curl -sf http://localhost:80/ > /dev/null 2>&1; then
|
if curl -sf http://127.0.0.1:9000/health > /dev/null 2>&1; then
|
||||||
echo "[OK] NextWorkspace is serving on http://localhost:80/"
|
echo "[OK] NextWorkspace launcher is healthy on http://127.0.0.1:9000/"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
echo " Attempt $i/$HEALTH_CHECK_RETRIES — not ready yet..."
|
echo " Attempt $i/$HEALTH_CHECK_RETRIES — not ready yet..."
|
||||||
sleep $HEALTH_CHECK_INTERVAL
|
sleep $HEALTH_CHECK_INTERVAL
|
||||||
done
|
done
|
||||||
|
|
||||||
echo "[FAIL] Health check failed — service did not respond on port 80"
|
echo "[FAIL] Health check failed — launcher did not respond on port 9000"
|
||||||
exit 1
|
exit 1
|
||||||
|
|
|
||||||
24
go.mod
24
go.mod
|
|
@ -2,26 +2,4 @@ module nextworkspace
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
github.com/caddyserver/certmagic v0.25.4
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
)
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/caddyserver/zerossl v0.1.5 // indirect
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
|
||||||
github.com/libdns/libdns v1.1.1 // indirect
|
|
||||||
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
|
||||||
go.uber.org/zap v1.27.1 // indirect
|
|
||||||
go.uber.org/zap/exp v0.3.0 // indirect
|
|
||||||
golang.org/x/crypto v0.50.0 // indirect
|
|
||||||
golang.org/x/mod v0.35.0 // indirect
|
|
||||||
golang.org/x/net v0.53.0 // indirect
|
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
|
||||||
golang.org/x/text v0.36.0 // indirect
|
|
||||||
golang.org/x/tools v0.44.0 // indirect
|
|
||||||
)
|
|
||||||
|
|
|
||||||
56
go.sum
56
go.sum
|
|
@ -1,59 +1,3 @@
|
||||||
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
|
||||||
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
|
||||||
github.com/caddyserver/certmagic v0.25.4 h1:8eIXh0HC3MsGnNo8One+BCxMGTbe5zb/oz+2KsxBFQg=
|
|
||||||
github.com/caddyserver/certmagic v0.25.4/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
|
|
||||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
|
||||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
|
|
||||||
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
|
|
||||||
github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
|
|
||||||
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
|
|
||||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
|
||||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
|
||||||
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
|
|
||||||
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
|
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
|
||||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
|
||||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
|
||||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
|
||||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
|
||||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
|
||||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
|
||||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
|
||||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
|
||||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
|
||||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
|
||||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
|
||||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
|
||||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
|
||||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
|
||||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
|
||||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
|
||||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
|
||||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
|
||||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
|
||||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
|
||||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
|
|
||||||
354
main.go
354
main.go
|
|
@ -1,121 +1,105 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/caddyserver/certmagic"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Config types ---
|
// --- Config types ---
|
||||||
|
|
||||||
type Config struct {
|
|
||||||
Server ServerConfig `yaml:"server"`
|
|
||||||
TLS TLSConfig `yaml:"tls"`
|
|
||||||
Domain string `yaml:"domain"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port"`
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TLSConfig struct {
|
type AppConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Name string `yaml:"name"`
|
||||||
Email string `yaml:"email"`
|
Description string `yaml:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Proxies struct {
|
type Config struct {
|
||||||
Apps map[string]string `yaml:"apps"`
|
Server ServerConfig `yaml:"server"`
|
||||||
|
App AppConfig `yaml:"app"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppEntry struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Subtitle string `yaml:"subtitle"`
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Icon string `yaml:"icon"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppsFile struct {
|
||||||
|
Apps []AppEntry `yaml:"apps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Config loading ---
|
// --- Config loading ---
|
||||||
|
|
||||||
func loadConfig(configDir string) (*Config, *Proxies, error) {
|
func loadConfig(configDir string) (*Config, error) {
|
||||||
configPath := filepath.Join(configDir, "config.yaml")
|
path := filepath.Join(configDir, "config.yaml")
|
||||||
proxiesPath := filepath.Join(configDir, "proxies.yaml")
|
data, err := os.ReadFile(path)
|
||||||
|
|
||||||
configData, err := os.ReadFile(configPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("reading config: %w", err)
|
return nil, fmt.Errorf("reading config: %w", err)
|
||||||
}
|
}
|
||||||
proxiesData, err := os.ReadFile(proxiesPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("reading proxies: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := yaml.Unmarshal(configData, &cfg); err != nil {
|
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
return nil, nil, fmt.Errorf("parsing config: %w", err)
|
return nil, fmt.Errorf("parsing config: %w", err)
|
||||||
}
|
}
|
||||||
var proxies Proxies
|
return &cfg, nil
|
||||||
if err := yaml.Unmarshal(proxiesData, &proxies); err != nil {
|
|
||||||
return nil, nil, fmt.Errorf("parsing proxies: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return &cfg, &proxies, nil
|
func loadApps(configDir string) ([]AppEntry, error) {
|
||||||
|
path := filepath.Join(configDir, "apps.yaml")
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return []AppEntry{}, nil
|
||||||
}
|
}
|
||||||
|
return nil, fmt.Errorf("reading apps: %w", err)
|
||||||
// --- Subdomain extraction ---
|
|
||||||
|
|
||||||
func extractSubdomain(host, domain string) string {
|
|
||||||
host = strings.ToLower(host)
|
|
||||||
|
|
||||||
// Strip port if present
|
|
||||||
if idx := strings.LastIndex(host, ":"); idx != -1 {
|
|
||||||
host = host[:idx]
|
|
||||||
}
|
}
|
||||||
|
var appsFile AppsFile
|
||||||
// Bare domain — no subdomain (e.g. "nextwks.eu")
|
if err := yaml.Unmarshal(data, &appsFile); err != nil {
|
||||||
domainWithDot := "." + domain
|
return nil, fmt.Errorf("parsing apps: %w", err)
|
||||||
if host == domain {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
if !strings.HasSuffix(host, domainWithDot) {
|
return appsFile.Apps, nil
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
return strings.TrimSuffix(host, domainWithDot)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
|
|
||||||
func coreAppHandler(w http.ResponseWriter, r *http.Request) {
|
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
fmt.Fprint(w, `<!DOCTYPE html>
|
fmt.Fprint(w, "OK")
|
||||||
<html lang="en">
|
}
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
||||||
<title>NextWorkspace — App</title>
|
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
|
||||||
</head>
|
|
||||||
<body>
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
<h1>NextWorkspace — Your Workspace, Your Server</h1>
|
user := r.Header.Get("X-Forwarded-User")
|
||||||
<p>Coming soon.</p>
|
|
||||||
</body>
|
data := struct {
|
||||||
</html>`)
|
AppName string
|
||||||
|
Description string
|
||||||
|
User string
|
||||||
|
LoggedIn bool
|
||||||
|
Apps []AppEntry
|
||||||
|
}{
|
||||||
|
AppName: cfg.App.Name,
|
||||||
|
Description: cfg.App.Description,
|
||||||
|
User: user,
|
||||||
|
LoggedIn: user != "",
|
||||||
|
Apps: apps,
|
||||||
}
|
}
|
||||||
|
|
||||||
func unknownSubdomainHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusNotFound)
|
tmpl.Execute(w, data)
|
||||||
fmt.Fprint(w, `<!DOCTYPE html>
|
}
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<title>Not Found</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>Not Found</h1>
|
|
||||||
<p>No application registered for this subdomain.</p>
|
|
||||||
</body>
|
|
||||||
</html>`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Main ---
|
// --- Main ---
|
||||||
|
|
@ -123,113 +107,141 @@ func unknownSubdomainHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
func main() {
|
func main() {
|
||||||
configDir := os.Getenv("CONFIG_DIR")
|
configDir := os.Getenv("CONFIG_DIR")
|
||||||
if configDir == "" {
|
if configDir == "" {
|
||||||
configDir = "/opt/workspace/configs/core"
|
configDir = "/opt/nextworkspace/config/nextworkspace"
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, proxies, err := loadConfig(configDir)
|
cfg, err := loadConfig(configDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to load config: %v", err)
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build subdomain -> handler map
|
apps, err := loadApps(configDir)
|
||||||
handlers := make(map[string]http.HandlerFunc)
|
if err != nil {
|
||||||
for subdomain, appName := range proxies.Apps {
|
log.Fatalf("Failed to load apps: %v", err)
|
||||||
switch appName {
|
|
||||||
case "core":
|
|
||||||
handlers[subdomain] = coreAppHandler
|
|
||||||
default:
|
|
||||||
log.Printf("Warning: unknown app %q for subdomain %q", appName, subdomain)
|
|
||||||
handlers[subdomain] = unknownSubdomainHandler
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main router (for HTTPS or non-TLS mode)
|
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
router := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
subdomain := extractSubdomain(r.Host, cfg.Domain)
|
|
||||||
if handler, ok := handlers[subdomain]; ok {
|
|
||||||
handler(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unknownSubdomainHandler(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
httpAddr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/health", healthHandler)
|
||||||
|
mux.HandleFunc("/", launcherHandler(cfg, apps))
|
||||||
|
|
||||||
if cfg.TLS.Enabled {
|
log.Printf("NextWorkspace launcher listening on %s", addr)
|
||||||
log.Println("NextWorkspace starting with TLS (certmagic)...")
|
log.Fatal(http.ListenAndServe(addr, mux))
|
||||||
|
|
||||||
certmagic.DefaultACME.Agreed = true
|
|
||||||
certmagic.DefaultACME.Email = cfg.TLS.Email
|
|
||||||
certmagic.Default.Storage = &certmagic.FileStorage{
|
|
||||||
Path: filepath.Join(configDir, "certs"),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect domains from proxies.yaml only (no bare domain, no wildcard)
|
const launcherHTML = `<!DOCTYPE html>
|
||||||
// Each subdomain gets its own certificate independently.
|
<html lang="en">
|
||||||
var domains []string
|
<head>
|
||||||
for subdomain := range proxies.Apps {
|
<meta charset="UTF-8">
|
||||||
domains = append(domains, subdomain+"."+cfg.Domain)
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{.AppName}}</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f0f2f5;
|
||||||
|
color: #1a1a2e;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
header {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
color: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
header h1 { font-size: 2rem; margin-bottom: 0.25rem; }
|
||||||
|
header p { color: #a0aec0; font-size: 1rem; }
|
||||||
|
.user-banner {
|
||||||
|
background: #2d3748;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.user-banner a { color: #63b3ed; text-decoration: none; margin-left: 0.5rem; }
|
||||||
|
.user-banner a:hover { text-decoration: underline; }
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
.icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.card h3 { font-size: 1.1rem; margin-bottom: 0.25rem; }
|
||||||
|
.card p { color: #718096; font-size: 0.85rem; }
|
||||||
|
.login-prompt {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
}
|
||||||
|
.login-prompt h2 { margin-bottom: 1rem; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.75rem 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.btn:hover { background: #2d3748; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>{{.AppName}}</h1>
|
||||||
|
<p>{{.Description}}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
// Obtain and manage certificates
|
{{if .LoggedIn}}
|
||||||
magic := certmagic.NewDefault()
|
<div class="user-banner">
|
||||||
if err := magic.ManageSync(context.Background(), domains); err != nil {
|
Welcome, {{.User}} · <a href="/logout">Logout</a>
|
||||||
log.Fatalf("Failed to manage certificates: %v", err)
|
</div>
|
||||||
}
|
{{else}}
|
||||||
|
<div class="login-prompt">
|
||||||
|
<h2>Welcome to {{.AppName}}</h2>
|
||||||
|
<p>Sign in to access your workspace applications.</p>
|
||||||
|
<a class="btn" href="/login">Login</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
// Extract ACME issuer for http-01 challenge handling
|
{{if .LoggedIn}}
|
||||||
var acmeIssuer *certmagic.ACMEIssuer
|
<div class="grid">
|
||||||
if len(magic.Issuers) > 0 {
|
{{range .Apps}}
|
||||||
acmeIssuer, _ = magic.Issuers[0].(*certmagic.ACMEIssuer)
|
<a class="card" href="{{.URL}}" target="_blank" rel="noopener">
|
||||||
}
|
<div class="icon">{{.Icon}}</div>
|
||||||
|
<h3>{{.Name}}</h3>
|
||||||
// HTTP handler on :80
|
<p>{{.Subtitle}}</p>
|
||||||
// - Known subdomains → redirect to HTTPS
|
</a>
|
||||||
// - Unknown subdomains → error directly on HTTP (no redirect to broken HTTPS)
|
{{end}}
|
||||||
// - ACME challenge paths → handled by certmagic before reaching us
|
</div>
|
||||||
var httpHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
{{end}}
|
||||||
subdomain := extractSubdomain(r.Host, cfg.Domain)
|
</body>
|
||||||
if _, ok := handlers[subdomain]; ok {
|
</html>`
|
||||||
target := "https://" + r.Host + r.URL.RequestURI()
|
|
||||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
unknownSubdomainHandler(w, r)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Wrap with ACME challenge handler for Let's Encrypt http-01 validation
|
|
||||||
if acmeIssuer != nil {
|
|
||||||
httpHandler = acmeIssuer.HTTPChallengeHandler(httpHandler)
|
|
||||||
log.Println("ACME challenge handler enabled on :80")
|
|
||||||
}
|
|
||||||
|
|
||||||
httpServer := &http.Server{
|
|
||||||
Addr: httpAddr,
|
|
||||||
Handler: httpHandler,
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPS server on :443 with TLS
|
|
||||||
tlsConfig := magic.TLSConfig()
|
|
||||||
tlsConfig.NextProtos = append(tlsConfig.NextProtos, "h2") // HTTP/2 support
|
|
||||||
|
|
||||||
httpsServer := &http.Server{
|
|
||||||
Addr: ":443",
|
|
||||||
Handler: router,
|
|
||||||
TLSConfig: tlsConfig,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start HTTP server
|
|
||||||
go func() {
|
|
||||||
log.Printf("HTTP listening on %s", httpAddr)
|
|
||||||
if err := httpServer.ListenAndServe(); err != nil {
|
|
||||||
log.Fatalf("HTTP server error: %v", err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
log.Println("HTTPS listening on :443")
|
|
||||||
log.Fatal(httpsServer.ListenAndServeTLS("", "")) // certmagic provides certs via TLSConfig
|
|
||||||
} else {
|
|
||||||
log.Printf("NextWorkspace listening on %s (no TLS)", httpAddr)
|
|
||||||
log.Fatal(http.ListenAndServe(httpAddr, router))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue