From cd15d294a1b34f35f75afb3af4e57aba41af1528 Mon Sep 17 00:00:00 2001 From: cclohmar Date: Mon, 6 Jul 2026 15:25:21 +0100 Subject: [PATCH] feat: subdomain router with certmagic integration --- configs/core/config.yaml | 9 ++ configs/core/proxies.yaml | 5 ++ deploy.sh | 36 ++++---- go.mod | 26 +++++- go.sum | 60 +++++++++++++ main.go | 185 ++++++++++++++++++++++++++++++++++++-- 6 files changed, 296 insertions(+), 25 deletions(-) create mode 100644 configs/core/config.yaml create mode 100644 configs/core/proxies.yaml create mode 100644 go.sum diff --git a/configs/core/config.yaml b/configs/core/config.yaml new file mode 100644 index 0000000..b6d5585 --- /dev/null +++ b/configs/core/config.yaml @@ -0,0 +1,9 @@ +server: + port: 80 + host: "0.0.0.0" + +tls: + enabled: false # Flip to true for Let's Encrypt HTTPS + email: "" # Required when enabled (e.g. admin@nextwks.eu) + +domain: "nextwks.eu" # Base domain for subdomain routing + certs diff --git a/configs/core/proxies.yaml b/configs/core/proxies.yaml new file mode 100644 index 0000000..dd8a413 --- /dev/null +++ b/configs/core/proxies.yaml @@ -0,0 +1,5 @@ +# Subdomain → registered app mapping +# app : app handler name (reserved) +# future: mail, drive, calendar, etc. +apps: + app: core diff --git a/deploy.sh b/deploy.sh index f6360a6..2f5f5a0 100755 --- a/deploy.sh +++ b/deploy.sh @@ -6,7 +6,7 @@ HEALTH_CHECK_RETRIES=10 HEALTH_CHECK_INTERVAL=2 REPO_DIR="/opt/NextWks" -TARGET_DIR="/opt/nextworkspace" +TARGET_DIR="/opt/workspace" SERVICE_NAME="nextworkspace" BINARY_NAME="nextworkspace" @@ -14,36 +14,40 @@ echo "=== NextWorkspace Deploy ===" # 1. Navigate to repo and pull latest cd "$REPO_DIR" -echo "[1/8] Pulling latest code..." +echo "[1/9] Pulling latest code..." git pull # 2. Build -echo "[2/8] Building binary..." +echo "[2/9] Building binary..." export PATH=$PATH:/usr/local/go/bin go build -o "$BINARY_NAME" . # 3. Remove old deployment -echo "[3/8] Removing old deployment..." +echo "[3/9] Removing old deployment..." rm -rf "$TARGET_DIR" -# 4. Create target directories -echo "[4/8] Creating target directories..." -mkdir -p "$TARGET_DIR/app/data" -mkdir -p "$TARGET_DIR/app/static" +# 4. Create target directory structure +echo "[4/9] Creating target directories..." +mkdir -p "$TARGET_DIR/configs/core/certs" # 5. Copy binary -echo "[5/8] Copying binary..." +echo "[5/9] Copying binary..." cp "$BINARY_NAME" "$TARGET_DIR/$BINARY_NAME" -# 6. Write systemd service -echo "[6/8] Writing systemd service..." +# 6. Copy config files +echo "[6/9] Copying config files..." +cp configs/core/config.yaml "$TARGET_DIR/configs/core/config.yaml" +cp configs/core/proxies.yaml "$TARGET_DIR/configs/core/proxies.yaml" + +# 7. Write systemd service +echo "[7/9] Writing systemd service..." cat > /etc/systemd/system/$SERVICE_NAME.service < /dev/null 2>&1; then echo "[OK] NextWorkspace is serving on http://localhost:80/" diff --git a/go.mod b/go.mod index 00495a1..e8e0c7d 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,27 @@ module nextworkspace -go 1.22 +go 1.25.0 + +require ( + 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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5891461 --- /dev/null +++ b/go.sum @@ -0,0 +1,60 @@ +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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 39e29ba..15d5cb3 100644 --- a/main.go +++ b/main.go @@ -1,22 +1,191 @@ package main import ( + "context" + "crypto/tls" "fmt" "log" "net/http" "os" + "path/filepath" + "strings" + + "github.com/caddyserver/certmagic" + "gopkg.in/yaml.v3" ) -func main() { - port := os.Getenv("PORT") - if port == "" { - port = "8080" +// --- Config types --- + +type Config struct { + Server ServerConfig `yaml:"server"` + TLS TLSConfig `yaml:"tls"` + Domain string `yaml:"domain"` +} + +type ServerConfig struct { + Port int `yaml:"port"` + Host string `yaml:"host"` +} + +type TLSConfig struct { + Enabled bool `yaml:"enabled"` + Email string `yaml:"email"` +} + +type Proxies struct { + Apps map[string]string `yaml:"apps"` +} + +// --- Config loading --- + +func loadConfig(configDir string) (*Config, *Proxies, error) { + configPath := filepath.Join(configDir, "config.yaml") + proxiesPath := filepath.Join(configDir, "proxies.yaml") + + configData, err := os.ReadFile(configPath) + if err != nil { + return nil, nil, fmt.Errorf("reading config: %w", err) + } + proxiesData, err := os.ReadFile(proxiesPath) + if err != nil { + return nil, nil, fmt.Errorf("reading proxies: %w", err) } - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - fmt.Fprintf(w, "Hello World from NextWorkspace!") + var cfg Config + if err := yaml.Unmarshal(configData, &cfg); err != nil { + return nil, nil, fmt.Errorf("parsing config: %w", err) + } + var proxies Proxies + if err := yaml.Unmarshal(proxiesData, &proxies); err != nil { + return nil, nil, fmt.Errorf("parsing proxies: %w", err) + } + + return &cfg, &proxies, nil +} + +// --- 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] + } + + // Bare domain — no subdomain + domainWithDot := "." + domain + if host == domain { + return "" + } + if !strings.HasSuffix(host, domainWithDot) { + return "" + } + + return strings.TrimSuffix(host, domainWithDot) +} + +// --- Handlers --- + +func boilerplateHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ` + + + + NextWorkspace + + +

