feat: nextwks-tool for LE + DB + Zoraxy Auth
This commit is contained in:
parent
d73b67614e
commit
2398bdce83
5 changed files with 398 additions and 192 deletions
50
deploy.sh
50
deploy.sh
|
|
@ -42,10 +42,31 @@ cd "$REPO_DIR"
|
||||||
echo "[1/6] Pulling latest code..."
|
echo "[1/6] Pulling latest code..."
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
echo "[2/6] Building binary..."
|
echo "[2/6] Building binary and helper tool..."
|
||||||
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 for cert management
|
||||||
|
TOOL_DIR="$REPO_DIR/tools/nextwks-tool"
|
||||||
|
TOOL_BIN="/tmp/nextwks-tool"
|
||||||
|
if [ -d "$TOOL_DIR" ]; then
|
||||||
|
cd "$TOOL_DIR"
|
||||||
|
go build -o "$TOOL_BIN" . 2>/dev/null && echo "[OK] Helper tool built" || echo "[WARN] Helper tool build failed"
|
||||||
|
cd "$REPO_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Certificate management via helper tool ---
|
||||||
|
if [ -f "$TOOL_BIN" ]; then
|
||||||
|
echo "[*] Managing LE certificates..."
|
||||||
|
mkdir -p /opt/backup/certs
|
||||||
|
|
||||||
|
"$TOOL_BIN" cert \
|
||||||
|
--domains "app.${DOMAIN},dns.${DOMAIN},www.${DOMAIN}" \
|
||||||
|
--email "${TLS_EMAIL:-admin@${DOMAIN}}" \
|
||||||
|
--backup-dir /opt/backup/certs \
|
||||||
|
--deploy-dir "${TARGET_DIR}/config/zoraxy/conf/certs" 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
# --- Greenfield path ---
|
# --- Greenfield path ---
|
||||||
if [ "$GREENFIELD" = true ]; then
|
if [ "$GREENFIELD" = true ]; then
|
||||||
echo "[3/6] Removing old deployment..."
|
echo "[3/6] Removing old deployment..."
|
||||||
|
|
@ -224,6 +245,23 @@ ZORAXY_WWW
|
||||||
# Copy www landing page
|
# Copy www landing page
|
||||||
cp -r config/www/* "$TARGET_DIR/config/zoraxy/www/html/"
|
cp -r config/www/* "$TARGET_DIR/config/zoraxy/www/html/"
|
||||||
|
|
||||||
|
# Generate auth subdomain config (handled by Zoraxy Auth on port 5489)
|
||||||
|
echo "[*] Generating auth subdomain config..."
|
||||||
|
cat > "$TARGET_DIR/config/zoraxy/conf/proxy/auth.$DOMAIN.config" <<ZORAXY_AUTH
|
||||||
|
{
|
||||||
|
"ProxyType": 1,
|
||||||
|
"RootOrMatchingDomain": "auth.$DOMAIN",
|
||||||
|
"ActiveOrigins": [{
|
||||||
|
"OriginIpOrDomain": "127.0.0.1:5489",
|
||||||
|
"RequireTLS": false,
|
||||||
|
"Weight": 1,
|
||||||
|
"MaxConn": 0
|
||||||
|
}],
|
||||||
|
"Disabled": false,
|
||||||
|
"AuthenticationProvider": {"AuthMethod": 0}
|
||||||
|
}
|
||||||
|
ZORAXY_AUTH
|
||||||
|
|
||||||
# Lock proxy configs so Zoraxy cannot rewrite them (clearing OriginIpOrDomain)
|
# Lock proxy configs so Zoraxy cannot rewrite them (clearing OriginIpOrDomain)
|
||||||
echo "[*] Locking proxy configs (chattr +i)..."
|
echo "[*] Locking proxy configs (chattr +i)..."
|
||||||
chattr -R +i "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/dev/null || true
|
chattr -R +i "$TARGET_DIR/config/zoraxy/conf/proxy/" 2>/dev/null || true
|
||||||
|
|
@ -369,6 +407,16 @@ EOF
|
||||||
echo " Enabling auto-renew..."
|
echo " Enabling auto-renew..."
|
||||||
zoraxy_post "/api/acme/autoRenew/enable" "enable=true" > /dev/null
|
zoraxy_post "/api/acme/autoRenew/enable" "enable=true" > /dev/null
|
||||||
echo " [OK] LE auto-renew enabled"
|
echo " [OK] LE auto-renew enabled"
|
||||||
|
|
||||||
|
# Configure Zoraxy Auth gateway in BoltDB
|
||||||
|
echo " Configuring Zoraxy Auth gateway..."
|
||||||
|
SYS_DB="$TARGET_DIR/data/zoraxy/sys.db"
|
||||||
|
if [ -f "$SYS_DB" ] && [ -f "$TOOL_BIN" ]; then
|
||||||
|
"$TOOL_BIN" db \
|
||||||
|
--db "$SYS_DB" \
|
||||||
|
--set "zorxauth:options:{\"enable_auth_gateway\":true,\"sso_redirect_url\":\"https://app.${DOMAIN}/\"}" 2>&1 || true
|
||||||
|
echo " [OK] Zoraxy Auth gateway configured"
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo " [WARN] Login failed: $LOGIN_RESULT"
|
echo " [WARN] Login failed: $LOGIN_RESULT"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
11
go.mod
11
go.mod
|
|
@ -2,9 +2,14 @@ module nextworkspace
|
||||||
|
|
||||||
go 1.25.0
|
go 1.25.0
|
||||||
|
|
||||||
require gopkg.in/yaml.v3 v3.0.1
|
require (
|
||||||
|
go.etcd.io/bbolt v1.5.0
|
||||||
|
golang.org/x/crypto v0.53.0
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
go.etcd.io/bbolt v1.5.0 // indirect
|
golang.org/x/net v0.55.0 // indirect
|
||||||
golang.org/x/sys v0.45.0 // indirect
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
)
|
)
|
||||||
|
|
|
||||||
18
go.sum
18
go.sum
|
|
@ -1,7 +1,21 @@
|
||||||
|
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/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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
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/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
|
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=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
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=
|
||||||
|
|
|
||||||
196
main.go
196
main.go
|
|
@ -1,9 +1,6 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"log"
|
"log"
|
||||||
|
|
@ -13,7 +10,6 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
@ -47,46 +43,6 @@ type AppsFile struct {
|
||||||
Apps []AppEntry `yaml:"apps"`
|
Apps []AppEntry `yaml:"apps"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Session key (derived from master password) ---
|
|
||||||
|
|
||||||
var sessionKey []byte
|
|
||||||
|
|
||||||
func initSessionKey(password string) {
|
|
||||||
hash := sha256.Sum256([]byte(password))
|
|
||||||
sessionKey = hash[:]
|
|
||||||
}
|
|
||||||
|
|
||||||
func createSessionToken(username string) string {
|
|
||||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
|
||||||
data := fmt.Sprintf("%s:%d", username, expires)
|
|
||||||
mac := hmac.New(sha256.New, sessionKey)
|
|
||||||
mac.Write([]byte(data))
|
|
||||||
sig := hex.EncodeToString(mac.Sum(nil))
|
|
||||||
return hex.EncodeToString([]byte(data)) + "." + sig
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateSessionToken(token string) (string, bool) {
|
|
||||||
parts := strings.SplitN(token, ".", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
data, err := hex.DecodeString(parts[0])
|
|
||||||
if err != nil {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
mac := hmac.New(sha256.New, sessionKey)
|
|
||||||
mac.Write(data)
|
|
||||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
|
||||||
if !hmac.Equal([]byte(parts[1]), []byte(expectedSig)) {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
pieces := strings.SplitN(string(data), ":", 2)
|
|
||||||
if len(pieces) != 2 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return pieces[0], true
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Config loading ---
|
// --- Config loading ---
|
||||||
|
|
||||||
func loadConfig(configDir string) (*Config, error) {
|
func loadConfig(configDir string) (*Config, error) {
|
||||||
|
|
@ -118,21 +74,16 @@ func loadApps(configDir string) ([]AppEntry, error) {
|
||||||
return appsFile.Apps, nil
|
return appsFile.Apps, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Auth middleware ---
|
// --- Auth middleware (trusts X-Forwarded-User from Zoraxy 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) {
|
||||||
cookie, err := r.Cookie("session")
|
user := r.Header.Get("X-Forwarded-User")
|
||||||
if err != nil || cookie.Value == "" {
|
if user == "" {
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
username, valid := validateSessionToken(cookie.Value)
|
r.Header.Set("X-Auth-User", user)
|
||||||
if !valid {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
r.Header.Set("X-Forwarded-User", username)
|
|
||||||
next(w, r)
|
next(w, r)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -144,55 +95,6 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
fmt.Fprint(w, "OK")
|
fmt.Fprint(w, "OK")
|
||||||
}
|
}
|
||||||
|
|
||||||
func loginFormHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
tmpl := template.Must(template.New("login").Parse(loginHTML))
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
tmpl.Execute(w, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func loginAuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
username := r.FormValue("username")
|
|
||||||
password := r.FormValue("password")
|
|
||||||
|
|
||||||
adminUser := os.Getenv("ADMIN_USERNAME")
|
|
||||||
adminPass := os.Getenv("ADMIN_PASSWORD")
|
|
||||||
|
|
||||||
if username == "" || password == "" || username != adminUser || password != adminPass {
|
|
||||||
tmpl := template.Must(template.New("login").Parse(loginHTML))
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
tmpl.Execute(w, map[string]string{"Error": "Invalid username or password"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token := createSessionToken(username)
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "session",
|
|
||||||
Value: token,
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
SameSite: http.SameSiteLaxMode,
|
|
||||||
MaxAge: 86400,
|
|
||||||
})
|
|
||||||
http.Redirect(w, r, "/home/", http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func logoutHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: "session",
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
HttpOnly: true,
|
|
||||||
MaxAge: -1,
|
|
||||||
})
|
|
||||||
http.Redirect(w, r, "/login", http.StatusFound)
|
|
||||||
}
|
|
||||||
|
|
||||||
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
func launcherHandler(cfg *Config, apps []AppEntry) http.HandlerFunc {
|
||||||
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
|
tmpl := template.Must(template.New("launcher").Parse(launcherHTML))
|
||||||
|
|
||||||
|
|
@ -228,73 +130,6 @@ func proxyToUpstream(upstream string) http.HandlerFunc {
|
||||||
|
|
||||||
// --- Templates ---
|
// --- Templates ---
|
||||||
|
|
||||||
const loginHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Login — NextWorkspace</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;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
.login-box {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 2.5rem;
|
|
||||||
width: 360px;
|
|
||||||
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
h1 { font-size: 1.5rem; margin-bottom: 0.25rem; text-align: center; }
|
|
||||||
p.sub { color: #718096; text-align: center; margin-bottom: 1.5rem; font-size: 0.9rem; }
|
|
||||||
.error { background: #fed7d7; color: #c53030; padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.85rem; }
|
|
||||||
label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.35rem; color: #4a5568; }
|
|
||||||
input[type="text"], input[type="password"] {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.65rem 0.75rem;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
input:focus { border-color: #1a1a2e; }
|
|
||||||
button {
|
|
||||||
width: 100%;
|
|
||||||
padding: 0.7rem;
|
|
||||||
background: #1a1a2e;
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
button:hover { background: #2d3748; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="login-box">
|
|
||||||
<h1>NextWorkspace</h1>
|
|
||||||
<p class="sub">Sign in to your workspace</p>
|
|
||||||
{{if .Error}}<div class="error">{{.Error}}</div>{{end}}
|
|
||||||
<form method="POST" action="/login/auth">
|
|
||||||
<label for="username">Username</label>
|
|
||||||
<input type="text" id="username" name="username" required autofocus>
|
|
||||||
<label for="password">Password</label>
|
|
||||||
<input type="password" id="password" name="password" required>
|
|
||||||
<button type="submit">Sign In</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`
|
|
||||||
|
|
||||||
const landingPageHTML = `<!DOCTYPE html>
|
const landingPageHTML = `<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
|
|
@ -397,7 +232,6 @@ const launcherHTML = `<!DOCTYPE html>
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
position: relative;
|
|
||||||
}
|
}
|
||||||
header h1 { font-size: 2rem; margin-bottom: 0.25rem; }
|
header h1 { font-size: 2rem; margin-bottom: 0.25rem; }
|
||||||
header p { color: #a0aec0; font-size: 1rem; }
|
header p { color: #a0aec0; font-size: 1rem; }
|
||||||
|
|
@ -454,7 +288,7 @@ const launcherHTML = `<!DOCTYPE html>
|
||||||
<p>{{.Description}}</p>
|
<p>{{.Description}}</p>
|
||||||
</header>
|
</header>
|
||||||
<div class="user-banner">
|
<div class="user-banner">
|
||||||
Welcome, {{.User}} · <a href="/home/logout">Logout</a>
|
Welcome, {{.User}} · <a href="https://auth.nextwks.eu/logout">Logout</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
{{range .Apps}}
|
{{range .Apps}}
|
||||||
|
|
@ -480,12 +314,6 @@ func main() {
|
||||||
configDir = "/opt/nextworkspace/config/nextworkspace"
|
configDir = "/opt/nextworkspace/config/nextworkspace"
|
||||||
}
|
}
|
||||||
|
|
||||||
adminPassword := os.Getenv("ADMIN_PASSWORD")
|
|
||||||
if adminPassword == "" {
|
|
||||||
log.Fatal("ADMIN_PASSWORD environment variable is required")
|
|
||||||
}
|
|
||||||
initSessionKey(adminPassword)
|
|
||||||
|
|
||||||
cfg, 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)
|
||||||
|
|
@ -500,27 +328,23 @@ func main() {
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
// Unprotected paths
|
// Public paths
|
||||||
mux.HandleFunc("/health", healthHandler)
|
mux.HandleFunc("/health", healthHandler)
|
||||||
mux.HandleFunc("/login", loginFormHandler)
|
|
||||||
mux.HandleFunc("/login/auth", loginAuthHandler)
|
|
||||||
|
|
||||||
// Protected: launcher
|
// Protected: launcher
|
||||||
mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps)))
|
mux.Handle("/home/", authMiddleware(launcherHandler(cfg, apps)))
|
||||||
mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps)))
|
mux.Handle("/home", authMiddleware(launcherHandler(cfg, apps)))
|
||||||
mux.HandleFunc("/home/logout", logoutHandler)
|
|
||||||
|
|
||||||
// Protected: upstream app proxies
|
// Protected: upstream app proxies
|
||||||
for _, app := range apps {
|
for _, app := range apps {
|
||||||
if app.Path != "" && app.Upstream != "" {
|
if app.Path != "" && app.Upstream != "" {
|
||||||
appPath := app.Path
|
|
||||||
proxyHandler := authMiddleware(proxyToUpstream(app.Upstream))
|
proxyHandler := authMiddleware(proxyToUpstream(app.Upstream))
|
||||||
mux.Handle(appPath+"/", proxyHandler)
|
mux.Handle(app.Path+"/", proxyHandler)
|
||||||
mux.Handle(appPath, proxyHandler)
|
mux.Handle(app.Path, proxyHandler)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default: serve landing page for www subdomain, redirect to launcher otherwise
|
// Default: serve landing page for www, redirect to launcher otherwise
|
||||||
domain := os.Getenv("DOMAIN")
|
domain := os.Getenv("DOMAIN")
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if strings.HasPrefix(r.Host, "www.") || (domain != "" && r.Host == "www."+domain) {
|
if strings.HasPrefix(r.Host, "www.") || (domain != "" && r.Host == "www."+domain) {
|
||||||
|
|
|
||||||
315
tools/nextwks-tool/main.go
Normal file
315
tools/nextwks-tool/main.go
Normal file
|
|
@ -0,0 +1,315 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.etcd.io/bbolt"
|
||||||
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if len(os.Args) < 2 {
|
||||||
|
log.Fatalf("Usage: %s <cert|db> [flags]", os.Args[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
switch os.Args[1] {
|
||||||
|
case "cert":
|
||||||
|
runCert(os.Args[2:])
|
||||||
|
case "db":
|
||||||
|
runDB(os.Args[2:])
|
||||||
|
default:
|
||||||
|
log.Fatalf("Unknown command: %s (use cert or db)", os.Args[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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/certs", "Backup directory for certs")
|
||||||
|
deployDir := fs.String("deploy-dir", "", "Optional deploy directory to copy certs to")
|
||||||
|
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 certs in backup
|
||||||
|
needIssue := false
|
||||||
|
for _, domain := range domains {
|
||||||
|
certFile := filepath.Join(*backupDir, domain, "fullchain.pem")
|
||||||
|
keyFile := filepath.Join(*backupDir, domain, "privkey.pem")
|
||||||
|
if !fileExists(certFile) || !fileExists(keyFile) {
|
||||||
|
needIssue = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Check expiry
|
||||||
|
if isCertExpired(certFile, 7*24*time.Hour) {
|
||||||
|
log.Printf("[INFO] Cert for %s expires soon or is invalid, reissuing", domain)
|
||||||
|
needIssue = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needIssue {
|
||||||
|
log.Printf("[INFO] Requesting LE certificates for %v...", domains)
|
||||||
|
if err := obtainCerts(domains, *email, *backupDir); err != nil {
|
||||||
|
log.Printf("[WARN] LE cert issuance failed: %v", err)
|
||||||
|
log.Printf("[INFO] Generating self-signed fallback certs")
|
||||||
|
if err := generateSelfSigned(domains, *backupDir); err != nil {
|
||||||
|
log.Printf("[WARN] Self-signed fallback also failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Printf("[OK] All certificates found in backup, deploying")
|
||||||
|
for _, domain := range domains {
|
||||||
|
certFile := filepath.Join(*backupDir, domain, "fullchain.pem")
|
||||||
|
expiry := getCertExpiry(certFile)
|
||||||
|
log.Printf(" %s — expires %s", domain, expiry.Format(time.RFC3339))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy to deploy-dir if specified
|
||||||
|
if *deployDir != "" {
|
||||||
|
if err := os.MkdirAll(*deployDir, 0755); err != nil {
|
||||||
|
log.Printf("[WARN] Failed to create deploy dir: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, domain := range domains {
|
||||||
|
srcCert := filepath.Join(*backupDir, domain, "fullchain.pem")
|
||||||
|
srcKey := filepath.Join(*backupDir, domain, "privkey.pem")
|
||||||
|
dstCert := filepath.Join(*deployDir, domain+".crt")
|
||||||
|
dstKey := filepath.Join(*deployDir, domain+".key")
|
||||||
|
|
||||||
|
if fileExists(srcCert) && fileExists(srcKey) {
|
||||||
|
copyFile(srcCert, dstCert)
|
||||||
|
copyFile(srcKey, dstKey)
|
||||||
|
log.Printf("[OK] Deployed cert for %s", domain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func obtainCerts(domains []string, email, backupDir string) error {
|
||||||
|
m := &autocert.Manager{
|
||||||
|
Prompt: autocert.AcceptTOS,
|
||||||
|
Email: email,
|
||||||
|
Cache: autocert.DirCache(backupDir),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to obtain certs by starting a temporary HTTP server for challenge
|
||||||
|
ln, err := net.Listen("tcp", ":80")
|
||||||
|
if err != nil {
|
||||||
|
// Port 80 busy — try to use the autocert client directly without HTTP server
|
||||||
|
log.Printf("[WARN] Port 80 not available (%v), trying direct ACME...", err)
|
||||||
|
return obtainCertsDirect(domains, email, backupDir)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
// Serve HTTP-01 challenge handler
|
||||||
|
srv := &http.Server{
|
||||||
|
Handler: m.HTTPHandler(nil),
|
||||||
|
Addr: ":80",
|
||||||
|
}
|
||||||
|
go srv.Serve(ln)
|
||||||
|
|
||||||
|
// Give LE a moment to validate
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
|
for _, domain := range domains {
|
||||||
|
hello := &tls.ClientHelloInfo{
|
||||||
|
ServerName: domain,
|
||||||
|
}
|
||||||
|
cert, err := m.GetCertificate(hello)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WARN] Failed to get cert for %s: %v", domain, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Store the cert to backup
|
||||||
|
domainDir := filepath.Join(backupDir, domain)
|
||||||
|
os.MkdirAll(domainDir, 0755)
|
||||||
|
|
||||||
|
for _, c := range cert.Certificate {
|
||||||
|
block := &pem.Block{Type: "CERTIFICATE", Bytes: c}
|
||||||
|
f, err := os.OpenFile(filepath.Join(domainDir, "fullchain.pem"),
|
||||||
|
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing cert: %w", err)
|
||||||
|
}
|
||||||
|
pem.Encode(f, block)
|
||||||
|
f.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and save private key
|
||||||
|
if key, ok := cert.PrivateKey.(*rsa.PrivateKey); ok {
|
||||||
|
keyBlock := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
|
||||||
|
os.WriteFile(filepath.Join(domainDir, "privkey.pem"),
|
||||||
|
pem.EncodeToMemory(keyBlock), 0600)
|
||||||
|
}
|
||||||
|
log.Printf("[OK] Certificate obtained for %s", domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func obtainCertsDirect(domains []string, email, backupDir string) error {
|
||||||
|
// Direct ACME without port 80 — will likely fail but try anyway
|
||||||
|
// This is a simplified fallback
|
||||||
|
return fmt.Errorf("port 80 required for HTTP-01 challenge")
|
||||||
|
}
|
||||||
|
|
||||||
|
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},
|
||||||
|
}
|
||||||
|
if len(domains) > 1 {
|
||||||
|
tmpl.DNSNames = domains
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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 fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue