diff --git a/README.md b/README.md index 4a8675f..0c33b65 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ The default configuration: - config - This allows you to set ANY configuration item from the config, example below: Example instance with extra configuration: -**NOTE:** Most settings that are filepaths should be left alone in the instance config settings, and will probably be overwritten anyways (like the ModPaths setting). +**NOTE:** Most settings that are filepaths should be left alone in the instance config settings, as they will be overwritten automatically (like the ModPaths setting). +**NOTE:** Roles settings must be modified in the actual instance config ```json "test_server": { "Version": "1.22.3", @@ -84,21 +85,21 @@ Example instance with extra configuration: } ``` - - - ## Web Interface -Alongside this project, I am developing a seperate web-based dashboard that can be used to manage you servers with a very simple UI. You can get it [here](https://git.bellsworne.tech/chrisbell/vssm_web). + +### NEW: [v1.0.0 Bundled Release (Includes Web Interface)](https://git.bellsworne.tech/chrisbell/vssm_full/releases/tag/v1.0.0) + +Alongside this project, I am developing a seperate web-based dashboard that can be used to manage you servers with a very simple UI. You can get it [HERE](https://git.bellsworne.tech/chrisbell/vssm_web). ## Roadmap -- [ ] Configuration +- [x] Configuration - [x] Custom configuration path (passed to the daemon with a flag `--config`) - [x] Daemon instance remembers config path (*so you don't have to pass `--config` everytime you run an ipc command*) - [x] Daemon listen port - [x] Declarative Server configuration (Create servers in config) - [x] All configuration options - - [ ] Endpoint for modifying instance configuration (for web ui) + - [x] Endpoint for modifying instance configuration (for web ui) - [ ] Server management - [x] Create servers diff --git a/config.go b/config.go index 6c28f85..49df1d0 100644 --- a/config.go +++ b/config.go @@ -19,6 +19,7 @@ type AppConfig struct { Daemon struct { ListenAddress string `json:"listen_address"` Port int `json:"port"` + AuthToken string `json:"auth_token"` } `json:"daemon"` Instances map[string]InstanceMetadata `json:"instances"` diff --git a/daemon.go b/daemon.go index 582bd69..2180918 100644 --- a/daemon.go +++ b/daemon.go @@ -1,19 +1,26 @@ package main import ( + "embed" "encoding/json" "fmt" + "io" + "io/fs" "net/http" "os" "os/signal" "path/filepath" "slices" "strconv" + "strings" "sync" "syscall" "time" ) +//go:embed web/dist +var webFS embed.FS + type CommandPayload struct { Command string `json:"command"` } @@ -48,17 +55,48 @@ func StartDaemon(cfg *AppConfig, configPath string) error { mux.HandleFunc("/instances/logs", ds.handleGetLogs) mux.HandleFunc("/instances/delete", ds.handleDelete) mux.HandleFunc("/instances/modify", ds.handleModify) + mux.HandleFunc("/instances/getConfig", ds.handleGetConfig) + mux.HandleFunc("/instances/setConfig", ds.handleSetConfig) + + webContent, err := fs.Sub(webFS, "web/dist") + if err != nil { + return fmt.Errorf("failed to load embedded web assets: %w", err) + } + mux.Handle("/", http.FileServer(http.FS(webContent))) corsWrappedHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } + authCheck := func() bool { + if !strings.HasPrefix(r.URL.Path, "/instances/") { + return true + } + + ds.RLock() + defer ds.RUnlock() + if ds.cfg.Daemon.AuthToken != "" { + authHeader := r.Header.Get("Authorization") + expected := "Bearer " + ds.cfg.Daemon.AuthToken + if authHeader != expected { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return false + } + } + + return true + }() + + if !authCheck { + return + } + mux.ServeHTTP(w, r) }) @@ -549,3 +587,86 @@ func (ds *DaemonServer) handleModify(w http.ResponseWriter, r *http.Request) { fmt.Fprintf(w, "Successfully modified instance %s", name) } + +func (ds *DaemonServer) handleGetConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + exists := func() bool { + ds.RLock() + defer ds.RUnlock() + _, ok := ds.cfg.Instances[name] + return ok + }() + if !exists { + http.Error(w, fmt.Sprintf("Cannot modify instance config for: '%s'; Does not exist.", name), http.StatusBadRequest) + return + } + + confPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") + data, err := os.ReadFile(confPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to get instance config: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.Write(data) +} + +func (ds *DaemonServer) handleSetConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Missing name parameter", http.StatusBadRequest) + return + } + + exists := func() bool { + ds.RLock() + defer ds.RUnlock() + _, ok := ds.cfg.Instances[name] + return ok + }() + if !exists { + http.Error(w, fmt.Sprintf("Cannot modify instance config for: '%s'; Does not exist.", name), http.StatusBadRequest) + return + } + + running := func() bool { + ds.procManager.RLock() + defer ds.procManager.RUnlock() + _, ok := ds.procManager.ActiveInstances[name] + return ok + }() + if running { + http.Error(w, fmt.Sprintf("Cannot modify instance config for: '%s'; It is currently running.", name), http.StatusBadRequest) + return + } + + data, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to set instance config: %v", err), http.StatusInternalServerError) + return + } + + confPath := filepath.Join(ds.cfg.Storage.InstancesDir, name, "serverconfig.json") + if err = os.WriteFile(confPath, data, 0644); err != nil { + http.Error(w, fmt.Sprintf("Failed to get instance config: %v", err), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusOK) +} diff --git a/instance_config.go b/instance_config.go index 126015b..16929b9 100644 --- a/instance_config.go +++ b/instance_config.go @@ -62,7 +62,15 @@ func SyncInstanceConfig(templateVersion string, instanceConfigPath string, meta } for key, value := range meta.Config { - rawConfig[key] = value + if sub, ok := value.(map[string]interface{}); ok { + if existing, ok := rawConfig[key].(map[string]interface{}); ok { + for k, v := range sub { + existing[k] = v + } + } + } else { + rawConfig[key] = value + } } rawConfig["ServerName"] = meta.ServerName diff --git a/main.go b/main.go index b715cdc..b763ecb 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "flag" "fmt" @@ -13,6 +15,7 @@ import ( "path/filepath" "strconv" "text/tabwriter" + "time" ) func main() { @@ -71,11 +74,42 @@ func main() { switch subCommand { case "daemon": + if isDaemonRunning(cfg) { + log.Fatalf("Cannot start daemon as it is already running. Stop it first (`pkill vssm`).") + } + fmt.Printf("Initializing VSSM Daemon [Config: %s]...\n", absConfigPath) if err := StartDaemon(cfg, absConfigPath); err != nil { log.Fatalf("Daemon runtime fatal error: %v", err) } + case "generate-token": + if isDaemonRunning(cfg) { + log.Fatalf("Cannot generate a new token while the daemon is running. Stop it first (`pkill vssm`).") + } + + token, err := generateToken() + if err != nil { + log.Fatalf("Failed to generate token: %v", err) + } + + overwritten := cfg.Daemon.AuthToken != "" + cfg.Daemon.AuthToken = token + + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + log.Fatalf("Failed to marshal config: %v", err) + } + + if err := os.WriteFile(absConfigPath, data, 0644); err != nil { + log.Fatalf("Failed to write config: %v", err) + } + + if overwritten { + fmt.Println("Existing auth token was overwritten.") + } + fmt.Printf("New auth token: %s\n", token) + case "create": if len(args) < 4 { log.Fatalf("Usage: vssm create ") @@ -156,6 +190,10 @@ func sendIPCRequest(cfg *AppConfig, method string, path string, body io.Reader) req.Header.Set("Content-Type", "application/json") } + if cfg.Daemon.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+cfg.Daemon.AuthToken) + } + resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalf("IPC connection failed. Is the VSSM daemon running? Error: %v", err) @@ -174,7 +212,17 @@ func sendIPCRequest(cfg *AppConfig, method string, path string, body io.Reader) func fetchAndPrintStatus(cfg *AppConfig) { listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) targetUrl := fmt.Sprintf("http://%s/instances/list", listenAddress) - resp, err := http.Get(targetUrl) + + req, err := http.NewRequest("GET", targetUrl, nil) + if err != nil { + log.Fatalf("Failed to construct status request: %v", err) + } + + if cfg.Daemon.AuthToken != "" { + req.Header.Set("Authorization", "Bearer "+cfg.Daemon.AuthToken) + } + + resp, err := http.DefaultClient.Do(req) if err != nil { log.Fatalf("IPC connection failed. Is the VSSM daemon running? Error: %v", err) } @@ -205,16 +253,44 @@ func fetchAndPrintStatus(cfg *AppConfig) { w.Flush() } +func generateToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func isDaemonRunning(cfg *AppConfig) bool { + listenAddress := cfg.Daemon.ListenAddress + ":" + strconv.Itoa(cfg.Daemon.Port) + client := http.Client{Timeout: 500 * time.Millisecond} + resp, err := client.Get(fmt.Sprintf("http://%s/instances/list", listenAddress)) + if err != nil { + return false + } + resp.Body.Close() + return true +} + func printUsage() { fmt.Println("VintageStory Server Manager") fmt.Println("\nGlobal Options:") - fmt.Println(" --config Explicitly target a non-default config structure file location") + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, " --config \tExplicitly target a non-default config structure file location") + w.Flush() + fmt.Println("\nUsage Commands:") - fmt.Println(" vssm daemon Starts the daemon") - fmt.Println(" vssm create Creates the instance configuration and profile") - fmt.Println(" vssm delete Deletes an instance profile, optionally deletes all files with the `--purge` flag") - fmt.Println(" vssm start Launches an existing server instance from a stored profile") - fmt.Println(" vssm stop Gracefully shuts down a server instance") - fmt.Println(" vssm cmd \"\" Sends a command to an instance") - fmt.Println(" vssm list Displays a list of all instances and their status") + + w = tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) + fmt.Fprintln(w, " vssm daemon\tStarts the daemon") + fmt.Fprintln(w, " vssm generate-token\tGenerates a new auth token and stores it in the config") + fmt.Fprintln(w, " vssm create \tCreates the instance configuration and profile") + fmt.Fprintln(w, " vssm delete [--purge]\tDeletes an instance profile, optionally deleting all files") + fmt.Fprintln(w, " vssm start \tLaunches an existing server instance from a stored profile") + fmt.Fprintln(w, " vssm stop \tGracefully shuts down a server instance") + fmt.Fprintln(w, " vssm cmd \"\"\tSends a command to an instance") + fmt.Fprintln(w, " vssm modify \tRenames an instance and/or changes its port") + fmt.Fprintln(w, " vssm list\tDisplays a list of all instances and their status") + w.Flush() } diff --git a/web/dist/index.html b/web/dist/index.html new file mode 100644 index 0000000..e878447 --- /dev/null +++ b/web/dist/index.html @@ -0,0 +1,2 @@ + +Web UI not embedded in this build. \ No newline at end of file