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 c7026f0..a023dbc 100644 --- a/daemon.go +++ b/daemon.go @@ -55,13 +55,32 @@ func StartDaemon(cfg *AppConfig, configPath string) error { 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 { + 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) }) 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() }