95ae900a58
docs/plans/06-mcp-support.md asks for the host direction — Maven connects OUT
to MCP servers and consumes what they offer. This is the client half: the
protocol, the transports, the connection manager, the config block. Nothing is
wired into a turn yet, and nothing here exposes Maven's own capabilities to an
outside caller.
internal/mcp:
- hand-rolled JSON-RPC 2.0 (the wire format is four fields, and the repo
vendors its deps, so a library would cost more than it saves);
- two transports: a stdio subprocess on this box, and streamable HTTP, which
accepts a plain JSON reply or an SSE frame because servers disagree about
which they send;
- Client: initialize handshake, tools/list, tools/call, resources/list,
resources/read. Text content only — everything downstream is a sentence;
- Manager: lazy dial, per-server failure that never blocks boot or the other
servers, backoff reconnect, Status for a web surface, graceful Close;
- the allowlist encoding: a discovered tool becomes the store row
"vikunja_list_tasks" with cmd ["mcp","vikunja","list_tasks"], scope
"mcp:vikunja". No new column, no migration, and ProposeTool, EnableTool,
the act matcher and the confirm turn all keep working untouched.
Constraints held, in code rather than in prose:
- OFF unless configured, and a server is dark until "enabled": true.
- A url server goes through internal/webfetch, so the SSRF guard, the size
cap, the redirect cap and the per-host rate limit apply. Reaching loopback
needs allow_private on THAT server, and each server gets its own fetcher so
one loopback exemption cannot become a hole for a public endpoint.
- readOnlyHint decides destructive: no hint means "assume it mutates", which
will route the call through the existing confirm turn. Guessing wrong in
that direction only costs a question.
- The catalogue stays small on purpose — allow_tools, and max_tools=12 per
server. The resident model is a 1.7B with a 4096-token context; a tool name
it half-remembers is a wrong act.
- Only the tool name and the router's arguments are sent. There is no API
here through which a note, a fact or the persona block could travel.
webfetch grows Post (JSON-RPC cannot be a GET) and surfaces response headers
for Mcp-Session-Id. It shares Get's guards exactly: a body buys a caller
nothing, a POST to the LAN is refused for the same reason a GET is.
Verified against the real Vikunja MCP server on homesrv
(http://localhost:9100/mcp): handshake, three discovered tools with update_task
correctly NOT read-only, a live list_projects call, a tool excluded by
allow_tools refused, and the same server refused outright once allow_private
was dropped. Tests cover both transports (the stdio one against a real
subprocess), SSE and JSON framing, session echo, reconnect, and the config
validation.
349 lines
12 KiB
Go
349 lines
12 KiB
Go
// Package webfetch is the one door Maven uses to read something off the
|
|
// network, and it is a narrow one.
|
|
//
|
|
// "Never phones home" stopped being a hard constraint on 2026-07-31, but what
|
|
// replaced it is not "she may fetch anything": local sources come first (Kiwix
|
|
// on the box), external fetching is off unless configured, and only the
|
|
// utterance ever leaves — never his notes, facts or history. That policy is
|
|
// enforced by the callers. What THIS package enforces is the part that must be
|
|
// code rather than a paragraph in a plan, because it protects the homelab from
|
|
// its own assistant:
|
|
//
|
|
// - http/https only — no file://, no ftp://, no gopher;
|
|
// - no private address, ever: loopback, RFC1918 (which is what makes the
|
|
// 10.42.0.0/24 wireguard tunnel and the 192.168.1.0/24 LAN unreachable),
|
|
// link-local incl. the 169.254.169.254 cloud metadata address, CGNAT,
|
|
// unique-local v6. Checked in the dialer's Control hook, so it holds for
|
|
// every address the resolver returns AND for every hop of a redirect
|
|
// chain — a DNS name that resolves to 127.0.0.1 is refused at connect
|
|
// time, which a pre-flight lookup could not promise (rebinding);
|
|
// - an allowlist, when one is configured, and a denylist that always wins;
|
|
// - a response size cap, a total timeout, a redirect cap;
|
|
// - one request per host per interval, so a poll loop with a bug is slow
|
|
// rather than an outbound flood.
|
|
//
|
|
// Everything above is on by default with sane numbers: a zero Config is a
|
|
// usable, conservative fetcher. There is no cache and no retry — a feed poll
|
|
// or a page read that fails is simply not answered this round.
|
|
package webfetch
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
)
|
|
|
|
// Defaults. Small on purpose: this reads feeds and article pages, not ISOs.
|
|
const (
|
|
DefaultTimeout = 20 * time.Second
|
|
DefaultMaxBytes = 2 << 20 // 2 MiB
|
|
DefaultMaxRedirects = 3
|
|
DefaultHostInterval = time.Second
|
|
DefaultUserAgent = "Maven/1.0 (self-hosted personal assistant)"
|
|
)
|
|
|
|
// Errors callers distinguish. Everything else is wrapped transport error.
|
|
var (
|
|
ErrScheme = errors.New("webfetch: only http and https are allowed")
|
|
ErrBlocked = errors.New("webfetch: host is not allowed")
|
|
ErrPrivate = errors.New("webfetch: refusing to connect to a private address")
|
|
ErrTooLarge = errors.New("webfetch: response exceeds the size cap")
|
|
ErrRedirects = errors.New("webfetch: too many redirects")
|
|
ErrStatus = errors.New("webfetch: non-2xx status")
|
|
)
|
|
|
|
// Config are the limits. Every zero value means "the default above", so
|
|
// Config{} is safe; the only field that changes behaviour by being empty is
|
|
// AllowHosts (empty ⇒ any public host that is not denied).
|
|
type Config struct {
|
|
// AllowHosts — when non-empty, the ONLY hosts that may be fetched. An
|
|
// entry matches the host itself and its subdomains ("example.com" allows
|
|
// "news.example.com"). This is the knob to reach for when a capability
|
|
// should read two feeds and nothing else.
|
|
AllowHosts []string
|
|
// DenyHosts — same matching, checked first and always winning.
|
|
DenyHosts []string
|
|
|
|
Timeout time.Duration // whole request, including redirects and body read
|
|
MaxBytes int64 // response body cap
|
|
MaxRedirects int // 0 ⇒ default; negative ⇒ no redirects followed
|
|
HostInterval time.Duration // minimum spacing between requests to one host
|
|
UserAgent string
|
|
|
|
// AllowPrivate disables the private-address guard. It exists for tests
|
|
// (httptest listens on 127.0.0.1) and for an explicitly configured
|
|
// on-box mirror. Nothing in deploy/mavend.json sets it, and it should
|
|
// stay that way: with it on, any URL Maven is handed becomes an SSRF
|
|
// probe of the LAN and the wireguard range.
|
|
AllowPrivate bool
|
|
}
|
|
|
|
// Response is a fetched body, already bounded by MaxBytes.
|
|
type Response struct {
|
|
URL string // final URL after redirects
|
|
Status int
|
|
ContentType string
|
|
Body []byte
|
|
// Header — the response headers, one value each (the first). Populated for
|
|
// every request; MCP needs Mcp-Session-Id, nothing else reads it.
|
|
Header map[string]string
|
|
}
|
|
|
|
// Fetcher performs guarded GETs. Safe for concurrent use; the per-host rate
|
|
// limiter is shared, which is the point of sharing one Fetcher.
|
|
type Fetcher struct {
|
|
cfg Config
|
|
http *http.Client
|
|
|
|
mu sync.Mutex
|
|
last map[string]time.Time // host → when we last dialed it
|
|
}
|
|
|
|
// New builds a fetcher from cfg, filling in defaults.
|
|
func New(cfg Config) *Fetcher {
|
|
if cfg.Timeout <= 0 {
|
|
cfg.Timeout = DefaultTimeout
|
|
}
|
|
if cfg.MaxBytes <= 0 {
|
|
cfg.MaxBytes = DefaultMaxBytes
|
|
}
|
|
if cfg.MaxRedirects == 0 {
|
|
cfg.MaxRedirects = DefaultMaxRedirects
|
|
}
|
|
if cfg.HostInterval <= 0 {
|
|
cfg.HostInterval = DefaultHostInterval
|
|
}
|
|
if cfg.UserAgent == "" {
|
|
cfg.UserAgent = DefaultUserAgent
|
|
}
|
|
f := &Fetcher{cfg: cfg, last: map[string]time.Time{}}
|
|
|
|
dialer := &net.Dialer{Timeout: 10 * time.Second}
|
|
if !cfg.AllowPrivate {
|
|
// The guard lives here rather than in a pre-flight net.LookupHost so
|
|
// that it sees the address actually being connected to: every A/AAAA
|
|
// the resolver handed back, on every redirect hop, with no window in
|
|
// which the name could be re-pointed at the LAN.
|
|
dialer.Control = func(_, address string, _ syscall.RawConn) error {
|
|
host, _, err := net.SplitHostPort(address)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || IsPrivateIP(ip) {
|
|
return fmt.Errorf("%w: %s", ErrPrivate, host)
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
f.http = &http.Client{
|
|
Timeout: cfg.Timeout,
|
|
Transport: &http.Transport{DialContext: dialer.DialContext},
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) > f.cfg.MaxRedirects {
|
|
return ErrRedirects
|
|
}
|
|
// A redirect is a fresh URL and gets the full check: an allowed
|
|
// host must not be able to bounce us onto a denied one.
|
|
return f.checkURL(req.URL)
|
|
},
|
|
}
|
|
return f
|
|
}
|
|
|
|
// Get fetches rawURL. The body is capped: a larger response is an error, not a
|
|
// truncation, because half an XML document is worse than none.
|
|
func (f *Fetcher) Get(ctx context.Context, rawURL string) (*Response, error) {
|
|
return f.do(ctx, http.MethodGet, rawURL, nil, nil)
|
|
}
|
|
|
|
// Post sends body to rawURL and returns the reply, under exactly the same
|
|
// guards as Get: scheme rule, host lists, the dialer's private-address check on
|
|
// every hop, the size cap and the per-host rate limit.
|
|
//
|
|
// It exists for JSON-RPC over HTTP (internal/mcp), which cannot be expressed as
|
|
// a GET. That an outbound request now carries a body does not widen the
|
|
// address policy one bit — a POST to the LAN is refused for the same reason a
|
|
// GET is, unless AllowPrivate was set for that specific fetcher.
|
|
//
|
|
// hdr is merged over the defaults; a caller may not override User-Agent or
|
|
// Accept-Encoding, because identity encoding and an honest UA are part of the
|
|
// contract with whatever is on the other end.
|
|
func (f *Fetcher) Post(ctx context.Context, rawURL, contentType string, body []byte, hdr map[string]string) (*Response, error) {
|
|
if contentType == "" {
|
|
contentType = "application/json"
|
|
}
|
|
if hdr == nil {
|
|
hdr = map[string]string{}
|
|
}
|
|
merged := make(map[string]string, len(hdr)+1)
|
|
for k, v := range hdr {
|
|
merged[k] = v
|
|
}
|
|
merged["Content-Type"] = contentType
|
|
return f.do(ctx, http.MethodPost, rawURL, body, merged)
|
|
}
|
|
|
|
func (f *Fetcher) do(ctx context.Context, method, rawURL string, body []byte, hdr map[string]string) (*Response, error) {
|
|
u, err := url.Parse(strings.TrimSpace(rawURL))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("webfetch: bad url %q: %w", rawURL, err)
|
|
}
|
|
if err := f.checkURL(u); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := f.waitTurn(ctx, u.Hostname()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
rdr = bytes.NewReader(body)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, u.String(), rdr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for k, v := range hdr {
|
|
req.Header.Set(k, v)
|
|
}
|
|
req.Header.Set("User-Agent", f.cfg.UserAgent)
|
|
req.Header.Set("Accept-Encoding", "identity")
|
|
|
|
resp, err := f.http.Do(req)
|
|
if err != nil {
|
|
// http.Client wraps our sentinels in *url.Error; unwrap so callers can
|
|
// still tell "blocked" from "the network is down".
|
|
for _, sentinel := range []error{ErrPrivate, ErrBlocked, ErrRedirects, ErrScheme} {
|
|
if errors.Is(err, sentinel) {
|
|
return nil, err
|
|
}
|
|
}
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
respBody, err := io.ReadAll(io.LimitReader(resp.Body, f.cfg.MaxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(respBody)) > f.cfg.MaxBytes {
|
|
return nil, fmt.Errorf("%w (%d bytes)", ErrTooLarge, f.cfg.MaxBytes)
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return nil, fmt.Errorf("%w: %d", ErrStatus, resp.StatusCode)
|
|
}
|
|
out := &Response{
|
|
URL: resp.Request.URL.String(),
|
|
Status: resp.StatusCode,
|
|
ContentType: resp.Header.Get("Content-Type"),
|
|
Body: respBody,
|
|
Header: map[string]string{},
|
|
}
|
|
for k := range resp.Header {
|
|
out.Header[k] = resp.Header.Get(k)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// checkURL applies the scheme rule and the host lists. The address rule is the
|
|
// dialer's job (see New).
|
|
func (f *Fetcher) checkURL(u *url.URL) error {
|
|
switch u.Scheme {
|
|
case "http", "https":
|
|
default:
|
|
return fmt.Errorf("%w: %q", ErrScheme, u.Scheme)
|
|
}
|
|
host := strings.ToLower(u.Hostname())
|
|
if host == "" {
|
|
return fmt.Errorf("%w: no host", ErrBlocked)
|
|
}
|
|
if HostMatches(host, f.cfg.DenyHosts) {
|
|
return fmt.Errorf("%w: %s is denied", ErrBlocked, host)
|
|
}
|
|
if len(f.cfg.AllowHosts) > 0 && !HostMatches(host, f.cfg.AllowHosts) {
|
|
return fmt.Errorf("%w: %s is not on the allowlist", ErrBlocked, host)
|
|
}
|
|
// A literal private address is refused here as well as in the dialer, so
|
|
// the error is the specific one even when no connection is attempted.
|
|
if !f.cfg.AllowPrivate {
|
|
if ip := net.ParseIP(host); ip != nil && IsPrivateIP(ip) {
|
|
return fmt.Errorf("%w: %s", ErrPrivate, host)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// waitTurn blocks until this host's rate-limit interval has elapsed. It holds
|
|
// no lock while sleeping, so two hosts never wait on each other.
|
|
func (f *Fetcher) waitTurn(ctx context.Context, host string) error {
|
|
for {
|
|
f.mu.Lock()
|
|
now := time.Now()
|
|
earliest := f.last[host].Add(f.cfg.HostInterval)
|
|
if !now.Before(earliest) {
|
|
f.last[host] = now
|
|
f.mu.Unlock()
|
|
return nil
|
|
}
|
|
f.mu.Unlock()
|
|
wait := time.NewTimer(earliest.Sub(now))
|
|
select {
|
|
case <-ctx.Done():
|
|
wait.Stop()
|
|
return ctx.Err()
|
|
case <-wait.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
// HostMatches reports whether host equals one of pats or is a subdomain of one.
|
|
// Exported because the crawler applies the same rule to links it decides not to
|
|
// follow, before it ever builds a request.
|
|
func HostMatches(host string, pats []string) bool {
|
|
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
|
for _, p := range pats {
|
|
p = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(p, "*.")))
|
|
if p == "" {
|
|
continue
|
|
}
|
|
if host == p || strings.HasSuffix(host, "."+p) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// cgnat is 100.64.0.0/10 — carrier NAT, not covered by net.IP's helpers and not
|
|
// somewhere a personal assistant has business connecting.
|
|
var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0).To4(), Mask: net.CIDRMask(10, 32)}
|
|
|
|
// IsPrivateIP reports whether ip is somewhere Maven must never reach out to:
|
|
// the box itself, the LAN, the wireguard range (10.42.0.0/24 ⊂ 10/8), the cloud
|
|
// metadata address (169.254.169.254 ⊂ link-local), or anything unroutable.
|
|
func IsPrivateIP(ip net.IP) bool {
|
|
if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
|
|
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
|
|
ip.IsInterfaceLocalMulticast() || ip.IsMulticast() {
|
|
return true
|
|
}
|
|
if v4 := ip.To4(); v4 != nil && cgnat.Contains(v4) {
|
|
return true
|
|
}
|
|
// IPv4-mapped/compatible forms of the above are handled by To4() inside the
|
|
// stdlib helpers; what is left is v6 unique-local (fc00::/7).
|
|
if len(ip) == net.IPv6len && ip.To4() == nil && ip[0]&0xfe == 0xfc {
|
|
return true
|
|
}
|
|
return false
|
|
}
|