125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
// mesh-mail-web: the mail web client (novox/hq ADR 0440), built from mesh-mail's source as mesh-mail-notifier is
|
|
// (novox/hq ADR 0198 §1, ADR 0175 §1). A Go bundle the node's runtime launches, its own account (runs-as): it
|
|
// claims mail-webclient (open, status), reaches mail-store's seat verbs (accounts, search, read, send,
|
|
// send-status), and — once issue 1283 lands — answers its own HTTP endpoint `web`. No database, no state, no
|
|
// writable directory beyond the one the runtime account needs for its own settings. What this module says, it
|
|
// says on stderr; stdout is the MCP channel.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) }
|
|
|
|
// The HTTP endpoint's own state (novox/hq ADR 0440 §8; review round 1 of issue 1283, finding 4): before this
|
|
// fix, a failure setting up sign-in (the issuer never answering) or starting to listen (its port already
|
|
// taken) was only logged — mail-webclient.status went on answering serves: true, and mail_web_health healthy,
|
|
// regardless, since neither asked serveHTTP anything. Kept on the Bundle instead, so both tools say the one
|
|
// truth.
|
|
const (
|
|
HTTPSettingUpSignIn = "setting up sign-in"
|
|
HTTPListening = "listening"
|
|
HTTPEnded = "ended"
|
|
)
|
|
|
|
// HTTPStatus is serveHTTP's own phase and (once it has one) its error, read by statusTool and healthTool.
|
|
type HTTPStatus struct {
|
|
mu sync.Mutex
|
|
phase string
|
|
err error
|
|
}
|
|
|
|
// NewHTTPStatus starts HTTPSettingUpSignIn: serveHTTP's own first phase, before issuer discovery has even been
|
|
// tried once.
|
|
func NewHTTPStatus() *HTTPStatus { return &HTTPStatus{phase: HTTPSettingUpSignIn} }
|
|
|
|
func (h *HTTPStatus) set(phase string, err error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
h.phase = phase
|
|
h.err = err
|
|
}
|
|
|
|
// Get answers the endpoint's own phase, and its error once it has one.
|
|
func (h *HTTPStatus) Get() (phase string, err error) {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
return h.phase, h.err
|
|
}
|
|
|
|
func main() {
|
|
b := &Bundle{Env: envFromOS(), HTTP: NewHTTPStatus()}
|
|
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 — but it is also kept
|
|
// on b.HTTP (finding 4), so mail-webclient.status and mail_web_health say it rather than going on claiming to
|
|
// serve. Sign-in (issue 1263) is set up first, retried on a failure (the issuer may not answer yet): never
|
|
// served with no session guard at all, which a nil Auth would be (server.go's own fallback, used by its unit
|
|
// tests alone).
|
|
func serveHTTP(b *Bundle) {
|
|
if b.Env.Listen == "" {
|
|
logf("[mesh-mail-web] started without MESH_MAIL_WEB_LISTEN; its HTTP endpoint is not served")
|
|
b.HTTP.set(HTTPEnded, errors.New("started without its own listen setting (MESH_MAIL_WEB_LISTEN)"))
|
|
return
|
|
}
|
|
host := InternalHost(b.Env.Node)
|
|
var auth *Auth
|
|
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
|
a, err := buildAuth(host, b.Env)
|
|
if err == nil {
|
|
auth = a
|
|
break
|
|
}
|
|
logf("[mesh-mail-web] sign-in cannot be set up yet (%v); trying again in %s", err, wait)
|
|
b.HTTP.set(HTTPSettingUpSignIn, err)
|
|
time.Sleep(wait)
|
|
}
|
|
s := NewServer(host, auth, b)
|
|
b.HTTP.set(HTTPListening, nil)
|
|
if err := http.ListenAndServe(":"+b.Env.Listen, s); err != nil {
|
|
logf("[mesh-mail-web] its HTTP endpoint ended: %v", err)
|
|
b.HTTP.set(HTTPEnded, err)
|
|
}
|
|
}
|
|
|
|
// buildAuth reads the oidc-client binding (issue 1262's oidc.json) and discovers the issuer (issue 1263).
|
|
func buildAuth(host string, env Env) (*Auth, error) {
|
|
cfg, err := ReadOIDCConfig(env.OIDC)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
authenticator, err := NewAuthenticator(context.Background(), cfg, "https://"+host+SignedInPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Auth{
|
|
Auth: authenticator,
|
|
Sessions: NewSessions(nil),
|
|
Pending: NewPendingAuths(nil),
|
|
Operators: Operators{Path: env.Operators},
|
|
Logf: logf,
|
|
}, nil
|
|
}
|
|
|
|
// 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 {
|
|
return stdio.ServeOn("", b.Tools(), in, out)
|
|
}
|