package main import ( "context" "fmt" "log" "net/http" "os" "path/filepath" "strings" "github.com/caddyserver/certmagic" "gopkg.in/yaml.v3" ) // --- 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) } 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 (e.g. "nextwks.eu") domainWithDot := "." + domain if host == domain { return "" } if !strings.HasSuffix(host, domainWithDot) { return "" } return strings.TrimSuffix(host, domainWithDot) } // --- Handlers --- 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, `
Coming soon.
`) } func unknownSubdomainHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(http.StatusNotFound) fmt.Fprint(w, `No application registered for this subdomain.
`) } // --- 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", appName, subdomain) handlers[subdomain] = unknownSubdomainHandler } } // Main router (for HTTPS or non-TLS mode) 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) 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"), } // Collect domains from proxies.yaml only (no bare domain, no wildcard) // Each subdomain gets its own certificate independently. var domains []string for subdomain := range proxies.Apps { domains = append(domains, subdomain+"."+cfg.Domain) } // Obtain and manage certificates magic := certmagic.NewDefault() if err := magic.ManageSync(context.Background(), domains); err != nil { log.Fatalf("Failed to manage certificates: %v", err) } // Extract ACME issuer for http-01 challenge handling var acmeIssuer *certmagic.ACMEIssuer if len(magic.Issuers) > 0 { acmeIssuer, _ = magic.Issuers[0].(*certmagic.ACMEIssuer) } // HTTP handler on :80 // - Known subdomains → redirect to HTTPS // - Unknown subdomains → error directly on HTTP (no redirect to broken HTTPS) // - ACME challenge paths → handled by certmagic before reaching us var httpHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { subdomain := extractSubdomain(r.Host, cfg.Domain) if _, ok := handlers[subdomain]; ok { 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)) } }