From db2247b56a3b9e4622b8d801c2535caa3089b78d Mon Sep 17 00:00:00 2001 From: John David White <122345776@umail.ucc.ie> Date: Fri, 27 Mar 2026 17:42:07 +0000 Subject: [PATCH] Fix multiple user routes not working --- config/default.yaml | 12 + dataplane/main.go | 94 +++--- dataplane/nginx/nginx.conf | 9 +- docker-compose.yml | 1 + management/main.go | 86 ++--- management/static/auth.html | 1 + management/static/config.html | 1 + src/config/config.go | 117 ++++--- src/semantics/semantic_analysis.go | 494 ++++++++++++++--------------- src/templates/nginx.conf.tmpl | 50 ++- src/templates/zone.conf.tmpl | 5 + src/utils/defaults.go | 28 +- src/watcher/watcher.go | 53 ++-- 13 files changed, 507 insertions(+), 444 deletions(-) create mode 100644 config/default.yaml create mode 100644 src/templates/zone.conf.tmpl diff --git a/config/default.yaml b/config/default.yaml new file mode 100644 index 0000000..3174d4c --- /dev/null +++ b/config/default.yaml @@ -0,0 +1,12 @@ +# +# Configuration file for API Gateway +# + +#connections: +# routes: +# - path: +# url: +# rate-limit: +# zone: +# rate: +# auth: diff --git a/dataplane/main.go b/dataplane/main.go index e07ed80..9bed731 100644 --- a/dataplane/main.go +++ b/dataplane/main.go @@ -10,80 +10,96 @@ import ( "path/filepath" ) -type Response struct { - Filename string `json:"filename"` - Body []byte `json:"body"` +type ConfigRequest struct { + Files map[string][]byte `json:"files"` } func main() { mux := http.NewServeMux() - mux.HandleFunc("/api/handle-config", handleNewConfig) - err := http.ListenAndServe(":1000", mux) - if err != nil { + + slog.Info("Starting dataplane on :1000") + if err := http.ListenAndServe(":1000", mux); err != nil { slog.Error("Error starting HTTP server", "error", err) } } func handleNewConfig(w http.ResponseWriter, r *http.Request) { slog.Info("handling config update") - res := &Response{} - if r.Method != "POST" { + if r.Method != http.MethodPost { slog.Error("Invalid request method", "method", r.Method) + http.Error(w, "invalid method", http.StatusMethodNotAllowed) return } - err := json.NewDecoder(r.Body).Decode(&res) - if err != nil { + var req ConfigRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { slog.Error("Error unmarshalling request body", "error", err) + http.Error(w, "bad request", http.StatusBadRequest) return } - dir := filepath.Dir(res.Filename) - err = os.MkdirAll(dir, 0644) - if err != nil { - slog.Error("Error creating directory", "error", err) - return - } - - username := filepath.Base(filepath.Dir(res.Filename)) - logDir := "/var/log/nginx/users/" + username - err = os.MkdirAll(logDir, 0755) - if err != nil { - slog.Error("Error creating log directory", "error", err) - return - } - - file, err := os.Create(res.Filename) - if err != nil { - slog.Error("Error creating file", "filename", res.Filename) - return + for path, content := range req.Files { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + slog.Error("Error creating directory", "dir", dir, "error", err) + continue + } + + // Ensure username log dir exists + username := filepath.Base(filepath.Dir(path)) + logDir := filepath.Join("/var/log/nginx/users", username) + if err := os.MkdirAll(logDir, 0755); err != nil { + slog.Error("Error creating log directory", "dir", logDir, "error", err) + } + + // Atomic write + tmpFile, err := os.CreateTemp(dir, "nginx-*.conf") + if err != nil { + slog.Error("Error creating temp file", "dir", dir, "error", err) + continue + } + + if _, err := tmpFile.Write(content); err != nil { + slog.Error("Error writing to temp file", "file", tmpFile.Name(), "error", err) + tmpFile.Close() + continue + } + + if err := tmpFile.Close(); err != nil { + slog.Error("Error closing temp file", "file", tmpFile.Name(), "error", err) + continue + } + + if err := os.Rename(tmpFile.Name(), path); err != nil { + slog.Error("Error moving temp file to final location", "src", tmpFile.Name(), "dst", path, "error", err) + continue + } + + slog.Info("Updated file", "path", path) } - _, err = file.Write(res.Body) - if err != nil { - slog.Error("Error writing to file", "filename", res.Filename) + // Apply NGINX config after all files updated + if err := applyNginxConfig(); err != nil { + slog.Error("Error applying NGINX config", "error", err) + http.Error(w, "failed to apply nginx config", http.StatusInternalServerError) return } - err = applyNginxConfig() - if err != nil { - slog.Error("Error applying nginx config", "error", err, "filename", res.Filename) - return - } + w.WriteHeader(http.StatusOK) + slog.Info("Config update applied successfully") } func applyNginxConfig() error { + slog.Info("applying NGINX config") cmd := exec.Command("nginx", "-t") - output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("nginx config test failed: %s", string(output)) } cmd = exec.Command("nginx", "-s", "reload") - output, err = cmd.CombinedOutput() if err != nil { return fmt.Errorf("nginx reload failed: %s", string(output)) diff --git a/dataplane/nginx/nginx.conf b/dataplane/nginx/nginx.conf index be7c2d0..0675778 100644 --- a/dataplane/nginx/nginx.conf +++ b/dataplane/nginx/nginx.conf @@ -14,5 +14,12 @@ http{ '"$bytes_sent" "$request_length" "$request_time" ' '"$gzip_ratio" $server_protocol '; - include /etc/nginx/users/*/nginx.conf; + include /etc/nginx/users/*/zone.conf; + + server { + listen 8080; + + include /etc/nginx/users/*/nginx.conf; + } + } \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 8b74a07..2870a86 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -61,6 +61,7 @@ services: volumes: - "./config:/etc/config" - "./src/templates/nginx.conf.tmpl:/etc/nginx/nginx.conf.tmpl:ro" + - "./src/templates/zone.conf.tmpl:/etc/nginx/zone.conf.tmpl:ro" - "./src/config/init.sql:/var/lib/init.sql:ro" restart: unless-stopped depends_on: diff --git a/management/main.go b/management/main.go index b1960d7..7a5f136 100644 --- a/management/main.go +++ b/management/main.go @@ -1,43 +1,43 @@ -package main - -import ( - "fyp-api-gateway/management/auth" - "fyp-api-gateway/management/handler" - "log/slog" - "net/http" -) - -func main() { - mux := http.NewServeMux() - - // frontend routes - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) - //mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - // http.Redirect(w, r, "/", http.StatusSeeOther) - //}) - mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "./static/auth.html") - }) - mux.HandleFunc("/config", auth.RequireSession(func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "./static/config.html") - })) - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "./static/index.html") - }) - - // account initialisation routes - mux.HandleFunc("/api/signup", auth.Signup) - mux.HandleFunc("/api/login", auth.Login) - mux.HandleFunc("/file/gateway", auth.RequireSession(handler.Gateway)) - - // config routes - mux.HandleFunc("/file/upload", handler.HandleNewConfig) - mux.HandleFunc("/file/findings", handler.RecvFindings) - mux.HandleFunc("/file/retrieve", handler.Findings) - mux.HandleFunc("/file/accept", handler.HandleAcceptChanges) - - err := http.ListenAndServe(":81", mux) - if err != nil { - slog.Error("could not start management plane", "error", err) - } -} +package main + +import ( + "fyp-api-gateway/management/auth" + "fyp-api-gateway/management/handler" + "log/slog" + "net/http" +) + +func main() { + mux := http.NewServeMux() + + // frontend routes + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("./static")))) + //mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // http.Redirect(w, r, "/", http.StatusSeeOther) + //}) + mux.HandleFunc("/auth", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./static/auth.html") + }) + mux.HandleFunc("/config", auth.RequireSession(func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./static/config.html") + })) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, "./static/index.html") + }) + + // account initialisation routes + mux.HandleFunc("/api/signup", auth.Signup) + mux.HandleFunc("/api/login", auth.Login) + mux.HandleFunc("/file/gateway", auth.RequireSession(handler.Gateway)) + + // config routes + mux.HandleFunc("/file/upload", handler.HandleNewConfig) + mux.HandleFunc("/file/findings", handler.RecvFindings) + mux.HandleFunc("/file/retrieve", handler.Findings) + mux.HandleFunc("/file/accept", handler.HandleAcceptChanges) + + err := http.ListenAndServe(":81", mux) + if err != nil { + slog.Error("could not start management plane", "error", err) + } +} diff --git a/management/static/auth.html b/management/static/auth.html index 85cb3a8..7c4f0d5 100644 --- a/management/static/auth.html +++ b/management/static/auth.html @@ -11,6 +11,7 @@ Home Signup/Login Config File + Dashboard diff --git a/management/static/config.html b/management/static/config.html index 3bad495..211399a 100644 --- a/management/static/config.html +++ b/management/static/config.html @@ -11,6 +11,7 @@ Home Signup/Login Config File + Dashboard diff --git a/src/config/config.go b/src/config/config.go index 93bd1bd..ec1e207 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -8,6 +8,7 @@ import ( "log/slog" "net/http" "os" + "path/filepath" "strings" "text/template" @@ -24,29 +25,42 @@ type TemplateData struct { } func InitUserNGINX(username string) error { - // load the default config gatewayConf, err := loadAndValidateGatewayConf(utils.DefaultConfigContent) if err != nil { slog.Error("failed to load and validate gateway config", "error", err) return err } - nginxUserConfDir := utils.NGINXDirName + "users/" + username + "/" + utils.NGINXConfigFileName + nginxUserConfDir := utils.NGINXDirName + "users/" + username + "/" + nginxUserConfFile := nginxUserConfDir + utils.NGINXConfigFileName + nginxUserZoneFile := nginxUserConfDir + utils.NGINXZoneFileName // create new user nginx file - _, err = os.Stat(nginxUserConfDir) + _, err = os.Stat(nginxUserConfFile) if err == nil { slog.Error("NGINX config file already exists") return err } + _, err = os.Stat(nginxUserZoneFile) + if err == nil { + slog.Error("NGINX zone file already exists") + return err + } + err = os.MkdirAll(utils.NGINXDirName+"users/"+username, 0644) if err != nil { slog.Error("failed creating users directory", "error", err) return err } - _, err = os.Create(nginxUserConfDir) + _, err = os.Create(nginxUserConfFile) + if err != nil { + slog.Error("failed creating users config", "error", err) + return err + } + + _, err = os.Create(nginxUserZoneFile) if err != nil { slog.Error("failed creating users config", "error", err) return err @@ -54,7 +68,7 @@ func InitUserNGINX(username string) error { templateData := buildTemplateData(username, gatewayConf) - err = renderNginxTemplate(templateData, nginxUserConfDir) + _, _, err = renderNginxTemplate(templateData) if err != nil { slog.Error("failed rendering NGINX template", "error", err) return err @@ -64,8 +78,19 @@ func InitUserNGINX(username string) error { } func loadAndValidateGatewayConf(body string) (*GatewayConfig, error) { - var config GatewayConfig + var meaningful []string + for _, line := range strings.Split(body, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "#") { + meaningful = append(meaningful, line) + } + } + if len(meaningful) == 0 { + return &GatewayConfig{}, nil + } + + var config GatewayConfig decoder := yaml.NewDecoder(bytes.NewReader([]byte(body))) if err := decoder.Decode(&config); err != nil { return nil, err @@ -74,28 +99,30 @@ func loadAndValidateGatewayConf(body string) (*GatewayConfig, error) { return &config, nil } -func renderNginxTemplate(data TemplateData, nginxUserConfDir string) error { - // load the template file - tmpl, err := template.ParseFiles(utils.NGINXTemplateDirName + utils.NGINXTemplateFileName) +func renderNginxTemplate(data TemplateData) (string, string, error) { + zoneTmpl, err := template.ParseFiles(utils.NGINXTemplateDirName + utils.NGINXZoneTemplateFileName) if err != nil { - return err + return "", "", err } - // Create the updated NGINX config file and save it into the file containing the current config - var buf bytes.Buffer - if err = tmpl.Execute(&buf, data); err != nil { + serverTmpl, err := template.ParseFiles(utils.NGINXTemplateDirName + utils.NGINXTemplateFileName) + if err != nil { + return "", "", err + } + + var zoneBuf bytes.Buffer + if err = zoneTmpl.Execute(&zoneBuf, data); err != nil { slog.Error("Error executing template: ", "error", err) - return err + return "", "", err } - nginxString := buf.String() - // write the file - err = os.WriteFile(nginxUserConfDir, []byte(nginxString), 0644) - if err != nil { - return err + var serverBuf bytes.Buffer + if err = serverTmpl.Execute(&serverBuf, data); err != nil { + slog.Error("Error executing template: ", "error", err) + return "", "", err } - return nil + return zoneBuf.String(), serverBuf.String(), nil } func buildTemplateData(username string, gw *GatewayConfig) TemplateData { @@ -177,50 +204,44 @@ func LoadNewConfig(w http.ResponseWriter, r *http.Request) { templateData := buildTemplateData(username, gatewayConf) - err = renderNginxTemplate(templateData, nginxUserConfPath) + zoneStr, serverStr, err := renderNginxTemplate(templateData) if err != nil { slog.Error("failed to render NGINX template", "error", err) http.Error(w, "failed to render NGINX template", http.StatusInternalServerError) return } - // Atomic writes - renderedConfig, err := os.ReadFile(nginxUserConfPath) - if err != nil { - slog.Error("failed to read rendered NGINX template", "error", err) - http.Error(w, "failed to render NGINX template", http.StatusInternalServerError) + if err = atomicWrites(nginxUserConfDir, utils.NGINXZoneFileName, []byte(zoneStr)); err != nil { + slog.Error("failed to write NGINX zone", "error", err) + http.Error(w, "failed to write NGINX zone", http.StatusInternalServerError) return } - tempFile, err := os.CreateTemp(nginxUserConfDir, "nginx-*.conf") - if err != nil { - slog.Error("failed creating temp file", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) + if err = atomicWrites(nginxUserConfDir, utils.NGINXConfigFileName, []byte(serverStr)); err != nil { + slog.Error("failed to write NGINX config", "error", err) + http.Error(w, "failed to write NGINX zone", http.StatusInternalServerError) return } - defer os.Remove(tempFile.Name()) - if _, err = tempFile.Write(renderedConfig); err != nil { - slog.Error("failed writing temp config", "error", err) - tempFile.Close() - http.Error(w, "internal server error", http.StatusInternalServerError) - return - } + slog.Info("gateway config updated successfully", "path", nginxUserConfPath) + w.WriteHeader(http.StatusOK) +} - if err = tempFile.Close(); err != nil { - slog.Error("failed closing temp file", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return +func atomicWrites(dir, filename string, content []byte) error { + tmpFile, err := os.CreateTemp(dir, "nginx-*.conf") + if err != nil { + return err } + defer os.Remove(tmpFile.Name()) - // Atomic replace - if err = os.Rename(tempFile.Name(), nginxUserConfPath); err != nil { - slog.Error("failed replacing config file", "error", err) - http.Error(w, "internal server error", http.StatusInternalServerError) - return + if _, err = tmpFile.Write(content); err != nil { + _ = tmpFile.Close() + return err } - slog.Info("gateway config updated successfully", "path", nginxUserConfPath) + if err = tmpFile.Close(); err != nil { + return err + } - w.WriteHeader(http.StatusOK) + return os.Rename(tmpFile.Name(), filepath.Join(dir, filename)) } diff --git a/src/semantics/semantic_analysis.go b/src/semantics/semantic_analysis.go index 7adddfd..6ecfe5a 100644 --- a/src/semantics/semantic_analysis.go +++ b/src/semantics/semantic_analysis.go @@ -1,247 +1,247 @@ -package semantics - -import ( - "bytes" - "encoding/json" - "fmt" - "fyp-api-gateway/src/config" - "log/slog" - "net/http" - "strings" - - "gopkg.in/yaml.v3" -) - -type AnalysisResult struct { - Findings []string `json:"findings"` -} - -type RouteView struct { - Path string - Url string - Auth bool - RateLimit config.RateLimit - ZoneName string -} - -type FinalFindings struct { - Errors []string `json:"errors"` - Updates []string `json:"updates"` -} - -func RecvConfig(w http.ResponseWriter, r *http.Request) { - slog.Info("Received config from management plane") - var newCfg config.GatewayConfig - - cookie, err := r.Cookie("session") - if err != nil { - slog.Error("error getting session cookie ", "error", err) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - username := config.RetrieveUserBySessionId(cookie.Value) - - if err = yaml.NewDecoder(r.Body).Decode(&newCfg); err != nil { - slog.Error("Error decoding config", "error", err) - http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) - return - } - - // Analyse the new config file - slog.Info("analysing new config file...") - findings, err := analyse(username, newCfg) - if err != nil { - slog.Error("Error analysing new config", "error", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } - - // send the findings back to the frontend - slog.Info("Sending data back to management plane") - _, err = http.Post("http://management-plane:81/file/findings", "application/json", bytes.NewBuffer(findings)) - if err != nil { - slog.Error("Error posting new config", "error", err) - http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) - return - } -} - -func analyse(username string, newCfg config.GatewayConfig) ([]byte, error) { - oldCfgStr := config.RetrieveUserConfig(username) - - var oldCfg config.GatewayConfig - err := yaml.Unmarshal([]byte(oldCfgStr), &oldCfg) - if err != nil { - slog.Error("Error decoding config", "error", err) - return nil, err - } - - oldConfig := flattenConfig(oldCfg) - newConfig := flattenConfig(newCfg) - foundErrors := validateConfigErrors(oldConfig, newConfig) - foundUpdates := explainDifferences(oldConfig, newConfig) - - finalFindings := FinalFindings{} - finalFindings.Errors = foundErrors - finalFindings.Updates = foundUpdates - findings, err := json.Marshal(finalFindings) - if err != nil { - slog.Error("Error marshalling findings map", "error", err) - return nil, err - } - - return findings, nil -} - -func validateConfigErrors(oldConf, newConf []RouteView) []string { - var findings []string - - // duplicate routes - paths := make(map[string]bool) - for _, r := range newConf { - if paths[r.Path] { - findings = append(findings, "Duplicate routes detected: "+r.Path) - } - paths[r.Path] = true - } - - // route shadowing - for i := 0; i < len(newConf); i++ { - for j := 0; j < len(oldConf); j++ { - if i == j { - continue - } - - r1 := newConf[i] - r2 := newConf[j] - - if pathShadows(r1.Path, r2.Path) { - findings = append(findings, "Route "+r2.Path+" may be shadowed by "+r1.Path) - } - } - } - - return findings -} - -func explainDifferences(oldConf, newConf []RouteView) []string { - var findings []string - - oldRoutes := indexRoutes(oldConf) - newRoutes := indexRoutes(newConf) - - // Detect Added Routes - for key, newRoute := range newRoutes { - _, exists := oldRoutes[key] - if !exists { - findings = append(findings, - "New route added: "+newRoute.Path+ - " on "+newRoute.Url) - - if !newRoute.Auth { - findings = append(findings, - "New public endpoint exposed at "+newRoute.Path) - } - } - } - - // Detect Removed Routes - for key, oldRoute := range oldRoutes { - _, exists := newRoutes[key] - if !exists { - findings = append(findings, - "Route removed: "+oldRoute.Path+ - " on "+oldRoute.Url) - - if oldRoute.Auth { - findings = append(findings, - "Previously protected route "+oldRoute.Path+ - " has been removed") - } - } - } - - // Detect Modified Routes - for key, newRoute := range newRoutes { - oldRoute, exists := oldRoutes[key] - if !exists { - continue - } - - // Auth Widening - if oldRoute.Auth && !newRoute.Auth { - findings = append(findings, - "Authentication removed from route "+newRoute.Path+ - " on "+newRoute.Url) - } - - // Auth Tightening - if !oldRoute.Auth && newRoute.Auth { - findings = append(findings, - "Authentication now required for route "+newRoute.Path) - } - - // Upstream Change - if oldRoute.Url != newRoute.Url { - findings = append(findings, - "Traffic for "+newRoute.Path+" will be routed from "+ - oldRoute.Url+" to "+newRoute.Url) - } - - // Rate Limit Tightening - if newRoute.RateLimit.Rate < oldRoute.RateLimit.Rate { - findings = append(findings, - "Rate limit tightened on "+newRoute.Path+ - " ("+fmt.Sprint(oldRoute.RateLimit.Rate)+ - " → "+fmt.Sprint(newRoute.RateLimit.Rate)+")") - } - - // Rate Limit Relaxed - if newRoute.RateLimit.Rate > oldRoute.RateLimit.Rate { - findings = append(findings, - "Rate limit relaxed on "+newRoute.Path+ - " ("+fmt.Sprint(oldRoute.RateLimit.Rate)+ - " → "+fmt.Sprint(newRoute.RateLimit.Rate)+")") - } - } - - return findings -} - -func flattenConfig(cfg config.GatewayConfig) []RouteView { - var routes []RouteView - - c := cfg.Connections - for _, r := range c.Routes { - zoneName := strings.ReplaceAll(r.Path, "/", "_") - if zoneName == "" { - zoneName = "root" - } - - routes = append(routes, RouteView{ - Path: r.Path, - Url: r.Url, - Auth: r.Auth, - RateLimit: r.RateLimit, - ZoneName: zoneName, - }) - } - - return routes -} - -func indexRoutes(routes []RouteView) map[string]RouteView { - index := make(map[string]RouteView) - for _, r := range routes { - index[r.Path] = r - } - return index -} - -func pathShadows(a, b string) bool { - if a == "/" { - return true - } - return len(b) > len(a) && b[:len(a)] == a -} +package semantics + +import ( + "bytes" + "encoding/json" + "fmt" + "fyp-api-gateway/src/config" + "log/slog" + "net/http" + "strings" + + "gopkg.in/yaml.v3" +) + +type AnalysisResult struct { + Findings []string `json:"findings"` +} + +type RouteView struct { + Path string + Url string + Auth bool + RateLimit config.RateLimit + ZoneName string +} + +type FinalFindings struct { + Errors []string `json:"errors"` + Updates []string `json:"updates"` +} + +func RecvConfig(w http.ResponseWriter, r *http.Request) { + slog.Info("Received config from management plane") + var newCfg config.GatewayConfig + + cookie, err := r.Cookie("session") + if err != nil { + slog.Error("error getting session cookie ", "error", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + username := config.RetrieveUserBySessionId(cookie.Value) + + if err = yaml.NewDecoder(r.Body).Decode(&newCfg); err != nil { + slog.Error("Error decoding config", "error", err) + http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) + return + } + + // Analyse the new config file + slog.Info("analysing new config file...") + findings, err := analyse(username, newCfg) + if err != nil { + slog.Error("Error analysing new config", "error", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + + // send the findings back to the frontend + slog.Info("Sending data back to management plane") + _, err = http.Post("http://management-plane:81/file/findings", "application/json", bytes.NewBuffer(findings)) + if err != nil { + slog.Error("Error posting new config", "error", err) + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } +} + +func analyse(username string, newCfg config.GatewayConfig) ([]byte, error) { + oldCfgStr := config.RetrieveUserConfig(username) + + var oldCfg config.GatewayConfig + err := yaml.Unmarshal([]byte(oldCfgStr), &oldCfg) + if err != nil { + slog.Error("Error decoding config", "error", err) + return nil, err + } + + oldConfig := flattenConfig(oldCfg) + newConfig := flattenConfig(newCfg) + foundErrors := validateConfigErrors(oldConfig, newConfig) + foundUpdates := explainDifferences(oldConfig, newConfig) + + finalFindings := FinalFindings{} + finalFindings.Errors = foundErrors + finalFindings.Updates = foundUpdates + findings, err := json.Marshal(finalFindings) + if err != nil { + slog.Error("Error marshalling findings map", "error", err) + return nil, err + } + + return findings, nil +} + +func validateConfigErrors(oldConf, newConf []RouteView) []string { + var findings []string + + // duplicate routes + paths := make(map[string]bool) + for _, r := range newConf { + if paths[r.Path] { + findings = append(findings, "Duplicate routes detected: "+r.Path) + } + paths[r.Path] = true + } + + // route shadowing + for i := 0; i < len(newConf); i++ { + for j := 0; j < len(oldConf); j++ { + if i == j { + continue + } + + r1 := newConf[i] + r2 := newConf[j] + + if pathShadows(r1.Path, r2.Path) { + findings = append(findings, "Route "+r2.Path+" may be shadowed by "+r1.Path) + } + } + } + + return findings +} + +func explainDifferences(oldConf, newConf []RouteView) []string { + var findings []string + + oldRoutes := indexRoutes(oldConf) + newRoutes := indexRoutes(newConf) + + // Detect Added Routes + for key, newRoute := range newRoutes { + _, exists := oldRoutes[key] + if !exists { + findings = append(findings, + "New route added: "+newRoute.Path+ + " on "+newRoute.Url) + + if !newRoute.Auth { + findings = append(findings, + "New public endpoint exposed at "+newRoute.Path) + } + } + } + + // Detect Removed Routes + for key, oldRoute := range oldRoutes { + _, exists := newRoutes[key] + if !exists { + findings = append(findings, + "Route removed: "+oldRoute.Path+ + " on "+oldRoute.Url) + + if oldRoute.Auth { + findings = append(findings, + "Previously protected route "+oldRoute.Path+ + " has been removed") + } + } + } + + // Detect Modified Routes + for key, newRoute := range newRoutes { + oldRoute, exists := oldRoutes[key] + if !exists { + continue + } + + // Auth Widening + if oldRoute.Auth && !newRoute.Auth { + findings = append(findings, + "Authentication removed from route "+newRoute.Path+ + " on "+newRoute.Url) + } + + // Auth Tightening + if !oldRoute.Auth && newRoute.Auth { + findings = append(findings, + "Authentication now required for route "+newRoute.Path) + } + + // Upstream Change + if oldRoute.Url != newRoute.Url { + findings = append(findings, + "Traffic for "+newRoute.Path+" will be routed from "+ + oldRoute.Url+" to "+newRoute.Url) + } + + // Rate Limit Tightening + if newRoute.RateLimit.Rate < oldRoute.RateLimit.Rate { + findings = append(findings, + "Rate limit tightened on "+newRoute.Path+ + " ("+fmt.Sprint(oldRoute.RateLimit.Rate)+ + " → "+fmt.Sprint(newRoute.RateLimit.Rate)+")") + } + + // Rate Limit Relaxed + if newRoute.RateLimit.Rate > oldRoute.RateLimit.Rate { + findings = append(findings, + "Rate limit relaxed on "+newRoute.Path+ + " ("+fmt.Sprint(oldRoute.RateLimit.Rate)+ + " → "+fmt.Sprint(newRoute.RateLimit.Rate)+")") + } + } + + return findings +} + +func flattenConfig(cfg config.GatewayConfig) []RouteView { + var routes []RouteView + + c := cfg.Connections + for _, r := range c.Routes { + zoneName := strings.ReplaceAll(r.Path, "/", "_") + if zoneName == "" { + zoneName = "root" + } + + routes = append(routes, RouteView{ + Path: r.Path, + Url: r.Url, + Auth: r.Auth, + RateLimit: r.RateLimit, + ZoneName: zoneName, + }) + } + + return routes +} + +func indexRoutes(routes []RouteView) map[string]RouteView { + index := make(map[string]RouteView) + for _, r := range routes { + index[r.Path] = r + } + return index +} + +func pathShadows(a, b string) bool { + if a == "/" { + return true + } + return len(b) > len(a) && b[:len(a)] == a +} diff --git a/src/templates/nginx.conf.tmpl b/src/templates/nginx.conf.tmpl index a74a844..7cab95c 100644 --- a/src/templates/nginx.conf.tmpl +++ b/src/templates/nginx.conf.tmpl @@ -1,38 +1,26 @@ {{- range .Connections.Routes }} -{{- if .RateLimit }} -limit_req_zone $binary_remote_addr zone={{ $.Username }}_{{ .ZoneName }}:{{ .RateLimit.Zone }}m rate={{ .RateLimit.Rate }}r/s; -{{- end }} -{{- end }} - -{{/* Server blocks */}} -server { - listen 8080; - server_name _; - - access_log /var/log/nginx/users/{{ .Username }}/access.log; - error_log /var/log/nginx/users/{{ .Username }}/error.log; +location /{{ $.Username }}_{{ .Path }} { - {{- range .Connections.Routes }} - location {{ .Path }} { + access_log /var/log/nginx/users/{{ $.Username }}/access.log; + error_log /var/log/nginx/users/{{ $.Username }}/error.log; - {{- if .Auth }} - auth_basic "Restricted"; - auth_basic_user_file /etc/nginx/.htpasswd; - {{- end }} + {{- if .Auth }} + auth_basic "Restricted"; + auth_basic_user_file /etc/nginx/.htpasswd; + {{- end }} - {{- if .RateLimit }} - limit_req zone={{ $.Username }}_{{ .ZoneName }}; - {{- end }} + {{- if .RateLimit }} + limit_req zone={{ $.Username }}_{{ .ZoneName }}; + {{- end }} - proxy_http_version 1.1; - proxy_set_header Connection ""; + proxy_http_version 1.1; + proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass {{ .Url }}; - } - {{- end }} -} \ No newline at end of file + proxy_pass {{ .Url }}; +} +{{- end }} diff --git a/src/templates/zone.conf.tmpl b/src/templates/zone.conf.tmpl new file mode 100644 index 0000000..633e3cf --- /dev/null +++ b/src/templates/zone.conf.tmpl @@ -0,0 +1,5 @@ +{{- range .Connections.Routes }} +{{- if .RateLimit }} +limit_req_zone $binary_remote_addr zone={{ $.Username }}_{{ .ZoneName }}:{{ .RateLimit.Zone }}m rate={{ .RateLimit.Rate }}r/s; +{{- end }} +{{- end }} diff --git a/src/utils/defaults.go b/src/utils/defaults.go index 2e6cd63..5c0d9a4 100644 --- a/src/utils/defaults.go +++ b/src/utils/defaults.go @@ -1,14 +1,14 @@ -package utils - -var ( - GatewayConfigDirName = "/etc/config/" - GatewayConfigFileName = "gateway.yaml" - - NGINXDirName = "/etc/nginx/" - NGINXTemplateDirName = "/etc/nginx/" - NGINXConfigFileName = "nginx.conf" - NGINXTemplateFileName = "nginx.conf.tmpl" - NGINXUserDirName = "/etc/nginx/users/" - - DefaultConfigContent = "#\n# Configuration file for API Gateway\n#\n\nconnections:\n routes:\n - path: /products\n url: http://services:9001\n rate-limit:\n zone: 10\n rate: 5\n auth: false\n zone-name: root\n\n - path: /orders\n url: http://services:9002\n rate-limit:\n zone: 10\n rate: 5\n auth: false\n zone-name: root\n\n - path: /protected\n url: http://services:9003\n rate-limit:\n zone: 10\n rate: 5\n auth: true\n zone-name: root\n\n - path: /external-weather\n url: https://api.open-meteo.com/v1/forecast?latitude=51.898&longitude=-8.4706&hourly=temperature_2m/\n rate-limit:\n zone: 10\n rate: 5\n auth: false\n zone-name: root" -) +package utils + +var ( + NGINXDirName = "/etc/nginx/" + NGINXTemplateDirName = "/etc/nginx/" + NGINXConfigFileName = "nginx.conf" + NGINXZoneFileName = "zone.conf" + + NGINXUserDirName = "/etc/nginx/users/" + NGINXTemplateFileName = "nginx.conf.tmpl" + NGINXZoneTemplateFileName = "zone.conf.tmpl" + + DefaultConfigContent = "#\n# Configuration file for API Gateway\n#\n\n#connections:\n# routes:\n# - path: \n# url:\n# rate-limit:\n# zone: \n# rate:\n# auth:" +) diff --git a/src/watcher/watcher.go b/src/watcher/watcher.go index a846ec9..f07cd5e 100644 --- a/src/watcher/watcher.go +++ b/src/watcher/watcher.go @@ -37,21 +37,27 @@ func Watch() { slog.Error("error adding new user directory to watcher", "error", err) } - confPath := filepath.Join(event.Name, "nginx.conf") - if _, err := os.Stat(confPath); err == nil { - sendNginxToDataplane(confPath) + zonePath := filepath.Join(event.Name, utils.NGINXZoneFileName) + serverPath := filepath.Join(event.Name, utils.NGINXConfigFileName) + _, zoneErr := os.Stat(zonePath) + _, serverErr := os.Stat(serverPath) + if zoneErr == nil && serverErr == nil { + sendNginxToDataplane(zonePath, serverPath) + continue } - continue } } - if filepath.Base(event.Name) == "nginx.conf" { - if event.Has(fsnotify.Write) || event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) { + base := filepath.Base(event.Name) + if base == utils.NGINXConfigFileName || base == utils.NGINXZoneFileName { + if event.Has(fsnotify.Rename) || event.Has(fsnotify.Create) || event.Has(fsnotify.Remove) { slog.Info("watcher detected modified file:", "file", event.Name) - // send the config to the dataplane! - sendNginxToDataplane(event.Name) + userDir := filepath.Dir(event.Name) + zonePath := filepath.Join(userDir, utils.NGINXZoneFileName) + serverPath := filepath.Join(userDir, utils.NGINXConfigFileName) + sendNginxToDataplane(zonePath, serverPath) } } case err, ok := <-watcher.Errors: @@ -95,26 +101,29 @@ func addUserDirs(w *fsnotify.Watcher, root string) error { return nil } -func sendNginxToDataplane(filename string) { - body, err := os.ReadFile(filename) - if err != nil { - slog.Error("error opening file", "filename", filename, "error", err) - return - } +func sendNginxToDataplane(zonePath, serverPath string) { + files := []string{zonePath, serverPath} type SendData struct { - Filename string `json:"filename"` - Body []byte `json:"body"` + Files map[string][]byte `json:"files"` } - sendData := SendData{ - Filename: filename, - Body: body, + payload := SendData{ + Files: make(map[string][]byte), } - data, err := json.Marshal(sendData) + for _, file := range files { + body, err := os.ReadFile(file) + if err != nil { + slog.Error("error reading file", "file", file) + return + } + payload.Files[file] = body + } + + data, err := json.Marshal(payload) if err != nil { - slog.Error("error encoding file", "filename", filename, "error", err) + slog.Error("error encoding file", "error", err) return } @@ -128,6 +137,8 @@ func sendNginxToDataplane(filename string) { return } + req.Header.Set("Content-Type", "application/json") + client := http.Client{} resp, err := client.Do(req) if err != nil {