diff --git a/third_party/agfs/agfs-server/README.md b/third_party/agfs/agfs-server/README.md index 749ab4339..09cf61582 100644 --- a/third_party/agfs/agfs-server/README.md +++ b/third_party/agfs/agfs-server/README.md @@ -116,6 +116,7 @@ The server is configured using a YAML file (default: `config.yaml`). server: address: ":8080" log_level: info # debug, info, warn, error + management_api_enabled: false # Enable only on trusted/admin-only networks # External plugins configuration external_plugins: @@ -191,6 +192,8 @@ AGFS Server comes with a rich set of built-in plugins. You can mount, unmount, and manage plugins at runtime using the API. +This management surface is disabled by default. To enable it, set `server.management_api_enabled: true` in `config.yaml` and only expose the server on a trusted admin network. + **Mount a plugin**: ```bash curl -X POST http://localhost:8080/api/v1/mount \ @@ -245,12 +248,12 @@ All API endpoints are prefixed with `/api/v1/`. | | `GET` | `/stat` | Get file metadata | | **Directories** | `GET` | `/directories` | List directory contents | | | `POST` | `/directories` | Create directory | -| **Management** | `GET` | `/mounts` | List active mounts | -| | `POST` | `/mount` | Mount a plugin | -| | `POST` | `/unmount` | Unmount a plugin | -| | `GET` | `/plugins` | List loaded external plugins | -| | `POST` | `/plugins/load` | Load an external plugin | -| | `POST` | `/plugins/unload` | Unload an external plugin | +| **Management** | `GET` | `/mounts` | List active mounts, when `server.management_api_enabled=true` | +| | `POST` | `/mount` | Mount a plugin, when `server.management_api_enabled=true` | +| | `POST` | `/unmount` | Unmount a plugin, when `server.management_api_enabled=true` | +| | `GET` | `/plugins` | List loaded external plugins, when `server.management_api_enabled=true` | +| | `POST` | `/plugins/load` | Load an external plugin, when `server.management_api_enabled=true` | +| | `POST` | `/plugins/unload` | Unload an external plugin, when `server.management_api_enabled=true` | | **System** | `GET` | `/health` | Server health check | ## Development @@ -267,4 +270,4 @@ All API endpoints are prefixed with `/api/v1/`. ## License -Apache License 2.0 \ No newline at end of file +Apache License 2.0 diff --git a/third_party/agfs/agfs-server/api.md b/third_party/agfs/agfs-server/api.md index 04fcf8f2b..b4b168433 100644 --- a/third_party/agfs/agfs-server/api.md +++ b/third_party/agfs/agfs-server/api.md @@ -323,6 +323,8 @@ curl -X POST "http://localhost:8080/api/v1/chmod?path=/memfs/data.txt" \ ### List Mounts List all currently mounted plugins. +This endpoint is available only when `server.management_api_enabled=true`. + **Endpoint:** `GET /api/v1/mounts` **Response:** @@ -346,6 +348,8 @@ curl "http://localhost:8080/api/v1/mounts" ### Mount Plugin Mount a new plugin instance. +This endpoint is available only when `server.management_api_enabled=true`. + **Endpoint:** `POST /api/v1/mount` **Body:** @@ -388,6 +392,8 @@ curl -X POST "http://localhost:8080/api/v1/unmount" \ ### List Plugins List all available (loaded) plugins, including external ones. +This endpoint is available only when `server.management_api_enabled=true`. + **Endpoint:** `GET /api/v1/plugins` **Response:** @@ -416,6 +422,8 @@ curl "http://localhost:8080/api/v1/plugins" ### Load External Plugin Load a dynamic library plugin (.so/.dylib/.dll) or WASM plugin. +This endpoint is available only when `server.management_api_enabled=true`. + **Endpoint:** `POST /api/v1/plugins/load` **Body:** @@ -436,6 +444,8 @@ curl -X POST "http://localhost:8080/api/v1/plugins/load" \ ### Unload External Plugin Unload a previously loaded external plugin. +This endpoint is available only when `server.management_api_enabled=true`. + **Endpoint:** `POST /api/v1/plugins/unload` **Body:** diff --git a/third_party/agfs/agfs-server/cmd/server/main.go b/third_party/agfs/agfs-server/cmd/server/main.go index e212a48e9..4ae315667 100644 --- a/third_party/agfs/agfs-server/cmd/server/main.go +++ b/third_party/agfs/agfs-server/cmd/server/main.go @@ -66,6 +66,7 @@ const sampleConfig = `# AGFS Server Configuration File server: address: ":8080" # Server listen address log_level: "info" # Log level: debug, info, warn, error + management_api_enabled: false # Set true to allow runtime mount/plugin changes over HTTP # Plugin configurations plugins: @@ -360,7 +361,13 @@ func main() { // Create handlers handler := handlers.NewHandler(mfs, trafficMonitor) handler.SetVersionInfo(Version, GitCommit, BuildTime) - pluginHandler := handlers.NewPluginHandler(mfs) + pluginHandler := handlers.NewPluginHandler(mfs, cfg.Server.ManagementAPIEnabled) + + if cfg.Server.ManagementAPIEnabled { + log.Warn("AGFS management API enabled: runtime mount and external plugin changes are available over HTTP") + } else { + log.Info("AGFS management API disabled: runtime mount and external plugin endpoints will return 403") + } // Setup routes mux := http.NewServeMux() diff --git a/third_party/agfs/agfs-server/config.example.yaml b/third_party/agfs/agfs-server/config.example.yaml index a91050f5e..a73b0c0ef 100644 --- a/third_party/agfs/agfs-server/config.example.yaml +++ b/third_party/agfs/agfs-server/config.example.yaml @@ -3,6 +3,7 @@ server: address: ":8080" log_level: info # Options: debug, info, warn, error + management_api_enabled: false # Set true only on trusted/admin-only networks plugins: serverinfofs: diff --git a/third_party/agfs/agfs-server/config.yaml b/third_party/agfs/agfs-server/config.yaml index b39d34869..52da32e68 100644 --- a/third_party/agfs/agfs-server/config.yaml +++ b/third_party/agfs/agfs-server/config.yaml @@ -3,6 +3,7 @@ server: address: ":8080" log_level: info # Options: debug, info, warn, error + management_api_enabled: false # Set true only on trusted/admin-only networks plugins: serverinfofs: diff --git a/third_party/agfs/agfs-server/pkg/config/config.go b/third_party/agfs/agfs-server/pkg/config/config.go index 50f4b09a2..2319dbc08 100644 --- a/third_party/agfs/agfs-server/pkg/config/config.go +++ b/third_party/agfs/agfs-server/pkg/config/config.go @@ -16,8 +16,9 @@ type Config struct { // ServerConfig contains server-level configuration type ServerConfig struct { - Address string `yaml:"address"` - LogLevel string `yaml:"log_level"` + Address string `yaml:"address"` + LogLevel string `yaml:"log_level"` + ManagementAPIEnabled bool `yaml:"management_api_enabled"` } // ExternalPluginsConfig contains configuration for external plugins diff --git a/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go b/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go index 8b1139051..bad1e1f60 100644 --- a/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go +++ b/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers.go @@ -20,12 +20,29 @@ import ( // PluginHandler handles plugin management operations type PluginHandler struct { - mfs *mountablefs.MountableFS + mfs *mountablefs.MountableFS + managementAPIEnabled bool } // NewPluginHandler creates a new plugin handler -func NewPluginHandler(mfs *mountablefs.MountableFS) *PluginHandler { - return &PluginHandler{mfs: mfs} +func NewPluginHandler(mfs *mountablefs.MountableFS, managementAPIEnabled bool) *PluginHandler { + return &PluginHandler{ + mfs: mfs, + managementAPIEnabled: managementAPIEnabled, + } +} + +func (ph *PluginHandler) requireManagementAPI(w http.ResponseWriter) bool { + if ph.managementAPIEnabled { + return true + } + + writeError( + w, + http.StatusForbidden, + "management API is disabled; set server.management_api_enabled=true to allow runtime mount and plugin changes", + ) + return false } // MountInfo represents information about a mounted plugin @@ -42,6 +59,10 @@ type ListMountsResponse struct { // ListMounts handles GET /mounts func (ph *PluginHandler) ListMounts(w http.ResponseWriter, r *http.Request) { + if !ph.requireManagementAPI(w) { + return + } + mounts := ph.mfs.GetMounts() var mountInfos []MountInfo @@ -63,6 +84,10 @@ type UnmountRequest struct { // Unmount handles POST /unmount func (ph *PluginHandler) Unmount(w http.ResponseWriter, r *http.Request) { + if !ph.requireManagementAPI(w) { + return + } + var req UnmountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -91,6 +116,10 @@ type MountRequest struct { // Mount handles POST /mount func (ph *PluginHandler) Mount(w http.ResponseWriter, r *http.Request) { + if !ph.requireManagementAPI(w) { + return + } + var req MountRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -248,6 +277,10 @@ func (ph *PluginHandler) readPluginFromAGFS(agfsPath string) (string, error) { // LoadPlugin handles POST /plugins/load func (ph *PluginHandler) LoadPlugin(w http.ResponseWriter, r *http.Request) { + if !ph.requireManagementAPI(w) { + return + } + var req LoadPluginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -316,6 +349,10 @@ type UnloadPluginRequest struct { // UnloadPlugin handles POST /plugins/unload func (ph *PluginHandler) UnloadPlugin(w http.ResponseWriter, r *http.Request) { + if !ph.requireManagementAPI(w) { + return + } + var req UnloadPluginRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid request body") @@ -357,6 +394,10 @@ type ListPluginsResponse struct { // ListPlugins handles GET /plugins func (ph *PluginHandler) ListPlugins(w http.ResponseWriter, r *http.Request) { + if !ph.requireManagementAPI(w) { + return + } + // Get all mounts mounts := ph.mfs.GetMounts() diff --git a/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers_test.go b/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers_test.go new file mode 100644 index 000000000..fc831d75f --- /dev/null +++ b/third_party/agfs/agfs-server/pkg/handlers/plugin_handlers_test.go @@ -0,0 +1,77 @@ +package handlers + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/c4pt0r/agfs/agfs-server/pkg/mountablefs" + "github.com/c4pt0r/agfs/agfs-server/pkg/plugin/api" +) + +func TestManagementEndpointsDisabledByDefault(t *testing.T) { + mfs := mountablefs.NewMountableFS(api.PoolConfig{}) + handler := NewPluginHandler(mfs, false) + mux := http.NewServeMux() + handler.SetupRoutes(mux) + + tests := []struct { + name string + method string + target string + }{ + {name: "list mounts", method: http.MethodGet, target: "/api/v1/mounts"}, + {name: "mount plugin", method: http.MethodPost, target: "/api/v1/mount"}, + {name: "unmount plugin", method: http.MethodPost, target: "/api/v1/unmount"}, + {name: "list plugins", method: http.MethodGet, target: "/api/v1/plugins"}, + {name: "load plugin", method: http.MethodPost, target: "/api/v1/plugins/load"}, + {name: "unload plugin", method: http.MethodPost, target: "/api/v1/plugins/unload"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.target, nil) + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden) + } + }) + } +} + +func TestManagementEndpointsEnabledPreserveExistingValidation(t *testing.T) { + mfs := mountablefs.NewMountableFS(api.PoolConfig{}) + handler := NewPluginHandler(mfs, true) + mux := http.NewServeMux() + handler.SetupRoutes(mux) + + tests := []struct { + name string + method string + target string + wantStatus int + }{ + {name: "list mounts", method: http.MethodGet, target: "/api/v1/mounts", wantStatus: http.StatusOK}, + {name: "mount plugin", method: http.MethodPost, target: "/api/v1/mount", wantStatus: http.StatusBadRequest}, + {name: "unmount plugin", method: http.MethodPost, target: "/api/v1/unmount", wantStatus: http.StatusBadRequest}, + {name: "list plugins", method: http.MethodGet, target: "/api/v1/plugins", wantStatus: http.StatusOK}, + {name: "load plugin", method: http.MethodPost, target: "/api/v1/plugins/load", wantStatus: http.StatusBadRequest}, + {name: "unload plugin", method: http.MethodPost, target: "/api/v1/plugins/unload", wantStatus: http.StatusBadRequest}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(tt.method, tt.target, nil) + rec := httptest.NewRecorder() + + mux.ServeHTTP(rec, req) + + if rec.Code != tt.wantStatus { + t.Fatalf("status = %d, want %d", rec.Code, tt.wantStatus) + } + }) + } +}