package console import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "time" "github.com/novox/mesh-tools/node-tools/internal/wire" ) // bodyLimit is the most a request body may be. const bodyLimit = 1 << 20 var loopback = map[string]bool{"127.0.0.1": true, "::1": true, "localhost": true, "[::1]": true} // Listening is a console that listens: where, with the port the machine gave, and how to stop it. type Listening struct { Address string Close func() error } // Serve listens on host:port, refused unless the host is loopback — said before binding, so a // console that would open to a network is a startup failure (ADR 0152). func Serve(s *Surface, listen string) (*Listening, error) { at := strings.LastIndex(listen, ":") if at < 0 { return nil, fmt.Errorf("%q is not host:port", listen) } host, portText := listen[:at], listen[at+1:] if !loopback[host] { return nil, fmt.Errorf(`the console listens on loopback and nowhere else (novox/hq ADR 0152): %q is not this `+ "machine's own address — whoever is on the machine owns the mesh there, and nobody else may reach this", host) } port, err := strconv.Atoi(portText) if err != nil || port < 0 || port > 65535 { return nil, fmt.Errorf("%q is not a port", portText) } ln, err := net.Listen("tcp", net.JoinHostPort(strings.Trim(host, "[]"), portText)) if err != nil { return nil, err } // Every request's context ends when the console is closed, so a mesh_wait in flight ends at once rather // than holding a stop or a restart for up to its ten minutes (the review of issue 745). base, cancel := context.WithCancel(context.Background()) server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { route(w, r, s) }), BaseContext: func(net.Listener) context.Context { return base }} go func() { _ = server.Serve(ln) }() bound := ln.Addr().(*net.TCPAddr).Port return &Listening{Address: host + ":" + strconv.Itoa(bound), Close: func() error { cancel() // A backstop: what is still answering after the cancel is cut off, never waited on for long. ctx, done := context.WithTimeout(context.Background(), ShutdownWithin) defer done() err := server.Shutdown(ctx) if errors.Is(err, context.DeadlineExceeded) { return server.Close() } return err }}, nil } // ShutdownWithin is how long closing the console waits for the requests still being answered after their // context was cancelled. var ShutdownWithin = 5 * time.Second func route(w http.ResponseWriter, r *http.Request, s *Surface) { switch r.URL.Path { case "/": w.Header().Set("content-type", "text/plain; charset=utf-8") _, _ = io.WriteString(w, "the mesh's console: MCP over HTTP at POST /mcp (novox/hq design 34)\n") return case "/mcp": default: writeJSON(w, 404, map[string]any{"error": "the console serves /mcp and nothing else"}) return } switch r.Method { case http.MethodPost: case http.MethodDelete: w.WriteHeader(204) // no session to end return default: w.Header().Set("allow", "POST, DELETE") w.WriteHeader(405) return } body, err := io.ReadAll(io.LimitReader(r.Body, bodyLimit+1)) if err != nil || len(body) > bodyLimit { if err == nil { err = fmt.Errorf("the request is larger than %d bytes", bodyLimit) } writeJSON(w, 413, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32600, "message": err.Error()}}) return } trimmed := strings.TrimSpace(string(body)) if strings.HasPrefix(trimmed, "[") { var batch []Request if err := json.Unmarshal(body, &batch); err != nil { writeJSON(w, 400, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "the body is not JSON"}}) return } replies := []*Reply{} for _, req := range batch { if reply := s.HandleContext(r.Context(), req); reply != nil { replies = append(replies, reply) } } if len(replies) == 0 { w.WriteHeader(202) return } writeJSON(w, 200, replies) return } var req Request if err := json.Unmarshal(body, &req); err != nil { writeJSON(w, 400, map[string]any{"jsonrpc": "2.0", "id": nil, "error": map[string]any{"code": -32700, "message": "the body is not JSON"}}) return } reply := s.HandleContext(r.Context(), req) if reply == nil { w.WriteHeader(202) return } writeJSON(w, 200, reply) } func writeJSON(w http.ResponseWriter, status int, body any) { text, err := wire.Marshal(body) if err != nil { text, _ = json.Marshal(map[string]any{"error": errors.New("unencodable answer").Error()}) } w.Header().Set("content-type", "application/json; charset=utf-8") w.Header().Set("content-length", strconv.Itoa(len(text))) w.WriteHeader(status) _, _ = w.Write(text) }