package main import ( "context" "encoding/base64" "encoding/json" "fmt" "io" "log" "net" "net/http" "net/url" "os" "path/filepath" "strings" "sync" "time" ) // Local admin channel: lets root on the VPS run a script on a connected machine and read the // output. It listens on a unix socket (mode 0600, next to config.json), never on the network. func adminSocketPath(cfgPath string) string { return filepath.Join(filepath.Dir(cfgPath), "admin.sock") } func (s *Server) startAdmin() { path := adminSocketPath(s.cfgPath) os.Remove(path) l, err := net.Listen("unix", path) if err != nil { log.Printf("admin socket disabled: %v", err) return } os.Chmod(path, 0600) mux := http.NewServeMux() mux.HandleFunc("/exec", s.adminExec) mux.HandleFunc("/rpc", s.adminRPC) mux.HandleFunc("/stats", s.adminStats) mux.HandleFunc("/newkey", s.adminNewKey) go http.Serve(l, mux) log.Printf("admin socket at %s", path) } func (s *Server) adminExec(w http.ResponseWriter, r *http.Request) { var req struct { Agent string `json:"agent"` Script string `json:"script"` TimeoutS int `json:"timeout_s"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req) != nil || req.Script == "" { http.Error(w, "bad request", http.StatusBadRequest) return } if req.TimeoutS <= 0 || req.TimeoutS > 3600 { req.TimeoutS = 120 } h := s.hub gsid := "adm:" + randHex(4) done := make(chan struct{}) var once sync.Once var wmu sync.Mutex closed := false fl, _ := w.(http.Flusher) c := &conn{sink: func(m Msg) { switch m.T { case "out": if b, err := base64.StdEncoding.DecodeString(m.D); err == nil { wmu.Lock() if !closed { w.Write(b) if fl != nil { fl.Flush() } } wmu.Unlock() } case "exit": once.Do(func() { close(done) }) } }} h.mu.Lock() a := h.agents[req.Agent] if a != nil { h.sessions[gsid] = &session{ui: c, uiSid: "x", agent: req.Agent} } h.mu.Unlock() if a == nil { http.Error(w, "machine offline: "+req.Agent, http.StatusNotFound) return } log.Printf("admin exec on %s: %s", req.Agent, req.Script) w.Header().Set("Content-Type", "text/plain; charset=utf-8") a.send(Msg{T: "open", Sid: gsid, Cols: 200, Rows: 50, Script: req.Script}) ctx, cancel := context.WithTimeout(r.Context(), time.Duration(req.TimeoutS)*time.Second) defer cancel() select { case <-done: case <-ctx.Done(): a.send(Msg{T: "close", Sid: gsid}) wmu.Lock() if !closed { io.WriteString(w, "\n[timeout]\n") } wmu.Unlock() } h.mu.Lock() delete(h.sessions, gsid) h.mu.Unlock() wmu.Lock() closed = true wmu.Unlock() } // adminExecClient is the CLI side: wxch-server -exec -script '...' func adminExecClient(cfgPath, agent, script string, timeout int) { if script == "" { b, _ := io.ReadAll(os.Stdin) script = string(b) } if strings.TrimSpace(script) == "" { log.Fatal("no script") } script += "\necho \"[exit code: $?]\"\n" client := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", adminSocketPath(cfgPath)) }}} body, _ := json.Marshal(map[string]any{"agent": agent, "script": script, "timeout_s": timeout}) resp, err := client.Post("http://admin/exec", "application/json", strings.NewReader(string(body))) if err != nil { log.Fatalf("cannot reach admin socket (is wxch-server running?): %v", err) } defer resp.Body.Close() if resp.StatusCode != 200 { b, _ := io.ReadAll(resp.Body) fmt.Fprint(os.Stderr, string(b)) os.Exit(1) } io.Copy(os.Stdout, resp.Body) } // adminRPC calls a structured agent method (same path the web UI uses) and streams events + result as JSON lines. func (s *Server) adminRPC(w http.ResponseWriter, r *http.Request) { var req struct { Agent string `json:"agent"` Method string `json:"method"` Params json.RawMessage `json:"params"` TimeoutS int `json:"timeout_s"` } if json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req) != nil || req.Method == "" { http.Error(w, "bad request", http.StatusBadRequest) return } if req.TimeoutS <= 0 || req.TimeoutS > 7200 { req.TimeoutS = 120 } h := s.hub gid := "adm:" + randHex(4) done := make(chan struct{}) var once sync.Once var wmu sync.Mutex closed := false fl, _ := w.(http.Flusher) c := &conn{sink: func(m Msg) { wmu.Lock() defer wmu.Unlock() if closed { return } b, _ := json.Marshal(m) w.Write(append(b, '\n')) if fl != nil { fl.Flush() } if m.T == "rpc_result" { once.Do(func() { close(done) }) } }} h.mu.Lock() a := h.agents[req.Agent] if a != nil { h.rpcs[gid] = &rpcEntry{ui: c, uiID: "adm", agent: req.Agent} } h.mu.Unlock() if a == nil { http.Error(w, "machine offline: "+req.Agent, http.StatusNotFound) return } log.Printf("admin rpc %s on %s", req.Method, req.Agent) if req.Method == "agent.update" { // the agent restarts itself, so there is no result to wait for a.send(Msg{T: "update", ID: gid}) h.mu.Lock() delete(h.rpcs, gid) h.mu.Unlock() io.WriteString(w, `{"t":"rpc_result","ok":true,"data":"update requested"}`+"\n") return } a.send(Msg{T: "rpc", ID: gid, Method: req.Method, Params: req.Params}) select { case <-done: case <-time.After(time.Duration(req.TimeoutS) * time.Second): wmu.Lock() io.WriteString(w, `{"t":"rpc_result","err":"timeout"}`+"\n") wmu.Unlock() } h.mu.Lock() delete(h.rpcs, gid) h.mu.Unlock() wmu.Lock() closed = true wmu.Unlock() } func adminRPCClient(cfgPath, agent, method, params string, timeout int) { if params == "" { params = "{}" } client := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", adminSocketPath(cfgPath)) }}} body, _ := json.Marshal(map[string]any{"agent": agent, "method": method, "params": json.RawMessage(params), "timeout_s": timeout}) resp, err := client.Post("http://admin/rpc", "application/json", strings.NewReader(string(body))) if err != nil { log.Fatalf("cannot reach admin socket: %v", err) } defer resp.Body.Close() io.Copy(os.Stdout, resp.Body) } func adminStatsClient(cfgPath string) { client := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", adminSocketPath(cfgPath)) }}} resp, err := client.Get("http://admin/stats") if err != nil { log.Fatalf("cannot reach admin socket: %v", err) } defer resp.Body.Close() io.Copy(os.Stdout, resp.Body) } func (s *Server) adminNewKey(w http.ResponseWriter, r *http.Request) { name := r.URL.Query().Get("name") if name == "" { http.Error(w, "name required", http.StatusBadRequest) return } key, _ := s.keys.create(name) io.WriteString(w, key+"\n") } func adminNewKeyClient(cfgPath, name string) { client := &http.Client{Transport: &http.Transport{ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", adminSocketPath(cfgPath)) }}} resp, err := client.Post("http://admin/newkey?name="+url.QueryEscape(name), "text/plain", nil) if err != nil { log.Fatalf("cannot reach admin socket: %v", err) } defer resp.Body.Close() io.Copy(os.Stdout, resp.Body) }