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 c9d2e5c..e68b703 100644
--- a/dataplane/main.go
+++ b/dataplane/main.go
@@ -10,81 +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("reloading 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 3d3e03a..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 {{ .Username }};
-
- 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 212f0a8..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 {