package main import ( "context" "crypto/tls" "encoding/json" "log" "net/http" "os" "os/signal" "path/filepath" "strconv" "strings" "syscall" "time" "git.send.nrw/sendnrw/decent-websrv/internal/beacon" "git.send.nrw/sendnrw/decent-websrv/internal/cas" "git.send.nrw/sendnrw/decent-websrv/internal/mesh" "git.send.nrw/sendnrw/decent-websrv/internal/security" ) // --- Config from ENV --- type Config struct { Addr string // public listener, e.g. :8080 TLSCertFile string // optional for public TLS TLSKeyFile string MeshAddr string // private mesh listener, e.g. :8443 TLSCAFile string // CA for mTLS client verify (mesh) BaseURL string // public base URL of THIS node (for info pages) MeshURL string // mesh base URL of THIS node (used by peers) NodeID string // unique ID AuthMode string // "ed25519" | "hmac" SigPrivPath string // ed25519 private key (this node) SigPubDir string // directory with .pub (peer pubkeys) MeshSecret string // hmac secret (if AuthMode=hmac) ConfigDir string // state dir (CAS, nonces) MaxBodyBytes int64 // e.g. 8<<20 BeaconMode bool // run introducer BeaconAddr string // introducer listener (public) BeaconURL string // where to register/poll (client) BeaconToken string // optional shared token for register/poll } func env(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } func envBool(k string) bool { v := strings.ToLower(os.Getenv(k)) return v == "1" || v == "true" || v == "yes" } func envInt64(k string, def int64) int64 { if v := os.Getenv(k); v != "" { if x, err := strconv.ParseInt(v, 10, 64); err == nil { return x } } return def } func loadConfig() Config { cfg := Config{ Addr: env("ADDR", ":8080"), TLSCertFile: env("TLS_CERT", ""), TLSKeyFile: env("TLS_KEY", ""), MeshAddr: env("MESH_ADDR", ":8443"), TLSCAFile: env("TLS_CA", ""), BaseURL: env("BASE_URL", "http://127.0.0.1:8080"), MeshURL: env("MESH_URL", "https://127.0.0.1:8443"), NodeID: env("NODE_ID", "node-A"), AuthMode: env("AUTH_MODE", "ed25519"), SigPrivPath: env("SIG_PRIV", ""), SigPubDir: env("SIG_PUB_DIR", "./peers"), MeshSecret: env("MESH_SECRET", ""), ConfigDir: env("CONFIG_DIR", "./data"), MaxBodyBytes: envInt64("MAX_BODY_BYTES", 8<<20), BeaconMode: envBool("BEACON_MODE"), BeaconAddr: env("BEACON_ADDR", ":9443"), BeaconURL: env("BEACON_URL", ""), BeaconToken: env("BEACON_TOKEN", ""), } return cfg } func main() { cfg := loadConfig() _ = os.MkdirAll(cfg.ConfigDir, 0o755) casStore := cas.New(filepath.Join(cfg.ConfigDir, "cas")) nonceStore := security.NewNonceStore(2 * time.Minute) // --- Mesh auth setup --- verifier, _ := security.NewMeshAuth(cfg.AuthMode, cfg.SigPrivPath, cfg.SigPubDir, cfg.MeshSecret) // --- Catalog & Rendezvous --- catalog := mesh.NewCatalog() ring := mesh.NewRendezvous() self := mesh.NodeInfo{NodeID: cfg.NodeID, PublicURL: cfg.BaseURL, MeshURL: cfg.MeshURL} catalog.Set(self) updateRing := func() { ring.Set(catalog.IDs()) } updateRing() // --- Beacon (introducer) --- if cfg.BeaconMode { go runBeaconServer(cfg) } // --- Beacon client loops --- if cfg.BeaconURL != "" { bcl := &beacon.Client{URL: cfg.BeaconURL, Token: cfg.BeaconToken, HTTP: &http.Client{Timeout: 5 * time.Second}} bcl.LoopRegister(self) go func() { for { peers := bcl.PollPeers() catalog.Replace(peers) updateRing() time.Sleep(20 * time.Second) } }() } // --- Public handler --- casHTTP := &cas.HTTP{S: casStore} fetcher := &cas.MeshFetcher{Ring: ring, HTTP: &http.Client{Timeout: 10 * time.Second}, Verifier: verifier, Self: self} casHTTP.Fetcher = fetcher publicMux := http.NewServeMux() publicMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]any{ "node": self, "peers": catalog.All(), "hint": "GET /c/ to fetch content; use mesh PUT to write", }) }) publicMux.HandleFunc("/c/", casHTTP.Serve) // --- Mesh handler (private) --- meshMux := http.NewServeMux() meshMux.HandleFunc("/_mesh/health", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) meshMux.HandleFunc("/_mesh/cas/", casHTTP.MeshGet) meshMux.HandleFunc("/_mesh/cas/put", casHTTP.MeshPut) meshHandler := security.MeshAuthMiddleware(verifier, nonceStore, cfg.MaxBodyBytes)(meshMux) // --- Rate limiter & global security wrappers --- rl := security.NewLimiter(5, 20) // 5rps per IP+path, burst 20 publicHandler := security.SecurityHeaders(security.MaxBody(cfg.MaxBodyBytes, rl.Middleware(publicMux))) // --- Servers --- ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() publicSrv := &http.Server{ Addr: cfg.Addr, Handler: publicHandler, ReadHeaderTimeout: 5 * time.Second, WriteTimeout: 30 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 8 << 10, } meshSrv := &http.Server{ Addr: cfg.MeshAddr, Handler: meshHandler, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 8 << 10, } if cfg.TLSCAFile != "" { meshSrv.TLSConfig = &tls.Config{ MinVersion: tls.VersionTLS12, ClientAuth: tls.RequireAndVerifyClientCert, ClientCAs: security.LoadCertPool(cfg.TLSCAFile), } } go func() { log.Println("public listening on", cfg.Addr) if cfg.TLSCertFile != "" { _ = publicSrv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile) } else { _ = publicSrv.ListenAndServe() } }() go func() { log.Println("mesh listening on", cfg.MeshAddr) if cfg.TLSCertFile != "" { _ = meshSrv.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile) } else { _ = meshSrv.ListenAndServe() } }() <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() _ = publicSrv.Shutdown(shutdownCtx) _ = meshSrv.Shutdown(shutdownCtx) } func runBeaconServer(cfg Config) { s := beacon.NewServer(cfg.BeaconAddr, cfg.BeaconToken) log.Println("beacon listening on", cfg.BeaconAddr) if cfg.TLSCertFile != "" { _ = s.ListenAndServeTLS(cfg.TLSCertFile, cfg.TLSKeyFile) } else { _ = s.ListenAndServe() } }