diff --git a/Dockerfile b/Dockerfile index ee50e6f..7d7b2d2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,8 @@ FROM golang:1.25-alpine AS builder WORKDIR /app -COPY main.go webhook.go config.go . -RUN go mod init varde && \ - go mod tidy && \ - CGO_ENABLED=0 GOOS=linux go build -o server main.go webhook.go config.go +COPY . . +RUN go mod tidy && \ + CGO_ENABLED=0 GOOS=linux go build -o server . FROM scratch COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ diff --git a/config.go b/config.go index 73af638..6cad040 100644 --- a/config.go +++ b/config.go @@ -3,34 +3,46 @@ package main import ( "flag" "os" + + "varde/internal/ntfy" + "varde/modules/keyserver" + "varde/modules/webhook" + "varde/modules/webserver" ) type Config struct { - ContentDir string - KeysDir string - Port string - RepoURL string - RepoToken string - WebhookSecret string - NtfyURL string - NtfyTopic string - EnableWebhook bool + Port string + + Webserver webserver.Config + Keyserver keyserver.Config + Webhook webhook.Config + Ntfy ntfy.Config } func loadConfig() *Config { cfg := &Config{} - flag.StringVar(&cfg.ContentDir, "content-dir", envOrDefault("CONTENT_DIR", "./content"), "directory to serve as content") - flag.StringVar(&cfg.KeysDir, "keys-dir", envOrDefault("KEYS_DIR", "./keys"), "directory to serve as WKD keys") flag.StringVar(&cfg.Port, "port", envOrDefault("PORT", "80"), "port to listen on") - flag.StringVar(&cfg.RepoURL, "repo-url", envOrDefault("REPO_URL", ""), "git repo URL for webhook auto-update") - flag.StringVar(&cfg.RepoToken, "repo-token", envOrDefault("REPO_TOKEN", ""), "git repo access token") - flag.StringVar(&cfg.WebhookSecret, "webhook-secret", envOrDefault("WEBHOOK_SECRET", ""), "HMAC secret for webhook validation") - flag.StringVar(&cfg.NtfyURL, "ntfy-url", envOrDefault("NTFY_URL", ""), "ntfy server URL for failure notifications") - flag.StringVar(&cfg.NtfyTopic, "ntfy-topic", envOrDefault("NTFY_TOPIC", ""), "ntfy topic for failure notifications") - flag.BoolVar(&cfg.EnableWebhook, "webhook", envOrDefaultBool("ENABLE_WEBHOOK", false), "enable git-webhook auto-update feature") + + flag.BoolVar(&cfg.Webserver.Enabled, "enable-webserver", envOrDefaultBool("ENABLE_WEBSERVER", false), "enable static webserver module") + flag.StringVar(&cfg.Webserver.ContentDir, "content-dir", envOrDefault("CONTENT_DIR", "./content"), "directory to serve as content") + + flag.BoolVar(&cfg.Keyserver.Enabled, "enable-keyserver", envOrDefaultBool("ENABLE_KEYSERVER", false), "enable WKD keyserver module") + flag.StringVar(&cfg.Keyserver.KeysDir, "keys-dir", envOrDefault("KEYS_DIR", "./keys"), "directory to serve as WKD keys") + + flag.BoolVar(&cfg.Webhook.Enabled, "enable-webhook", envOrDefaultBool("ENABLE_WEBHOOK", false), "enable git-webhook auto-update module") + flag.StringVar(&cfg.Webhook.RepoURL, "repo-url", envOrDefault("REPO_URL", ""), "git repo URL for webhook auto-update") + flag.StringVar(&cfg.Webhook.RepoToken, "repo-token", envOrDefault("REPO_TOKEN", ""), "git repo access token") + flag.StringVar(&cfg.Webhook.WebhookSecret, "webhook-secret", envOrDefault("WEBHOOK_SECRET", ""), "HMAC secret for webhook validation") + + flag.StringVar(&cfg.Ntfy.URL, "ntfy-url", envOrDefault("NTFY_URL", ""), "ntfy server URL for failure notifications") + flag.StringVar(&cfg.Ntfy.Topic, "ntfy-topic", envOrDefault("NTFY_TOPIC", ""), "ntfy topic for failure notifications") flag.Parse() + + // webhook pulls into the same directory the webserver serves + cfg.Webhook.ContentDir = cfg.Webserver.ContentDir + return cfg } diff --git a/internal/middleware/middleware.go b/internal/middleware/middleware.go new file mode 100644 index 0000000..1af3a4a --- /dev/null +++ b/internal/middleware/middleware.go @@ -0,0 +1,52 @@ +package middleware + +import ( + "log" + "net/http" + "strings" + + "golang.org/x/time/rate" +) + +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + next.ServeHTTP(w, req) + }) +} + +func NoListing(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasSuffix(req.URL.Path, "/") { + http.NotFound(w, req) + return + } + next.ServeHTTP(w, req) + }) +} + +func WKDHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", "application/octet-stream") + w.Header().Set("Access-Control-Allow-Origin", "*") + next.ServeHTTP(w, req) + }) +} + +// RateLimit is parameterized by the limiter instance, so each module +// that needs rate limiting owns its own limiter rather than sharing +// a package-level global. +func RateLimit(limiter *rate.Limiter) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if !limiter.Allow() { + log.Println("rate limit exceeded") + w.WriteHeader(http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, req) + }) + } +} diff --git a/internal/ntfy/ntfy.go b/internal/ntfy/ntfy.go new file mode 100644 index 0000000..c04865a --- /dev/null +++ b/internal/ntfy/ntfy.go @@ -0,0 +1,45 @@ +package ntfy + +import ( + "bytes" + "fmt" + "log" + "net/http" + "time" +) + +type Config struct { + URL string + Topic string +} + +// Notify sends a failure notification. Silently does nothing if +// URL or Topic are unset, so callers don't need to check first. +func Notify(cfg Config, context string, err error) { + if cfg.URL == "" || cfg.Topic == "" { + return + } + + msg := fmt.Sprintf("%s: %v", context, err) + url := fmt.Sprintf("%s/%s", cfg.URL, cfg.Topic) + + req, reqErr := http.NewRequest("POST", url, bytes.NewBufferString(msg)) + if reqErr != nil { + log.Printf("ntfy request build failed: %v", reqErr) + return + } + req.Header.Set("Title", "Varde Notification") + req.Header.Set("Priority", "high") + + client := &http.Client{Timeout: 5 * time.Second} + resp, sendErr := client.Do(req) + if sendErr != nil { + log.Printf("ntfy notification failed: %v", sendErr) + return + } + defer resp.Body.Close() + + if resp.StatusCode >= 300 { + log.Printf("ntfy notification returned status %d", resp.StatusCode) + } +} diff --git a/main.go b/main.go index 7863f72..3d94c23 100644 --- a/main.go +++ b/main.go @@ -11,69 +11,83 @@ import ( "time" "github.com/gorilla/mux" + + "varde/internal/middleware" + "varde/modules/keyserver" + "varde/modules/webhook" + "varde/modules/webserver" ) func main() { cfg := loadConfig() - modules := []Module { - // Module werden hier registriert, sobald sie existieren, zB.: - // &webserver.Module{}, - // &keyserver.Module{}, - // &webhook.Module{}, - // &resourcemonitor.Module{}, - } - r := mux.NewRouter() - r.Use(securityHeaders) + r.Use(middleware.SecurityHeaders) + + r.HandleFunc("/api/healthz", healthHandler).Methods("GET", "HEAD") var active []Module - for _, m := range modules { - log.Printf("module %s: disabled, skipping", m.Name()) - continue + + // Registration order matters: gorilla/mux matches in the order + // routes are registered, not by specificity. Webhook and keyserver + // use specific paths and must come before webserver's catch-all "/". + if cfg.Webhook.Enabled { + m := webhook.New(cfg.Webhook, cfg.Ntfy) + m.RegisterRoutes(r) + active = append(active, m) + } + if cfg.Keyserver.Enabled { + m := keyserver.New(cfg.Keyserver) + m.RegisterRoutes(r) + active = append(active, m) + } + if cfg.Webserver.Enabled { + m := webserver.New(cfg.Webserver) + m.RegisterRoutes(r) // catch-all — must stay last + active = append(active, m) } ctx, cancel := context.WithCancel(context.Background()) var wg sync.WaitGroup - for :, m := range active { + for _, m := range active { wg.Add(1) go func(m Module) { defer wg.Done() - if err := m.Start(ctx, cfg); err != nil { - log.Printf("module %s: exited with error: %v" m.Name(), err) + if err := m.Start(ctx); err != nil { + log.Printf("module %s: start error: %v", m.Name(), err) } }(m) } - srv := &http.Server { - Addr: ":" + cfg.Port, + srv := &http.Server{ + Addr: ":" + cfg.Port, Handler: r, } go func() { - log.Printf("Listening on :%s, cfg.Port) + log.Printf("Listening on :%s", cfg.Port) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { log.Fatalf("server error: %v", err) } }() stop := make(chan os.Signal, 1) - signal.Notify(stop, syscall.SIGTERM, syscall.SIGINIT) + signal.Notify(stop, syscall.SIGTERM, syscall.SIGINT) <-stop - log.Pritln("Shutdown siganl received, draining connections...") - cancel() // signals all moduke Start() goroutines to stop via ctx + log.Println("Shutdown signal received, draining connections...") + cancel() shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) defer shutdownCancel() if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("graceful shutdown failed %v", err) + log.Printf("graceful shutdown failed: %v", err) } for _, m := range active { - if err := srv.Shutdown(shutdownCtx); err != nil { + if err := m.Stop(shutdownCtx); err != nil { log.Printf("module %s: stop error: %v", m.Name(), err) } } @@ -81,3 +95,8 @@ func main() { wg.Wait() log.Println("Server shut down cleanly") } + +func healthHandler(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) +} diff --git a/module.go b/module.go index d82d5be..f474010 100644 --- a/module.go +++ b/module.go @@ -2,38 +2,14 @@ package main import ( "context" - "net/http" "github.com/gorilla/mux" ) -// Module represents a self-contained feature of Varde. -// Each module is independently enabled/disabled via config and -// runs in the same process, sharing the HTTP router where relevant. +// Module represents a self-contained feature of varde. type Module interface { - // Name returns a short, unique identifier used in logs and config. - Name() - - // Enabled reports whether this module should be started - // based on the loaded configuration. - Enabled(cfg *Config) bool - - // RegisterRoutes attaches this module's HTTP routes to the shared router, if any. - // Modules without HTTP routes should leave this empty. - RegisterRoutes(r *musx.Router, cfg *Config) - - // Start runs any background work for this module (eg. periodic checks, repo cloning). - // Should block until ctx is cancelled, or return immediately if the module has no background work. - Start(ctx context.Context, cfg *Config) error - - // Stop performs graceful cleanup. Called druing shutdown for every module that was successfully started. + Name() string + RegisterRoutes(r *mux.Router) + Start(ctx context.Context) error Stop(ctx context.Context) error } - -// baseModule provides no-op defaults so modules only need to implement the methods relevant to them. -type baseModule struct{} - -func (baseModule) RegisterRoutes(r *mux.Router, cfg *Config){} -func (baseModule) Start(cxt context.Context, cfg *Config) error {return nil} - -var _ = http.MethodGet // placeholder import guard, remove once a module uses net/http directly here diff --git a/modules/keyserver/keyserver.go b/modules/keyserver/keyserver.go new file mode 100644 index 0000000..791ce58 --- /dev/null +++ b/modules/keyserver/keyserver.go @@ -0,0 +1,36 @@ +package keyserver + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" + + "varde/internal/middleware" +) + +type Config struct { + Enabled bool + KeysDir string +} + +type Module struct { + cfg Config +} + +func New(cfg Config) *Module { + return &Module{cfg: cfg} +} + +func (m *Module) Name() string { return "keyserver" } + +func (m *Module) RegisterRoutes(r *mux.Router) { + fs := http.FileServer(http.Dir(m.cfg.KeysDir)) + wkd := r.PathPrefix("/.well-known/").Subrouter() + wkd.Use(middleware.NoListing) + wkd.Use(middleware.WKDHeaders) + wkd.PathPrefix("/").Handler(fs) +} + +func (m *Module) Start(ctx context.Context) error { return nil } +func (m *Module) Stop(ctx context.Context) error { return nil } diff --git a/modules/webhook/webhook.go b/modules/webhook/webhook.go new file mode 100644 index 0000000..b4742b7 --- /dev/null +++ b/modules/webhook/webhook.go @@ -0,0 +1,138 @@ +package webhook + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "io" + "log" + "net/http" + "os" + "time" + + "github.com/go-git/go-git/v5" + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/gorilla/mux" + "golang.org/x/time/rate" + + "varde/internal/middleware" + "varde/internal/ntfy" +) + +type Config struct { + Enabled bool + ContentDir string + RepoURL string + RepoToken string + WebhookSecret string +} + +type Module struct { + cfg Config + ntfyCfg ntfy.Config + repo *git.Repository + limiter *rate.Limiter +} + +func New(cfg Config, ntfyCfg ntfy.Config) *Module { + return &Module{ + cfg: cfg, + ntfyCfg: ntfyCfg, + limiter: rate.NewLimiter(rate.Every(10*time.Second), 1), + } +} + +func (m *Module) Name() string { return "webhook" } + +func (m *Module) RegisterRoutes(r *mux.Router) { + r.Handle("/webhook", middleware.RateLimit(m.limiter)(http.HandlerFunc(m.handleWebhook))).Methods("POST") +} + +// Start clones or opens the content repo. Returns an error instead of +// calling log.Fatalf directly — a failing webhook module should not +// necessarily bring down other, unrelated modules in the same process. +func (m *Module) Start(ctx context.Context) error { + return m.cloneOrOpen() +} + +func (m *Module) Stop(ctx context.Context) error { return nil } + +func (m *Module) cloneOrOpen() error { + auth := &githttp.BasicAuth{ + Username: "x-token", + Password: m.cfg.RepoToken, + } + + if _, err := os.Stat(m.cfg.ContentDir + "/.git"); os.IsNotExist(err) { + log.Println("webhook: cloning repo...") + r, err := git.PlainClone(m.cfg.ContentDir, false, &git.CloneOptions{ + URL: m.cfg.RepoURL, + Auth: auth, + }) + if err != nil { + ntfy.Notify(m.ntfyCfg, "webhook clone failed", err) + return err + } + m.repo = r + return nil + } + + log.Println("webhook: opening existing repo...") + r, err := git.PlainOpen(m.cfg.ContentDir) + if err != nil { + ntfy.Notify(m.ntfyCfg, "webhook open failed", err) + return err + } + m.repo = r + return nil +} + +func (m *Module) pull() error { + w, err := m.repo.Worktree() + if err != nil { + return err + } + err = w.Pull(&git.PullOptions{ + Auth: &githttp.BasicAuth{ + Username: "x-token", + Password: m.cfg.RepoToken, + }, + }) + if err == git.NoErrAlreadyUpToDate { + return nil + } + return err +} + +func validateSignature(secret, signature string, body []byte) bool { + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + expected := hex.EncodeToString(mac.Sum(nil)) + return hmac.Equal([]byte(expected), []byte(signature)) +} + +func (m *Module) handleWebhook(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + + sig := r.Header.Get("X-Gitea-Signature") + if !validateSignature(m.cfg.WebhookSecret, sig, body) { + log.Println("webhook: invalid signature") + w.WriteHeader(http.StatusUnauthorized) + return + } + + if err := m.pull(); err != nil { + ntfy.Notify(m.ntfyCfg, "webhook pull failed", err) + log.Printf("webhook: pull failed: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + + log.Println("webhook: pulled new content") + w.WriteHeader(http.StatusOK) +} diff --git a/modules/webserver/webserver.go b/modules/webserver/webserver.go new file mode 100644 index 0000000..708307d --- /dev/null +++ b/modules/webserver/webserver.go @@ -0,0 +1,34 @@ +package webserver + +import ( + "context" + "net/http" + + "github.com/gorilla/mux" +) + +type Config struct { + Enabled bool + ContentDir string +} + +type Module struct { + cfg Config +} + +func New(cfg Config) *Module { + return &Module{cfg: cfg} +} + +func (m *Module) Name() string { return "webserver" } + +// RegisterRoutes uses a catch-all "/" handler — must be registered +// LAST in main.go, after any more specific routes (keyserver, webhook), +// since gorilla/mux matches in registration order. +func (m *Module) RegisterRoutes(r *mux.Router) { + fs := http.FileServer(http.Dir(m.cfg.ContentDir)) + r.PathPrefix("/").Handler(fs) +} + +func (m *Module) Start(ctx context.Context) error { return nil } +func (m *Module) Stop(ctx context.Context) error { return nil } diff --git a/webhook.go b/webhook.go deleted file mode 100644 index c7b5167..0000000 --- a/webhook.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "crypto/hmac" - "crypto/sha256" - "encoding/hex" - "io" - "log" - "net/http" - "os" - "bytes" - "fmt" - "time" - - "github.com/go-git/go-git/v5" - githttp "github.com/go-git/go-git/v5/plumbing/transport/http" -) - -var repo *git.Repository - -func cloneOrOpen(cfg *Config) { - auth := &githttp.BasicAuth{ - Username: "x-token", - Password: cfg.RepoToken, - } - - if _, err := os.Stat(cfg.ContentDir + "/.git"); os.IsNotExist(err) { - log.Println("Cloning repo...") - r, err := git.PlainClone(cfg.ContentDir, false, &git.CloneOptions{ - URL: cfg.RepoURL, - Auth: auth, - }) - if err != nil { - notifyFailure(cfg, "clone failed", err) // NEU - log.Fatalf("clone failed: %v", err) - } - repo = r - } else { - log.Println("Opening existing repo...") - r, err := git.PlainOpen(cfg.ContentDir) - if err != nil { - notifyFailure(cfg, "open failed", err) // NEU - log.Fatalf("open failed: %v", err) - } - repo = r - } -} - -func pull(cfg *Config) error { - w, err := repo.Worktree() - if err != nil { - return err - } - err = w.Pull(&git.PullOptions{ - Auth: &githttp.BasicAuth{ - Username: "x-token", - Password: cfg.RepoToken, - }, - }) - if err == git.NoErrAlreadyUpToDate { - return nil - } - return err -} - -func validateSignature(secret, signature string, body []byte) bool { - mac := hmac.New(sha256.New, []byte(secret)) - mac.Write(body) - expected := hex.EncodeToString(mac.Sum(nil)) - return hmac.Equal([]byte(expected), []byte(signature)) -} - -func webhookHandler(cfg *Config) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - if err != nil { - w.WriteHeader(http.StatusBadRequest) - return - } - - sig := r.Header.Get("X-Gitea-Signature") - if !validateSignature(cfg.WebhookSecret, sig, body) { - log.Println("invalid webhook signature") - w.WriteHeader(http.StatusUnauthorized) - return - } - - if err := pull(cfg); err != nil { - notifyFailure(cfg, "pull failed", err) // NEU - log.Printf("pull failed: %v", err) - w.WriteHeader(http.StatusInternalServerError) - return - } - - log.Println("pulled new content") - w.WriteHeader(http.StatusOK) - } -} - -func notifyFailure(cfg *Config, context string, err error) { - if cfg.NtfyURL == "" || cfg.NtfyTopic == "" { - return - } - - msg := fmt.Sprintf("%s: %v", context, err) - url := fmt.Sprintf("%s/%s", cfg.NtfyURL, cfg.NtfyTopic) - - req, reqErr := http.NewRequest("POST", url, bytes.NewBufferString(msg)) - if reqErr != nil { - log.Printf("ntfy request build failed: %v", reqErr) - return - } - req.Header.Set("Title", "Varde Webhook Failure") - req.Header.Set("Priority", "high") - - client := &http.Client{Timeout: 5 * time.Second} - resp, sendErr := client.Do(req) - if sendErr != nil { - log.Printf("ntfy notification failed: %v", sendErr) - return - } - defer resp.Body.Close() - - if resp.StatusCode >= 300 { - log.Printf("ntfy notification returned status %d", resp.StatusCode) - } -}