package lua // Outbound HTTP for plugins. // // The billing plugin never needed this: everything it reports arrives on a hook // payload that the gateway produced while forwarding a request. A quota plugin // is the first thing that must REACH OUT on its own — Command Code exposes // /alpha/billing/credits and friends, and there is no way to learn a plan's // remaining balance without asking. // // That capability is also the sharpest edge in the plugin model. A plugin is a // single .lua file the WebUI installs from a textarea: untrusted, browser-facing // code that runs in the gateway process. Handing it a general-purpose HTTP // client would make every install a standing SSRF primitive against whatever // the gateway can reach — the cloud metadata endpoint, the cluster control // plane, whatever else sits on 127.0.0.1 in this deployment. Reading the // environment for credentials is a second door out. // // So the bridge is deliberately narrow: // // - Destination must match a host that a CONFIGURED SOURCE already talks to. // Those are operator-declared, not plugin-declared: a plugin cannot widen // its own reach by inventing a URL, and an existing source's host is // exactly the set of upstreams this deployment already trusts with API keys. // - http:// on a non-loopback host is refused. Credentials belong in HTTPS. // - Loopback needs no explicit opt-in: it is the only way a plugin can read // the gateway's own API (the billing rules UI does exactly that), and it // cannot leave the machine. // - Every request is bounded by a timeout and the response is size-capped, so // a plugin cannot wedge a worker goroutine or exhaust memory. // - Credentials are NEVER read from the environment here. A plugin that needs // a key must be given it (state, or the caller's own config), which keeps // the grant visible to the operator instead of silently ambient. // // Redirects are NOT followed: the allowlist is checked per hop, and a redirect // to an unlisted host would otherwise walk straight past it. A 3xx is returned // as-is so the plugin can see it. import ( "fmt" "io" "net" "net/http" "net/url" "strings" "sync" "time" golua "github.com/aarzilli/golua/lua" ) // httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body // a plugin can pull into the gateway's heap. const ( httpDefaultTimeout = 15 * time.Second httpMaxTimeout = 60 * time.Second httpMaxResponse = 4 << 20 // 4 MiB ) // httpClient is shared by every plugin request. It deliberately has no // ProxyFromEnvironment: an operator who proxies upstreams configures the proxy // per source, and a plugin request should not silently inherit one. var httpClient = &http.Client{ Timeout: httpDefaultTimeout, Transport: &http.Transport{ Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ForceAttemptHTTP2: true, MaxIdleConns: 16, IdleConnTimeout: 60 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: time.Second, }, // See above: a redirect is a hop the allowlist has not seen. CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, } // httpGlobal is the Lua name of the outbound bridge. const httpGlobal = "__llmsproxy_http" // httpAllowlist is the set of hosts plugins may contact: the host of every // configured source's base_url, plus every operator-granted extra host. // // It is a LIVE, shared object, not a snapshot. This matters: bootPluginState // binds one *httpAllowlist into every Lua closure at state-boot time, and those // closures live for the plugin's lifetime. Granting an extra host after a // worker booted must therefore MUTATE the same object the closures hold — // replacing ps.allowHTTP with a fresh instance would leave every already-booted // worker checking the STALE list, and a grant would look accepted (the API // answers ok, the UI shows it) while requests kept getting refused with // "not an allowed outbound destination". That exact failure shipped once. // // Mutations are rare (operator approves a host); lookups are per request. type httpAllowlist struct { mu sync.RWMutex hosts map[string]bool } func newHTTPAllowlist(hosts []string) *httpAllowlist { a := &httpAllowlist{hosts: make(map[string]bool, len(hosts))} a.add(hosts) return a } // add merges hosts into the live allowlist. Called by rebuild (source hosts // change on hot reload) and by GrantExtraHosts (operator approves a host). func (a *httpAllowlist) add(hosts []string) { a.mu.Lock() defer a.mu.Unlock() for _, h := range hosts { if h != "" { a.hosts[strings.ToLower(h)] = true } } } // allows reports whether host:port may be contacted. Ports 80/443 are implied // by the bare host so a source configured as https://api.example.com/v1 does not // have to spell the port. func (a *httpAllowlist) allows(host string, port string) bool { host = strings.ToLower(host) if host == "" { return false } a.mu.RLock() defer a.mu.RUnlock() if a.hosts[host] { return true } switch port { case "", "80", "443": return a.hosts[host] } return a.hosts[net.JoinHostPort(host, port)] } // checkURL validates a plugin-supplied URL against the allowlist. It returns // the parsed URL plus a reason when it is refused. func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil { return nil, fmt.Errorf("bad url: %v", err) } switch strings.ToLower(u.Scheme) { case "https": case "http": // Plain HTTP is only tolerated on loopback, where it stays on this // machine and is how the gateway's own plain-HTTP dev port is reached. if !isLoopbackHost(u.Hostname()) { return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname()) } default: return nil, fmt.Errorf("unsupported scheme %q", u.Scheme) } if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) { // Loopback needs no entry: it cannot leave the machine, and the billing // rules UI reads the gateway's own API over plain HTTP in dev setups. return nil, fmt.Errorf("host %q is not an allowed outbound destination "+ "(allowed: the hosts of configured sources, plus loopback)", u.Hostname()) } return u, nil } func isLoopbackHost(h string) bool { if h == "localhost" { return true } ip := net.ParseIP(strings.Trim(h, "[]")) return ip != nil && ip.IsLoopback() } // httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http. type httpRequestSpec struct { URL string Method string Headers map[string]string Body string Timeout time.Duration } // luaHTTPFn builds the golua closure. It takes one Lua table argument and // returns (responseTable, nil) or (nil, errorMessage) — never raises, so a // plugin mistake cannot take the gateway with it. func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction { return func(L *golua.State) int { spec, err := readHTTPRequestSpec(L) if err != nil { L.PushNil() L.PushString(err.Error()) return 2 } u, err := allow.checkURL(spec.URL) if err != nil { L.PushNil() L.PushString(err.Error()) return 2 } resp, err := performHTTP(u, spec) if err != nil { L.PushNil() L.PushString(err.Error()) return 2 } pushHTTPResponse(L, resp) L.PushNil() return 2 } } func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) { method := strings.ToUpper(spec.Method) if method == "" { method = http.MethodGet } timeout := spec.Timeout if timeout <= 0 || timeout > httpMaxTimeout { timeout = httpDefaultTimeout } var body io.Reader if spec.Body != "" { body = strings.NewReader(spec.Body) } req, err := http.NewRequest(method, u.String(), body) if err != nil { return nil, fmt.Errorf("build request: %v", err) } for k, v := range spec.Headers { req.Header.Set(k, v) } if req.Header.Get("User-Agent") == "" { req.Header.Set("User-Agent", "ModelRouter-plugin") } client := *httpClient client.Timeout = timeout resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %v", err) } defer resp.Body.Close() data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1)) if err != nil { return nil, fmt.Errorf("read body: %v", err) } if len(data) > httpMaxResponse { return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse) } return &httpResult{ Status: resp.StatusCode, Headers: flattenHeaders(resp.Header), Body: string(data), }, nil } type httpResult struct { Status int Headers map[string]string Body string } func flattenHeaders(h http.Header) map[string]string { out := make(map[string]string, len(h)) for k := range h { out[strings.ToLower(k)] = h.Get(k) } return out }