package main import ( "context" "encoding/json" "fmt" "io" "net/http" "time" "next.orly.dev/pkg/lol/chk" "next.orly.dev/pkg/lol/log" ) // AdminServer provides HTTP endpoints for managing the launcher. type AdminServer struct { cfg *Config supervisor *Supervisor updater *Updater auth *AuthMiddleware server *http.Server startTime time.Time } // NewAdminServer creates a new admin HTTP server. func NewAdminServer(cfg *Config, supervisor *Supervisor) *AdminServer { return &AdminServer{ cfg: cfg, supervisor: supervisor, updater: NewUpdater(cfg.BinDir), auth: NewAuthMiddleware(cfg.AdminOwners), startTime: time.Now(), } } // Start starts the admin HTTP server. func (s *AdminServer) Start(ctx context.Context) error { mux := http.NewServeMux() // Public endpoints mux.HandleFunc("/admin", s.serveUI) mux.HandleFunc("/admin/", s.serveUI) // Authenticated API endpoints mux.HandleFunc("/api/status", s.auth.RequireAuth(s.handleStatus)) mux.HandleFunc("/api/config", s.auth.RequireAuth(s.handleConfig)) mux.HandleFunc("/api/binaries", s.auth.RequireAuth(s.handleBinaries)) mux.HandleFunc("/api/update", s.auth.RequireAuth(s.handleUpdate)) mux.HandleFunc("/api/releases", s.auth.RequireAuth(s.handleReleases)) mux.HandleFunc("/api/restart", s.auth.RequireAuth(s.handleRestart)) mux.HandleFunc("/api/restart-service", s.auth.RequireAuth(s.handleRestartService)) mux.HandleFunc("/api/rollback", s.auth.RequireAuth(s.handleRollback)) mux.HandleFunc("/api/start-services", s.auth.RequireAuth(s.handleStartServices)) mux.HandleFunc("/api/stop-services", s.auth.RequireAuth(s.handleStopServices)) mux.HandleFunc("/api/start-service", s.auth.RequireAuth(s.handleStartService)) mux.HandleFunc("/api/stop-service", s.auth.RequireAuth(s.handleStopService)) addr := fmt.Sprintf(":%d", s.cfg.AdminPort) s.server = &http.Server{ Addr: addr, Handler: mux, } log.I.F("starting admin server on %s", addr) go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.server.Shutdown(shutdownCtx) }() return s.server.ListenAndServe() } // StatusResponse is the response for GET /api/status type StatusResponse struct { Version string `json:"version"` Uptime string `json:"uptime"` ServicesRunning bool `json:"services_running"` Processes []ProcessStatus `json:"processes"` } // ProcessStatus represents the status of a single managed process. type ProcessStatus struct { Name string `json:"name"` Binary string `json:"binary"` Version string `json:"version"` Status string `json:"status"` // running, stopped, disabled Enabled bool `json:"enabled"` Category string `json:"category"` // database, acl, sync, certs, relay Description string `json:"description"` PID int `json:"pid"` Restarts int `json:"restarts"` StartedAt string `json:"started_at,omitempty"` } func (s *AdminServer) handleStatus(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } uptime := time.Since(s.startTime).Round(time.Second).String() processes := s.supervisor.GetProcessStatuses() response := StatusResponse{ Version: s.updater.CurrentVersion(), Uptime: uptime, ServicesRunning: s.supervisor.IsRunning(), Processes: processes, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // ConfigResponse is the response for GET /api/config type ConfigResponse struct { DBBackend string `json:"db_backend"` DBBinary string `json:"db_binary"` RelayBinary string `json:"relay_binary"` ACLBinary string `json:"acl_binary"` DBListen string `json:"db_listen"` ACLListen string `json:"acl_listen"` ACLEnabled bool `json:"acl_enabled"` ACLMode string `json:"acl_mode"` DataDir string `json:"data_dir"` LogLevel string `json:"log_level"` DistributedSyncEnabled bool `json:"distributed_sync_enabled"` ClusterSyncEnabled bool `json:"cluster_sync_enabled"` RelayGroupEnabled bool `json:"relay_group_enabled"` NegentropyEnabled bool `json:"negentropy_enabled"` AdminOwners []string `json:"admin_owners"` BinDir string `json:"bin_dir"` // Bitcoin node (nits) NitsEnabled bool `json:"nits_enabled"` NitsBinary string `json:"nits_binary"` NitsListen string `json:"nits_listen"` NitsRPCPort int `json:"nits_rpc_port"` NitsDataDir string `json:"nits_data_dir"` NitsPruneMB int `json:"nits_prune_mb"` NitsNetwork string `json:"nits_network"` // Lightning node (luk) LukEnabled bool `json:"luk_enabled"` LukBinary string `json:"luk_binary"` LukDataDir string `json:"luk_data_dir"` LukRPCListen string `json:"luk_rpc_listen"` LukPeerListen string `json:"luk_peer_listen"` // Wallet (strela) StrelaEnabled bool `json:"strela_enabled"` StrelaBinary string `json:"strela_binary"` StrelaPort int `json:"strela_port"` StrelaDataDir string `json:"strela_data_dir"` } func (s *AdminServer) handleConfig(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: s.handleGetConfig(w, r) case http.MethodPost: s.handleSetConfig(w, r) default: http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } } func (s *AdminServer) handleGetConfig(w http.ResponseWriter, r *http.Request) { response := ConfigResponse{ DBBackend: s.cfg.DBBackend, DBBinary: s.cfg.DBBinary, RelayBinary: s.cfg.RelayBinary, ACLBinary: s.cfg.ACLBinary, DBListen: s.cfg.DBListen, ACLListen: s.cfg.ACLListen, ACLEnabled: s.cfg.ACLEnabled, ACLMode: s.cfg.ACLMode, DataDir: s.cfg.DataDir, LogLevel: s.cfg.LogLevel, DistributedSyncEnabled: s.cfg.DistributedSyncEnabled, ClusterSyncEnabled: s.cfg.ClusterSyncEnabled, RelayGroupEnabled: s.cfg.RelayGroupEnabled, NegentropyEnabled: s.cfg.NegentropyEnabled, AdminOwners: s.auth.Owners(), BinDir: s.cfg.BinDir, NitsEnabled: s.cfg.NitsEnabled, NitsBinary: s.cfg.NitsBinary, NitsListen: s.cfg.NitsListen, NitsRPCPort: s.cfg.NitsRPCPort, NitsDataDir: s.cfg.NitsDataDir, NitsPruneMB: s.cfg.NitsPruneMB, NitsNetwork: s.cfg.NitsNetwork, LukEnabled: s.cfg.LukEnabled, LukBinary: s.cfg.LukBinary, LukDataDir: s.cfg.LukDataDir, LukRPCListen: s.cfg.LukRPCListen, LukPeerListen: s.cfg.LukPeerListen, StrelaEnabled: s.cfg.StrelaEnabled, StrelaBinary: s.cfg.StrelaBinary, StrelaPort: s.cfg.StrelaPort, StrelaDataDir: s.cfg.StrelaDataDir, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // SetConfigRequest is the request body for POST /api/config type SetConfigRequest struct { DBBackend string `json:"db_backend,omitempty"` DBBinary string `json:"db_binary,omitempty"` RelayBinary string `json:"relay_binary,omitempty"` ACLBinary string `json:"acl_binary,omitempty"` DBListen string `json:"db_listen,omitempty"` ACLListen string `json:"acl_listen,omitempty"` ACLEnabled *bool `json:"acl_enabled,omitempty"` ACLMode string `json:"acl_mode,omitempty"` DataDir string `json:"data_dir,omitempty"` LogLevel string `json:"log_level,omitempty"` AdminPort *int `json:"admin_port,omitempty"` AdminOwners []string `json:"admin_owners,omitempty"` BinDir string `json:"bin_dir,omitempty"` DistributedSyncEnabled *bool `json:"distributed_sync_enabled,omitempty"` ClusterSyncEnabled *bool `json:"cluster_sync_enabled,omitempty"` RelayGroupEnabled *bool `json:"relay_group_enabled,omitempty"` NegentropyEnabled *bool `json:"negentropy_enabled,omitempty"` // Bitcoin node (nits) NitsEnabled *bool `json:"nits_enabled,omitempty"` NitsBinary string `json:"nits_binary,omitempty"` NitsDataDir string `json:"nits_data_dir,omitempty"` NitsPruneMB *int `json:"nits_prune_mb,omitempty"` NitsNetwork string `json:"nits_network,omitempty"` // Lightning node (luk) LukEnabled *bool `json:"luk_enabled,omitempty"` LukBinary string `json:"luk_binary,omitempty"` LukDataDir string `json:"luk_data_dir,omitempty"` LukRPCListen string `json:"luk_rpc_listen,omitempty"` LukPeerListen string `json:"luk_peer_listen,omitempty"` // Wallet (strela) StrelaEnabled *bool `json:"strela_enabled,omitempty"` StrelaBinary string `json:"strela_binary,omitempty"` StrelaPort *int `json:"strela_port,omitempty"` StrelaDataDir string `json:"strela_data_dir,omitempty"` } // SetConfigResponse is the response for POST /api/config type SetConfigResponse struct { Success bool `json:"success"` Message string `json:"message"` RestartNeeded bool `json:"restart_needed"` ConfigFilePath string `json:"config_file_path"` } func (s *AdminServer) handleSetConfig(w http.ResponseWriter, r *http.Request) { var req SetConfigRequest if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) { http.Error(w, "Invalid request body", http.StatusBadRequest) return } // Load existing config file or create new cf, err := loadConfigFile() if chk.E(err) { cf = &ConfigFile{} } // Update only fields that were provided if req.DBBackend != "" { cf.DBBackend = req.DBBackend } if req.DBBinary != "" { cf.DBBinary = req.DBBinary } if req.RelayBinary != "" { cf.RelayBinary = req.RelayBinary } if req.ACLBinary != "" { cf.ACLBinary = req.ACLBinary } if req.DBListen != "" { cf.DBListen = req.DBListen } if req.ACLListen != "" { cf.ACLListen = req.ACLListen } if req.ACLEnabled != nil { cf.ACLEnabled = req.ACLEnabled } if req.ACLMode != "" { cf.ACLMode = req.ACLMode } if req.DataDir != "" { cf.DataDir = req.DataDir } if req.LogLevel != "" { cf.LogLevel = req.LogLevel } if req.AdminPort != nil { cf.AdminPort = req.AdminPort } if req.AdminOwners != nil { cf.AdminOwners = req.AdminOwners } if req.BinDir != "" { cf.BinDir = req.BinDir } if req.DistributedSyncEnabled != nil { cf.DistributedSyncEnabled = req.DistributedSyncEnabled } if req.ClusterSyncEnabled != nil { cf.ClusterSyncEnabled = req.ClusterSyncEnabled } if req.RelayGroupEnabled != nil { cf.RelayGroupEnabled = req.RelayGroupEnabled } if req.NegentropyEnabled != nil { cf.NegentropyEnabled = req.NegentropyEnabled } if req.NitsEnabled != nil { cf.NitsEnabled = req.NitsEnabled } if req.NitsBinary != "" { cf.NitsBinary = req.NitsBinary } if req.NitsDataDir != "" { cf.NitsDataDir = req.NitsDataDir } if req.NitsPruneMB != nil { cf.NitsPruneMB = req.NitsPruneMB } if req.NitsNetwork != "" { cf.NitsNetwork = req.NitsNetwork } if req.LukEnabled != nil { cf.LukEnabled = req.LukEnabled } if req.LukBinary != "" { cf.LukBinary = req.LukBinary } if req.LukDataDir != "" { cf.LukDataDir = req.LukDataDir } if req.LukRPCListen != "" { cf.LukRPCListen = req.LukRPCListen } if req.LukPeerListen != "" { cf.LukPeerListen = req.LukPeerListen } if req.StrelaEnabled != nil { cf.StrelaEnabled = req.StrelaEnabled } if req.StrelaBinary != "" { cf.StrelaBinary = req.StrelaBinary } if req.StrelaPort != nil { cf.StrelaPort = req.StrelaPort } if req.StrelaDataDir != "" { cf.StrelaDataDir = req.StrelaDataDir } // Save to file if err := SaveConfigFile(cf); chk.E(err) { response := SetConfigResponse{ Success: false, Message: fmt.Sprintf("Failed to save config: %v", err), } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(response) return } // Update auth middleware if owners changed if req.AdminOwners != nil { for _, owner := range s.auth.Owners() { s.auth.RemoveOwner(owner) } for _, owner := range req.AdminOwners { s.auth.AddOwner(owner) } } response := SetConfigResponse{ Success: true, Message: "Configuration saved. Restart required for most changes to take effect.", RestartNeeded: true, ConfigFilePath: configFilePath(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // BinariesResponse is the response for GET /api/binaries type BinariesResponse struct { CurrentVersion string `json:"current_version"` AvailableVersions []VersionInfo `json:"available_versions"` } func (s *AdminServer) handleBinaries(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } response := BinariesResponse{ CurrentVersion: s.updater.CurrentVersion(), AvailableVersions: s.updater.ListVersions(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // ReleasesResponse is the response for GET /api/releases type ReleasesResponse struct { Releases []ReleaseInfo `json:"releases"` } // ReleaseInfo represents a single release/tag type ReleaseInfo struct { Tag string `json:"tag"` Message string `json:"message"` } const tagsAPIURL = "https://git.nostrdev.com/api/v1/repos/mleku/next.orly.dev/tags" func (s *AdminServer) handleReleases(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Fetch tags from upstream client := &http.Client{Timeout: 10 * time.Second} resp, err := client.Get(tagsAPIURL) if err != nil { log.E.F("failed to fetch tags: %v", err) http.Error(w, "Failed to fetch releases", http.StatusBadGateway) return } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { http.Error(w, "Failed to fetch releases", http.StatusBadGateway) return } body, err := io.ReadAll(resp.Body) if err != nil { http.Error(w, "Failed to read response", http.StatusInternalServerError) return } // Parse the tags response var tags []struct { Name string `json:"name"` Message string `json:"message"` } if err := json.Unmarshal(body, &tags); chk.E(err) { http.Error(w, "Failed to parse response", http.StatusInternalServerError) return } // Filter and transform to our response format var releases []ReleaseInfo for _, tag := range tags { if len(tag.Name) > 0 && tag.Name[0] == 'v' { msg := tag.Message // Get first line only for i, c := range msg { if c == '\n' { msg = msg[:i] break } } releases = append(releases, ReleaseInfo{ Tag: tag.Name, Message: msg, }) } if len(releases) >= 15 { break } } response := ReleasesResponse{Releases: releases} w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // UpdateRequest is the request body for POST /api/update type UpdateRequest struct { Version string `json:"version"` URLs map[string]string `json:"urls"` // binary name -> download URL } // UpdateResponse is the response for POST /api/update type UpdateResponse struct { Success bool `json:"success"` Message string `json:"message"` Version string `json:"version"` DownloadedFiles []string `json:"downloaded_files"` } func (s *AdminServer) handleUpdate(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req UpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Version == "" { http.Error(w, "Version is required", http.StatusBadRequest) return } if len(req.URLs) == 0 { http.Error(w, "At least one binary URL is required", http.StatusBadRequest) return } // Perform the update downloadedFiles, err := s.updater.Update(req.Version, req.URLs) if chk.E(err) { response := UpdateResponse{ Success: false, Message: err.Error(), Version: req.Version, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(response) return } response := UpdateResponse{ Success: true, Message: fmt.Sprintf("Successfully updated to version %s", req.Version), Version: req.Version, DownloadedFiles: downloadedFiles, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // RestartResponse is the response for POST /api/restart type RestartResponse struct { Success bool `json:"success"` Message string `json:"message"` } func (s *AdminServer) handleRestart(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Signal supervisor to restart all processes go func() { if err := s.supervisor.RestartAll(); chk.E(err) { log.E.F("restart failed: %v", err) } }() response := RestartResponse{ Success: true, Message: "Restart initiated", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // RestartServiceRequest is the request body for POST /api/restart-service type RestartServiceRequest struct { Service string `json:"service"` } // RestartServiceResponse is the response for POST /api/restart-service type RestartServiceResponse struct { Success bool `json:"success"` Message string `json:"message"` Restarted []string `json:"restarted"` } func (s *AdminServer) handleRestartService(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req RestartServiceRequest if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Service == "" { http.Error(w, "Service name is required", http.StatusBadRequest) return } // Map binary names to service names serviceName := req.Service switch req.Service { case "orly-db-badger", "orly-db-neo4j": serviceName = "orly-db" case "orly-acl-follows", "orly-acl-managed", "orly-acl-curation": serviceName = "orly-acl" } // Perform the restart in a goroutine to avoid blocking go func() { if restarted, err := s.supervisor.RestartService(serviceName); chk.E(err) { log.E.F("restart service %s failed: %v", serviceName, err) } else { log.I.F("restart service completed: %v", restarted) } }() response := RestartServiceResponse{ Success: true, Message: fmt.Sprintf("Restart of %s initiated", serviceName), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // RollbackResponse is the response for POST /api/rollback type RollbackResponse struct { Success bool `json:"success"` Message string `json:"message"` PreviousVersion string `json:"previous_version"` CurrentVersion string `json:"current_version"` } func (s *AdminServer) handleRollback(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } previousVersion := s.updater.CurrentVersion() if err := s.updater.Rollback(); chk.E(err) { response := RollbackResponse{ Success: false, Message: err.Error(), } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusInternalServerError) json.NewEncoder(w).Encode(response) return } response := RollbackResponse{ Success: true, Message: "Rollback successful - restart required to apply", PreviousVersion: previousVersion, CurrentVersion: s.updater.CurrentVersion(), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // StartServicesResponse is the response for POST /api/start-services type StartServicesResponse struct { Success bool `json:"success"` Message string `json:"message"` } func (s *AdminServer) handleStartServices(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Check if services are already running if s.supervisor.IsRunning() { response := StartServicesResponse{ Success: false, Message: "Services are already running", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(response) return } // Start services in a goroutine go func() { if err := s.supervisor.Start(); chk.E(err) { log.E.F("failed to start services: %v", err) } }() response := StartServicesResponse{ Success: true, Message: "Services starting...", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // StopServicesResponse is the response for POST /api/stop-services type StopServicesResponse struct { Success bool `json:"success"` Message string `json:"message"` } func (s *AdminServer) handleStopServices(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } // Check if services are running if !s.supervisor.IsRunning() { response := StopServicesResponse{ Success: false, Message: "Services are not running", } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(response) return } // Stop services in a goroutine go func() { if err := s.supervisor.Stop(); chk.E(err) { log.E.F("failed to stop services: %v", err) } }() response := StopServicesResponse{ Success: true, Message: "Services stopping...", } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // StartServiceRequest is the request body for POST /api/start-service type StartServiceRequest struct { Service string `json:"service"` } // StartServiceResponse is the response for POST /api/start-service type StartServiceResponse struct { Success bool `json:"success"` Message string `json:"message"` } func (s *AdminServer) handleStartService(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req StartServiceRequest if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Service == "" { http.Error(w, "Service name is required", http.StatusBadRequest) return } // Start the service go func() { if err := s.supervisor.StartService(req.Service); chk.E(err) { log.E.F("start service %s failed: %v", req.Service, err) } else { log.I.F("started service: %s", req.Service) } }() response := StartServiceResponse{ Success: true, Message: fmt.Sprintf("Start of %s initiated", req.Service), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } // StopServiceRequest is the request body for POST /api/stop-service type StopServiceRequest struct { Service string `json:"service"` } // StopServiceResponse is the response for POST /api/stop-service type StopServiceResponse struct { Success bool `json:"success"` Message string `json:"message"` } func (s *AdminServer) handleStopService(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var req StopServiceRequest if err := json.NewDecoder(r.Body).Decode(&req); chk.E(err) { http.Error(w, "Invalid request body", http.StatusBadRequest) return } if req.Service == "" { http.Error(w, "Service name is required", http.StatusBadRequest) return } // Stop the service go func() { if err := s.supervisor.StopService(req.Service); chk.E(err) { log.E.F("stop service %s failed: %v", req.Service, err) } else { log.I.F("stopped service: %s", req.Service) } }() response := StopServiceResponse{ Success: true, Message: fmt.Sprintf("Stop of %s initiated", req.Service), } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(response) } func (s *AdminServer) serveUI(w http.ResponseWriter, r *http.Request) { s.serveAdminUI(w, r) }