NextWorkspace

+

The Self-Hosted Workspace for Startups

+ +`) +} + +func coreAppHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, ` + + + + NextWorkspace — App + + +

NextWorkspace — Your Workspace, Your Server

+

Coming soon.

+ +`) +} + +// --- Main --- + +func main() { + configDir := os.Getenv("CONFIG_DIR") + if configDir == "" { + configDir = "/opt/workspace/configs/core" + } + + cfg, proxies, err := loadConfig(configDir) + if err != nil { + log.Fatalf("Failed to load config: %v", err) + } + + // Build subdomain -> handler map + handlers := make(map[string]http.HandlerFunc) + for subdomain, appName := range proxies.Apps { + switch appName { + case "core": + handlers[subdomain] = coreAppHandler + default: + log.Printf("Warning: unknown app %q for subdomain %q, using boilerplate", appName, subdomain) + handlers[subdomain] = boilerplateHandler + } + } + + // Main router + 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 + } + boilerplateHandler(w, r) }) - log.Printf("NextWorkspace listening on :%s", port) - log.Fatal(http.ListenAndServe(":"+port, nil)) + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + + if cfg.TLS.Enabled { + log.Println("NextWorkspace starting with TLS (certmagic)...") + + certmagic.DefaultACME.Agreed = true + certmagic.DefaultACME.Email = cfg.TLS.Email + certmagic.Default.Storage = &certmagic.FileStorage{ + Path: filepath.Join(configDir, "certs"), + } + + var domains []string + for subdomain := range proxies.Apps { + domains = append(domains, subdomain+"."+cfg.Domain) + } + domains = append(domains, cfg.Domain) + + magic := certmagic.NewDefault() + if err := magic.ManageSync(context.Background(), domains); err != nil { + log.Fatalf("Failed to manage certificates: %v", err) + } + + tlsConfig := magic.TLSConfig() + listener, err := tls.Listen("tcp", addr, tlsConfig) + if err != nil { + log.Fatalf("Failed to start TLS listener: %v", err) + } + + log.Printf("NextWorkspace listening on %s (TLS)", addr) + log.Fatal(http.Serve(listener, router)) + } else { + log.Printf("NextWorkspace listening on %s (no TLS)", addr) + log.Fatal(http.ListenAndServe(addr, router)) + } }