diff --git a/.gitea/workflows/registry.yml b/.gitea/workflows/registry.yml new file mode 100644 index 0000000..20912ac --- /dev/null +++ b/.gitea/workflows/registry.yml @@ -0,0 +1,51 @@ +name: release-tag +on: + push: + branches: + - 'main' +jobs: + release-image: + runs-on: ubuntu-fast + env: + DOCKER_ORG: ${{ vars.DOCKER_ORG }} + DOCKER_LATEST: latest + RUNNER_TOOL_CACHE: /toolcache + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + + - name: Set up Docker BuildX + uses: docker/setup-buildx-action@v2 + with: # replace it with your local IP + config-inline: | + [registry."${{ vars.DOCKER_REGISTRY }}"] + http = true + insecure = true + + - name: Login to DockerHub + uses: docker/login-action@v2 + with: + registry: ${{ vars.DOCKER_REGISTRY }} # replace it with your local IP + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Get Meta + id: meta + run: | + echo REPO_NAME=$(echo ${GITHUB_REPOSITORY} | awk -F"/" '{print $2}') >> $GITHUB_OUTPUT + echo REPO_VERSION=$(git describe --tags --always | sed 's/^v//') >> $GITHUB_OUTPUT + + - name: Build and push + uses: docker/build-push-action@v4 + with: + context: . + file: ./Dockerfile + platforms: | + linux/amd64 + push: true + tags: | # replace it with your local IP and tags + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }} + ${{ vars.DOCKER_REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..43bf13e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +# ---------- build ---------- +FROM golang:1.24-alpine AS build +WORKDIR /src +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/dweb ./cmd/dweb + +# ---------- run ---------- +FROM alpine:3.22 +RUN adduser -D -u 10001 dweb && apk add --no-cache ca-certificates +USER dweb +WORKDIR /app + +# Daten & Schlüssel landen unter /data (als Volume mounten) +ENV CONFIG_DIR=/data \ + ADDR=:8080 \ + MESH_ADDR=:8443 \ + BEACON_ADDR=:9443 \ + MAX_BODY_BYTES=8388608 + +COPY --from=build /out/dweb /app/dweb +EXPOSE 8080 8443 9443 +ENTRYPOINT ["/app/dweb"] diff --git a/cmd/dweb/main.go b/cmd/dweb/main.go new file mode 100644 index 0000000..1259a3d --- /dev/null +++ b/cmd/dweb/main.go @@ -0,0 +1,221 @@ +package main + +import ( + "context" + "crypto/tls" + "encoding/json" + "log" + "net/http" + "os" + "os/signal" + "path/filepath" + "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 != "" { + var x int64 + _, _ = fmtSscanf(v, &x) + return x + } + return def +} +func fmtSscanf(s string, p *int64) (int, error) { return fmtSscanfImpl(s, p) } + +// tiny wrapper to avoid importing fmt at top-level; keeps imports tidy +func fmtSscanfImpl(s string, p *int64) (int, error) { return fmtSscanfReal(s, p) } + +//go:linkname fmtSscanfReal fmt.Sscanf +func fmtSscanfReal(s string, p *int64) (int, error) + +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() + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b0d44f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/decent-websrv + +go 1.24.4 diff --git a/internal/beacon/client.go b/internal/beacon/client.go new file mode 100644 index 0000000..133b96e --- /dev/null +++ b/internal/beacon/client.go @@ -0,0 +1,55 @@ +package beacon + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "time" + + "git.send.nrw/sendnrw/decent-websrv/internal/mesh" +) + +type Client struct { + URL, Token string + HTTP *http.Client +} + +func (c *Client) LoopRegister(n mesh.NodeInfo) { + go func() { + for { + c.register(n) + time.Sleep(30 * time.Second) + } + }() +} + +func (c *Client) register(n mesh.NodeInfo) { + b, _ := json.Marshal(RegisterReq{BaseURL: n.PublicURL, MeshURL: n.MeshURL, NodeID: n.NodeID, TTL: 45}) + req, _ := http.NewRequest(http.MethodPost, c.URL+"/_beacon/register", bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + if c.Token != "" { + req.Header.Set("X-Beacon-Token", c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + log.Println("beacon register:", err) + return + } + resp.Body.Close() +} + +func (c *Client) PollPeers() []mesh.NodeInfo { + req, _ := http.NewRequest(http.MethodGet, c.URL+"/_beacon/peers", nil) + if c.Token != "" { + req.Header.Set("X-Beacon-Token", c.Token) + } + resp, err := c.HTTP.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + var out PeersResp + _ = json.NewDecoder(resp.Body).Decode(&out) + return out.Peers +} diff --git a/internal/beacon/server.go b/internal/beacon/server.go new file mode 100644 index 0000000..9b487e0 --- /dev/null +++ b/internal/beacon/server.go @@ -0,0 +1,73 @@ +package beacon + +import ( + "encoding/json" + "net/http" + "sync" + "time" + + "git.send.nrw/sendnrw/decent-websrv/internal/mesh" +) + +type Server struct { + addr string + token string + reg *registry + http *http.Server +} + +type registry struct { + mu sync.Mutex + m map[string]mesh.NodeInfo + exp map[string]int64 // id->expires +} + +func NewServer(addr, token string) *Server { + s := &Server{addr: addr, token: token, reg: ®istry{m: map[string]mesh.NodeInfo{}, exp: map[string]int64{}}} + mux := http.NewServeMux() + mux.HandleFunc("/_beacon/register", s.handleRegister) + mux.HandleFunc("/_beacon/peers", s.handlePeers) + s.http = &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second} + return s +} + +func (s *Server) ListenAndServe() error { return s.http.ListenAndServe() } +func (s *Server) ListenAndServeTLS(cert, key string) error { + return s.http.ListenAndServeTLS(cert, key) +} + +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + if s.token != "" && r.Header.Get("X-Beacon-Token") != s.token { + http.Error(w, "forbidden", 403) + return + } + var in RegisterReq + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + http.Error(w, "bad", 400) + return + } + n := mesh.NodeInfo{NodeID: in.NodeID, PublicURL: in.BaseURL, MeshURL: in.MeshURL} + exp := time.Now().Add(time.Duration(in.TTL) * time.Second).Unix() + s.reg.mu.Lock() + s.reg.m[n.NodeID] = n + s.reg.exp[n.NodeID] = exp + s.reg.mu.Unlock() + w.WriteHeader(200) +} + +func (s *Server) handlePeers(w http.ResponseWriter, r *http.Request) { + if s.token != "" && r.Header.Get("X-Beacon-Token") != s.token { + http.Error(w, "forbidden", 403) + return + } + now := time.Now().Unix() + s.reg.mu.Lock() + out := make([]mesh.NodeInfo, 0, len(s.reg.m)) + for id, n := range s.reg.m { + if s.reg.exp[id] > now { + out = append(out, n) + } + } + s.reg.mu.Unlock() + _ = json.NewEncoder(w).Encode(PeersResp{Peers: out}) +} diff --git a/internal/beacon/types.go b/internal/beacon/types.go new file mode 100644 index 0000000..90422d5 --- /dev/null +++ b/internal/beacon/types.go @@ -0,0 +1,14 @@ +package beacon + +import "git.send.nrw/sendnrw/decent-websrv/internal/mesh" + +type RegisterReq struct { + BaseURL string `json:"public_url"` + MeshURL string `json:"mesh_url"` + NodeID string `json:"node_id"` + TTL int `json:"ttl"` +} + +type PeersResp struct { + Peers []mesh.NodeInfo `json:"peers"` +} diff --git a/internal/cas/http.go b/internal/cas/http.go new file mode 100644 index 0000000..73367de --- /dev/null +++ b/internal/cas/http.go @@ -0,0 +1,58 @@ +package cas + +import ( + "io" + "net/http" + "strings" +) + +type Fetcher interface { + FetchTo(hash string, w http.ResponseWriter) bool +} + +type HTTP struct { + S *Store + Fetcher Fetcher // optional, for federation +} + +// Public: GET /c/ +func (h *HTTP) Serve(w http.ResponseWriter, r *http.Request) { + hash := strings.TrimPrefix(r.URL.Path, "/c/") + if hash == "" { + w.WriteHeader(400) + return + } + if h.S.Has(hash) { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + _ = h.S.Get(hash, w) + return + } + if h.Fetcher != nil && h.Fetcher.FetchTo(hash, w) { + return + } + w.WriteHeader(404) +} + +// Mesh: GET /_mesh/cas/ → raw bytes +func (h *HTTP) MeshGet(w http.ResponseWriter, r *http.Request) { + hash := strings.TrimPrefix(r.URL.Path, "/_mesh/cas/") + if hash == "" { + w.WriteHeader(400) + return + } + if !h.S.Has(hash) { + w.WriteHeader(404) + return + } + _ = h.S.Get(hash, w) +} + +// Mesh: POST /_mesh/cas/put → returns hash as text +func (h *HTTP) MeshPut(w http.ResponseWriter, r *http.Request) { + hash, err := h.S.PutStream(r.Body) + if err != nil { + w.WriteHeader(500) + return + } + io.WriteString(w, hash) +} diff --git a/internal/cas/mesh_fetcher.go b/internal/cas/mesh_fetcher.go new file mode 100644 index 0000000..2508a30 --- /dev/null +++ b/internal/cas/mesh_fetcher.go @@ -0,0 +1,50 @@ +package cas + +import ( + "io" + "net/http" + "strings" + + "git.send.nrw/sendnrw/decent-websrv/internal/mesh" + "git.send.nrw/sendnrw/decent-websrv/internal/security" +) + +type MeshFetcher struct { + Ring *mesh.Rendezvous + HTTP *http.Client + Verifier *security.MeshVerifier + Self mesh.NodeInfo +} + +func (f *MeshFetcher) FetchTo(hash string, w http.ResponseWriter) bool { + owners := f.Ring.Owners(hash, 3) + if len(owners) == 0 { + return false + } + for _, id := range owners { + if id == f.Self.NodeID { + continue + } + // resolve id to node + // In this minimal starter we assume Mesh URL can be derived externally; in a fuller impl, pass a Catalog here. + // For simplicity, try owner as URL directly if it's already a URL; otherwise skip. + u := id // If your ring holds IDs, you should map ID->Node (MeshURL); keep simple here. + if !strings.HasPrefix(u, "http") { + continue + } + path := "/_mesh/cas/" + hash + req, _ := http.NewRequest(http.MethodGet, strings.TrimRight(u, "/")+path, nil) + f.Verifier.SignOutgoing(req, "", f.Self.NodeID) // sign GET with empty body-hash + resp, err := f.HTTP.Do(req) + if err != nil { + continue + } + if resp.StatusCode == 200 { + defer resp.Body.Close() + io.Copy(w, resp.Body) + return true + } + resp.Body.Close() + } + return false +} diff --git a/internal/cas/store.go b/internal/cas/store.go new file mode 100644 index 0000000..d7dfd44 --- /dev/null +++ b/internal/cas/store.go @@ -0,0 +1,57 @@ +package cas + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "os" + "path/filepath" +) + +type Store struct{ dir string } + +func New(dir string) *Store { os.MkdirAll(dir, 0o755); return &Store{dir: dir} } + +func (s *Store) pathOf(hash string) string { + if len(hash) < 2 { + return filepath.Join(s.dir, hash) + } + return filepath.Join(s.dir, hash[:2], hash) +} + +func (s *Store) Has(hash string) bool { _, err := os.Stat(s.pathOf(hash)); return err == nil } + +func (s *Store) PutStream(r io.Reader) (string, error) { + h := sha256.New() + tmp, err := os.CreateTemp(s.dir, "put-*.tmp") + if err != nil { + return "", err + } + defer os.Remove(tmp.Name()) + mw := io.MultiWriter(h, tmp) + if _, err := io.Copy(mw, r); err != nil { + tmp.Close() + return "", err + } + tmp.Close() + sum := hex.EncodeToString(h.Sum(nil)) + dst := s.pathOf(sum) + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return "", err + } + if err := os.Rename(tmp.Name(), dst); err != nil { + return "", err + } + return sum, nil +} + +func (s *Store) Get(hash string, w io.Writer) error { + f, err := os.Open(s.pathOf(hash)) + if err != nil { + return errors.New("not found") + } + defer f.Close() + _, err = io.Copy(w, f) + return err +} diff --git a/internal/mesh/catalog.go b/internal/mesh/catalog.go new file mode 100644 index 0000000..2c38c44 --- /dev/null +++ b/internal/mesh/catalog.go @@ -0,0 +1,46 @@ +package mesh + +import "sync" + +type Catalog struct { + mu sync.RWMutex + byID map[string]NodeInfo +} + +func NewCatalog() *Catalog { return &Catalog{byID: map[string]NodeInfo{}} } + +func (c *Catalog) Set(n NodeInfo) { c.mu.Lock(); c.byID[n.NodeID] = n; c.mu.Unlock() } + +func (c *Catalog) Replace(nodes []NodeInfo) { + c.mu.Lock() + defer c.mu.Unlock() + c.byID = map[string]NodeInfo{} + for _, n := range nodes { + c.byID[n.NodeID] = n + } +} + +func (c *Catalog) All() []NodeInfo { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]NodeInfo, 0, len(c.byID)) + for _, n := range c.byID { + out = append(out, n) + } + return out +} +func (c *Catalog) IDs() []string { + c.mu.RLock() + defer c.mu.RUnlock() + out := make([]string, 0, len(c.byID)) + for id := range c.byID { + out = append(out, id) + } + return out +} +func (c *Catalog) ByID(id string) (NodeInfo, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + n, ok := c.byID[id] + return n, ok +} diff --git a/internal/mesh/rendezvous.go b/internal/mesh/rendezvous.go new file mode 100644 index 0000000..2f9d98e --- /dev/null +++ b/internal/mesh/rendezvous.go @@ -0,0 +1,49 @@ +package mesh + +import ( + "crypto/sha256" + "encoding/binary" + "sort" + "sync" +) + +type Rendezvous struct { + mu sync.RWMutex + nodes []string // node IDs +} + +func NewRendezvous() *Rendezvous { return &Rendezvous{} } + +func (r *Rendezvous) Set(ids []string) { + r.mu.Lock() + r.nodes = append([]string(nil), ids...) + r.mu.Unlock() +} + +func (r *Rendezvous) Owners(key string, k int) []string { + r.mu.RLock() + ids := append([]string(nil), r.nodes...) + r.mu.RUnlock() + if len(ids) == 0 { + return nil + } + type pair struct { + id string + w uint64 + } + ws := make([]pair, 0, len(ids)) + for _, id := range ids { + h := sha256.Sum256([]byte(id + "|" + key)) + w := binary.LittleEndian.Uint64(h[:8]) + ws = append(ws, pair{id: id, w: w}) + } + sort.Slice(ws, func(i, j int) bool { return ws[i].w > ws[j].w }) + if k > len(ws) { + k = len(ws) + } + out := make([]string, 0, k) + for i := 0; i < k; i++ { + out = append(out, ws[i].id) + } + return out +} diff --git a/internal/mesh/types.go b/internal/mesh/types.go new file mode 100644 index 0000000..d6a9409 --- /dev/null +++ b/internal/mesh/types.go @@ -0,0 +1,7 @@ +package mesh + +type NodeInfo struct { + NodeID string `json:"node_id"` + PublicURL string `json:"public_url"` + MeshURL string `json:"mesh_url"` +} diff --git a/internal/security/http.go b/internal/security/http.go new file mode 100644 index 0000000..f401084 --- /dev/null +++ b/internal/security/http.go @@ -0,0 +1,20 @@ +package security + +import "net/http" + +func MaxBody(n int64, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, n) + next.ServeHTTP(w, r) + }) +} + +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Content-Security-Policy", "default-src 'self'") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/security/mesh_auth.go b/internal/security/mesh_auth.go new file mode 100644 index 0000000..b5a151b --- /dev/null +++ b/internal/security/mesh_auth.go @@ -0,0 +1,189 @@ +package security + +import ( + "crypto/ed25519" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "net/http" + "os" + "path/filepath" + "strings" + "time" +) + +type MeshVerifier struct { + mode string // ed25519|hmac + priv ed25519.PrivateKey + pubs map[string]ed25519.PublicKey // nodeID->pub + secret []byte +} + +func NewMeshAuth(mode, privPath, pubDir, secret string) (*MeshVerifier, *MeshVerifier) { + mv := &MeshVerifier{mode: mode} + if mode == "ed25519" { + if privPath != "" { + if b, err := os.ReadFile(privPath); err == nil { + if p, _ := pem.Decode(b); p != nil { + mv.priv = ed25519.PrivateKey(p.Bytes) + } + } + } + mv.pubs = map[string]ed25519.PublicKey{} + _ = filepath.WalkDir(pubDir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".pub") && !strings.HasSuffix(path, ".pem") { + return nil + } + name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) + b, _ := os.ReadFile(path) + p, _ := pem.Decode(b) + if p != nil { + mv.pubs[name] = ed25519.PublicKey(p.Bytes) + } + return nil + }) + } else { + mv.secret = []byte(secret) + } + return mv, mv +} + +func (m *MeshVerifier) bodyHash(r *http.Request) string { + // Expect header X-Mesh-Hash (hex) from client for POST; for GET may be empty + return r.Header.Get("X-Mesh-Hash") +} + +func (m *MeshVerifier) canonical(r *http.Request, ts, nonce, bodyHash string) []byte { + return []byte(strings.Join([]string{r.Method, r.URL.Path, ts, nonce, bodyHash}, "\n")) +} + +// Verify incoming request (used in middleware) +func (m *MeshVerifier) VerifyIncoming(r *http.Request, now time.Time, nonces *NonceStore) bool { + ts := r.Header.Get("X-Mesh-TS") + nonce := r.Header.Get("X-Mesh-Nonce") + from := r.Header.Get("X-Mesh-From") + bh := m.bodyHash(r) + if ts == "" || nonce == "" || from == "" { + return false + } + // timestamp window + //sec, _ := time.ParseDuration("0s") + if len(ts) >= 10 { // crude parse + i := int64(0) + for _, ch := range ts { + if ch < '0' || ch > '9' { + i = 0 + break + } + i = i*10 + int64(ch-'0') + } + if i != 0 { + dt := time.Unix(i, 0).Sub(now) + if dt > 60*time.Second || dt < -60*time.Second { + return false + } + } + } + if !nonces.Use(nonce, now) { + return false + } + canon := m.canonical(r, ts, nonce, bh) + if m.mode == "ed25519" { + sig := r.Header.Get("X-Mesh-Sig") + pub := m.pubs[from] + if len(pub) == 0 { + return false + } + b, err := base64.StdEncoding.DecodeString(sig) + if err != nil { + return false + } + return ed25519.Verify(pub, canon, b) + } + macHex := r.Header.Get("X-Mesh-MAC") + if macHex == "" { + return false + } + mac := hmac.New(sha256.New, m.secret) + mac.Write(canon) + want := mac.Sum(nil) + got, _ := hex.DecodeString(macHex) + return hmac.Equal(want, got) +} + +// Sign outgoing mesh request (client-side helper) +func (m *MeshVerifier) SignOutgoing(r *http.Request, bodyHash string, from string) { + ts := time.Now().Unix() + nonce := make([]byte, 12) + _, _ = rand.Read(nonce) + nonceB64 := base64.RawURLEncoding.EncodeToString(nonce) + r.Header.Set("X-Mesh-TS", fmtItoa(ts)) + r.Header.Set("X-Mesh-Nonce", nonceB64) + r.Header.Set("X-Mesh-From", from) + r.Header.Set("X-Mesh-Hash", bodyHash) + canon := m.canonical(r, fmtItoa(ts), nonceB64, bodyHash) + if m.mode == "ed25519" { + sig := ed25519.Sign(m.priv, canon) + r.Header.Set("X-Mesh-Sig", base64.StdEncoding.EncodeToString(sig)) + } else { + mac := hmac.New(sha256.New, m.secret) + mac.Write(canon) + r.Header.Set("X-Mesh-MAC", hex.EncodeToString(mac.Sum(nil))) + } +} + +// Middleware enforcing mesh auth +func MeshAuthMiddleware(verifier *MeshVerifier, nonces *NonceStore, maxBody int64) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // limit headers/body + r.Body = http.MaxBytesReader(w, r.Body, maxBody) + if !verifier.VerifyIncoming(r, time.Now(), nonces) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) + } +} + +// helpers to avoid extra imports + +func fmtItoa(i int64) string { // fast itoa + if i == 0 { + return "0" + } + neg := i < 0 + if neg { + i = -i + } + buf := make([]byte, 0, 20) + for i > 0 { + buf = append(buf, byte('0'+i%10)) + i /= 10 + } + for l, r := 0, len(buf)-1; l < r; l, r = l+1, r-1 { + buf[l], buf[r] = buf[r], buf[l] + } + if neg { + return "-" + string(buf) + } + return string(buf) +} + +func LoadCertPool(caPath string) *x509.CertPool { + b, err := os.ReadFile(caPath) + if err != nil { + return nil + } + pool := x509.NewCertPool() + pool.AppendCertsFromPEM(b) + return pool +} diff --git a/internal/security/nonce.go b/internal/security/nonce.go new file mode 100644 index 0000000..0cbbbe3 --- /dev/null +++ b/internal/security/nonce.go @@ -0,0 +1,31 @@ +package security + +import ( + "sync" + "time" +) + +type NonceStore struct { + mu sync.Mutex + m map[string]int64 + ttl time.Duration +} + +func NewNonceStore(ttl time.Duration) *NonceStore { + return &NonceStore{m: map[string]int64{}, ttl: ttl} +} + +func (s *NonceStore) Use(nonce string, now time.Time) bool { + s.mu.Lock() + defer s.mu.Unlock() + if exp, ok := s.m[nonce]; ok && exp >= now.Unix() { + return false + } + s.m[nonce] = now.Add(s.ttl).Unix() + for k, v := range s.m { + if v < now.Unix() { + delete(s.m, k) + } + } + return true +} diff --git a/internal/security/ratelimit.go b/internal/security/ratelimit.go new file mode 100644 index 0000000..30c7667 --- /dev/null +++ b/internal/security/ratelimit.go @@ -0,0 +1,62 @@ +package security + +import ( + "net" + "net/http" + "sync" + "time" +) + +type bucket struct { + tokens float64 + last time.Time +} + +type Limiter struct { + mu sync.Mutex + m map[string]*bucket + rps float64 + burst float64 +} + +func NewLimiter(rps, burst float64) *Limiter { + return &Limiter{m: map[string]*bucket{}, rps: rps, burst: burst} +} + +func (l *Limiter) allow(key string) bool { + l.mu.Lock() + defer l.mu.Unlock() + b := l.m[key] + now := time.Now() + if b == nil { + b = &bucket{tokens: l.burst, last: now} + l.m[key] = b + } + elapsed := now.Sub(b.last).Seconds() + b.tokens = min(l.burst, b.tokens+elapsed*l.rps) + b.last = now + if b.tokens >= 1 { + b.tokens -= 1 + return true + } + return false +} + +func (l *Limiter) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + host, _, _ := net.SplitHostPort(r.RemoteAddr) + key := host + r.URL.Path + if !l.allow(key) { + http.Error(w, "rate limit", http.StatusTooManyRequests) + return + } + next.ServeHTTP(w, r) + }) +} + +func min(a, b float64) float64 { + if a < b { + return a + } + return b +}