diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go index 328a6c6b..46009ecb 100644 --- a/modules/mesh-mail-web/cmd/mesh-mail-web/main.go +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/main.go @@ -9,6 +9,7 @@ package main import ( "fmt" "io" + "net/http" "os" stdio "git.novox.be/novox/mesh-sdk/go" @@ -18,12 +19,26 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } func main() { b := &Bundle{Env: envFromOS()} + go serveHTTP(b) if err := run(b, os.Stdin, os.Stdout); err != nil { logf("%v", err) os.Exit(1) } } +// serveHTTP starts the HTTP endpoint `web` (issue 1283), on the port the mesh gave it; a listen it cannot +// start is logged, never fatal to the seat verbs stdio.Serve answers on the MCP channel. +func serveHTTP(b *Bundle) { + if b.Env.Listen == "" { + logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served") + return + } + s := NewServer(InternalHost(b.Env.Node)) + if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil { + logf("[mesh-mail-web] its HTTP endpoint ended: %v", err) + } +} + // run is main's startup path, factored so a test can run it over a pipe instead of the real stdin/stdout // (mirroring mesh-mail-notifier's own run). func run(b *Bundle, in io.Reader, out io.Writer) error { diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go new file mode 100644 index 00000000..087a9963 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server.go @@ -0,0 +1,107 @@ +package main + +// The web server (novox/hq ADR 0440 §2, §5; issue 1283): answers only its internal name mail..internal +// (404 for any other Host — a public name, an IP, or none), sets a content security policy allowing only its +// own origin on every answer, and writes nothing to disk. Pages are rendered through html/template only (its +// auto-escaping), never written to a file. The seat verb open (issue 1262's claim) answers the page's address: +// the root, or one mail's, by account, folder and uid (mail_read's own addressing, tools.go of mesh-mail). + +import ( + "fmt" + "html/template" + "net" + "net/http" + "net/url" + "strconv" +) + +// OpenTool is the mail-webclient seat's open verb. +const OpenTool = "mail-webclient.open" + +// CSP is ADR 0440 §5's own policy, set on every answer: nothing loaded from elsewhere, and this page never +// framed by another site. +const CSP = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; " + + "frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + +// InternalHost is this machine's own internal name for the module (ADR 0440 §2): the operator's own setting +// (set on the machine, outside the manifest) assigns it the label `mail`, so it is always mail..internal. +func InternalHost(node string) string { return "mail." + node + ".internal" } + +// Server answers mesh-mail-web's HTTP endpoint: a Host check and a CSP on every answer (issue 1283), wrapping +// a plain mux that later issues (1284's form check, 1264's pages, ...) add routes to. +type Server struct { + Host string // InternalHost(node) + Mux *http.ServeMux +} + +// NewServer is a Server for host, with the root page issue 1283 itself answers (a stand-in: the real accounts +// and folder pages are issue 1264's). +func NewServer(host string) *Server { + s := &Server{Host: host, Mux: http.NewServeMux()} + s.Mux.HandleFunc("/", s.rootPage) + return s +} + +// ServeHTTP is issue 1283/AC1, AC2: every answer carries the CSP; a request for any Host but this server's own +// internal name is 404, naming nothing of the mail. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Security-Policy", CSP) + host := r.Host + if h, _, err := net.SplitHostPort(host); err == nil { + host = h + } + if host != s.Host { + http.NotFound(w, r) + return + } + s.Mux.ServeHTTP(w, r) +} + +// rootTemplate is 1283/AC4's own stand-in page: no account or mail content yet (issue 1264), only enough to +// prove the server answers at all, through html/template (auto-escaping), never a file written to disk. +var rootTemplate = template.Must(template.New("root").Parse( + `Mail

mesh-mail-web is up.

