package console import ( "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "strconv" "strings" "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 } server := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { route(w, r, s) })} go func() { _ = server.Serve(ln) }() bound := ln.Addr().(*net.TCPAddr).Port return &Listening{Address: host + ":" + strconv.Itoa(bound), Close: func() error { return server.Shutdown(context.Background()) }}, nil } 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.Handle(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.Handle(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) }