`)) + +func (s *Server) rootPage(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _ = rootTemplate.Execute(w, nil) +} + +// openTool is issue 1262's claimed verb open, built here (1283) now that the server's own host exists: with +// nothing given, the root; with account, folder and uid together, that mail's own address. +func (b *Bundle) openTool(args map[string]any) (any, error) { + if err := only(args, "account", "folder", "uid"); err != nil { + return nil, err + } + account, _ := args["account"].(string) + folder, _ := args["folder"].(string) + uidArg, hasUID := args["uid"] + + base := "https://" + InternalHost(b.Env.Node) + "/" + if account == "" && folder == "" && !hasUID { + return map[string]any{"url": base}, nil + } + uid, isNum := uidArg.(float64) + if account == "" || folder == "" || !isNum || uid <= 0 { + return nil, fmt.Errorf("%s needs account, folder and uid together, or none of them", OpenTool) + } + q := url.Values{"account": {account}, "folder": {folder}, "uid": {strconv.FormatFloat(uid, 'f', 0, 64)}} + return map[string]any{"url": "https://" + InternalHost(b.Env.Node) + "/mail?" + q.Encode()}, nil +} + +// only refuses an argument the tool does not take (mirroring mesh-mail's own tools.go). +func only(args map[string]any, takes ...string) error { + for k := range args { + ok := false + for _, t := range takes { + if k == t { + ok = true + break + } + } + if !ok { + return fmt.Errorf("the argument %q is not taken", k) + } + } + return nil +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go b/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go new file mode 100644 index 00000000..b2382043 --- /dev/null +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/server_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// 1283/AC1: a request whose Host is not mail..internal — a public name, an IP, or none — is answered +// 404, naming nothing of the mail. +func TestServerAnswers404ForAnyHostButItsOwnInternalName(t *testing.T) { + s := NewServer(InternalHost("g14")) + for _, host := range []string{"example.com", "203.0.113.5", "", "mail.other-node.internal"} { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = host + s.ServeHTTP(w, r) + if w.Code != http.StatusNotFound { + t.Fatalf("host %q: status %d, want 404", host, w.Code) + } + if strings.Contains(strings.ToLower(w.Body.String()), "mail") { + t.Fatalf("host %q: the 404 body names mail: %s", host, w.Body.String()) + } + } +} + +// Its own internal name is answered (not 404). +func TestServerAnswersItsOwnInternalName(t *testing.T) { + s := NewServer(InternalHost("g14")) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = InternalHost("g14") + s.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d, want 200", w.Code) + } +} + +// A Host with a port still matches, port and all (the mesh's own listen carries one). +func TestServerAnswersItsOwnInternalNameWithAPort(t *testing.T) { + s := NewServer(InternalHost("g14")) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = InternalHost("g14") + ":8099" + s.ServeHTTP(w, r) + if w.Code != http.StatusOK { + t.Fatalf("status %d, want 200", w.Code) + } +} + +// 1283/AC2: every answer (200 and 404 alike) carries the content security policy. +func TestServerSetsTheContentSecurityPolicyOnEveryAnswer(t *testing.T) { + s := NewServer(InternalHost("g14")) + cases := []string{"example.com", InternalHost("g14")} + for _, host := range cases { + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Host = host + s.ServeHTTP(w, r) + if got := w.Header().Get("Content-Security-Policy"); got != CSP { + t.Fatalf("host %q: CSP %q, want %q", host, got, CSP) + } + } +} + +// 1283/AC3: open with nothing given answers the root; with account, folder and uid together, that mail's own +// address. +func TestOpenAnswersTheRootOrOneMailsAddress(t *testing.T) { + b := &Bundle{Env: Env{Node: "g14"}} + out, err := b.openTool(map[string]any{}) + if err != nil { + t.Fatal(err) + } + if got := out.(map[string]any)["url"]; got != "https://mail.g14.internal/" { + t.Fatalf("root url: %v", got) + } + + out, err = b.openTool(map[string]any{"account": "mesh", "folder": "INBOX", "uid": float64(7)}) + if err != nil { + t.Fatal(err) + } + url := out.(map[string]any)["url"].(string) + if !strings.HasPrefix(url, "https://mail.g14.internal/mail?") || + !strings.Contains(url, "account=mesh") || !strings.Contains(url, "folder=INBOX") || !strings.Contains(url, "uid=7") { + t.Fatalf("mail url: %s", url) + } + + if _, err := b.openTool(map[string]any{"account": "mesh"}); err == nil { + t.Fatalf("account alone, with no folder or uid, was not refused") + } +} + +// 1283/AC4: with the working directory and home read-only, the server still starts and answers a page (it +// writes nothing to disk). +func TestServerStartsAndAnswersAPageWithACwdAndHomeReadOnly(t *testing.T) { + ro := t.TempDir() + if err := os.Chmod(ro, 0o500); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(ro, 0o700) }) + + oldWd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + oldHome := os.Getenv("HOME") + t.Cleanup(func() { + _ = os.Chdir(oldWd) + _ = os.Setenv("HOME", oldHome) + }) + if err := os.Chdir(ro); err != nil { + t.Fatal(err) + } + _ = os.Setenv("HOME", filepath.Join(ro, "home-does-not-exist")) + + s := NewServer(InternalHost("g14")) + srv := httptest.NewServer(s) + defer srv.Close() + + req, err := http.NewRequest(http.MethodGet, srv.URL+"/", nil) + if err != nil { + t.Fatal(err) + } + req.Host = InternalHost("g14") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, want 200", resp.StatusCode) + } +} diff --git a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go index 53638f44..495e79a2 100644 --- a/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go +++ b/modules/mesh-mail-web/cmd/mesh-mail-web/tools.go @@ -51,6 +51,16 @@ func (b *Bundle) now() time.Time { // Tools are the module's tools. func (b *Bundle) Tools() []stdio.Tool { return []stdio.Tool{ + {Name: OpenTool, + Description: "The mail web client's own address (novox/hq ADR 0440 §8): with nothing given, the " + + "page's root; with account, folder and uid together (mail_read's own addressing), that mail's " + + "own page. Read-only; answers a URL, never a page's content.", + Input: schema(nil, map[string]any{ + "account": text("the account, by its name in mail_accounts"), + "folder": text("the folder, such as INBOX"), + "uid": integer("the message's uid in that folder"), + }), + Run: b.openTool}, {Name: StatusTool, Description: "Whether mesh-mail-web serves, and the mail accounts it is granted through " + "seat:mail-store.accounts (novox/hq ADR 0440 §8). Read-only.", @@ -104,3 +114,6 @@ func schema(required []string, props map[string]any) map[string]any { } return s } + +func text(d string) map[string]any { return map[string]any{"type": "string", "description": d} } +func integer(d string) map[string]any { return map[string]any{"type": "integer", "description": d} } diff --git a/modules/mesh-mail-web/module.json b/modules/mesh-mail-web/module.json index 544ed697..a0dd2d7f 100644 --- a/modules/mesh-mail-web/module.json +++ b/modules/mesh-mail-web/module.json @@ -55,6 +55,7 @@ } }, "tools": [ + "mail-webclient.open", "mail-webclient.status", "mail_web_health" ],