package main // coder_egress.go — a coder run's ONLY way out: egress.sock. // // The container runs with --network none (service.go measured why: the // default bridge reaches the machine's LAN address and its own services, an // --internal network still reaches the machine through its gateway, none // reaches nothing). Its sidecar points HTTP(S)_PROXY at a loopback forwarder // that carries each connection to this unix socket, and this proxy dials out // over the machine's own connection. // // THE VETTING APPLIES ALWAYS, anchored box or not. The egress lane // (egress.go) keeps a local box's LAN reachable, because on a local box the // host using it is the person's own. A coding agent is not the person: it runs // whatever a prompt or a repository talks it into, so this proxy reaches the // public internet and nothing else — not this machine, not its LAN, not the // cloud metadata address, nor any address this machine takes as its own (a // public one on a NIC, or a range a `local` route delivers here; ownaddr.go) // — unless the owner named the host in allow_lan. // Every address a name resolves to is checked, and the address checked is the // address dialled (egress.go's rebinding rule). import ( "bufio" "context" "net" "os" "path/filepath" "strconv" "strings" "sync" "sync/atomic" "time" ) // The seams: how a name is resolved and how the vetted address is dialled. var ( coderResolve = func(ctx context.Context, host string) ([]net.IP, error) { addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host) if err != nil { return nil, err } out := make([]net.IP, 0, len(addrs)) for _, a := range addrs { out = append(out, a.IP) } return out, nil } coderDialOut egressDialer = (&net.Dialer{Timeout: egressDialTimeout}).DialContext ) // coderInternalIP is internalIP (egress.go): a coder run's proxy and the // backup lanes refuse the same address classes as the egress lane, from the // one classifier (classifyIP), the IPv4-compatible, IPv4-translated and // site-local fec0::/10 forms included. func coderInternalIP(ip net.IP) bool { return internalIP(ip) } // coderLANAllowed: the owner's allow_lan names this requested host (an entry // with a port must match host:port, one without matches any port). func coderLANAllowed(allowLAN []string, host, port string) bool { h := strings.ToLower(strings.Trim(host, "[]")) for _, e := range allowLAN { e = strings.ToLower(strings.TrimSpace(e)) if e == "" { continue } eh, ep, err := net.SplitHostPort(e) if err != nil { eh, ep = strings.Trim(e, "[]"), "" } if eh == h && (ep == "" || ep == port) { return true } } return false } // coderVettedAddr: ("ip:port", "") to dial first, or ("", the refusal). func coderVettedAddr(ctx context.Context, hostport string, allowLAN []string) (string, string) { all, why := coderVettedAddrs(ctx, hostport, allowLAN) if why != "" { return "", why } return all[0], "" } // coderVettedAddrs: every "ip:port" the name resolved to, IPv4 first, or the // refusal. Refuses localhost names, names it cannot resolve, and any name // with even ONE internal address or ONE address the kernel delivers to this // machine. Only these addresses are ever dialled, never the name again; // trying each in turn lets a run reach a dual-stack // site from a machine whose IPv6 goes nowhere. func coderVettedAddrs(ctx context.Context, hostport string, allowLAN []string) ([]string, string) { host, port, err := net.SplitHostPort(hostport) if err != nil { return nil, "refused: not a host:port" } host = strings.Trim(strings.TrimSpace(host), "[]") if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { return nil, "refused: bad port" } if host == "" { return nil, "refused: no host" } lh := strings.ToLower(strings.TrimSuffix(host, ".")) lanOK := coderLANAllowed(allowLAN, host, port) if !lanOK && (lh == "localhost" || strings.HasSuffix(lh, ".localhost")) { return nil, "refused: this machine is not reachable from a coder run" } var addrs []net.IP if ip := net.ParseIP(host); ip != nil { addrs = []net.IP{ip} } else { resolved, err := coderResolve(ctx, host) if err != nil || len(resolved) == 0 { return nil, "refused: the name does not resolve on this machine" } addrs = resolved } if !lanOK { for _, ip := range addrs { if coderInternalIP(ip) { return nil, "refused: a coder run reaches the public internet only, never this machine, its network or a private range" } } // The classes above miss this machine when it holds a PUBLIC address // on one of its interfaces (a VPS, a server with its own IP), or when // the kernel delivers a whole range locally through a `local` route // (AnyIP): a service listening on 0.0.0.0 answers there. ownDestination // (ownaddr.go) asks both. own, err := ownDestination(addrs) if err != nil { return nil, "refused: this machine could not tell whether the destination is itself, so it cannot be vetted" } if own { return nil, "refused: a coder run reaches the public internet only, never this machine, its network or a private range" } } var v4, v6 []string for _, ip := range addrs { if ip.To4() != nil { v4 = append(v4, net.JoinHostPort(ip.String(), port)) } else { v6 = append(v6, net.JoinHostPort(ip.String(), port)) } } return append(v4, v6...), "" } func coderRefuse(c net.Conn, why string) { _, _ = c.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\nX-Privatae-Refused: " + strings.NewReplacer("\r", " ", "\n", " ").Replace(why) + "\r\n\r\n")) } // handleCoderEgressConn serves one connection from a run: HTTP CONNECT or an // absolute-form plain HTTP request, the same parsing as handleEgressConn. // Destinations are never logged; refusals are counted on the run. func handleCoderEgressConn(client net.Conn, allowLAN []string, refused *int64) { defer client.Close() _ = client.SetReadDeadline(time.Now().Add(30 * time.Second)) br := bufio.NewReader(client) line, err := br.ReadString('\n') if err != nil { return } parts := strings.Fields(strings.TrimSpace(line)) if len(parts) < 2 { return } method, target := parts[0], parts[1] var head strings.Builder head.WriteString(line) hostHdr := "" for { h, err := br.ReadString('\n') if err != nil { return } head.WriteString(h) if h == "\r\n" || h == "\n" { break } if head.Len() > 64<<10 { return } if len(h) >= 5 && strings.EqualFold(h[:5], "host:") { hostHdr = strings.TrimSpace(h[5:]) } } _ = client.SetReadDeadline(time.Time{}) ctx, cancel := context.WithTimeout(context.Background(), egressDialTimeout) defer cancel() connect := strings.EqualFold(method, "CONNECT") hp := "" if connect { hp = ensurePort(target, "443") } else { host := hostHdr if i := strings.Index(target, "://"); i >= 0 { rest := target[i+3:] if s := strings.IndexAny(rest, "/?#"); s >= 0 { host = rest[:s] } else { host = rest } if at := strings.LastIndexByte(host, '@'); at >= 0 { host = host[at+1:] } } if host == "" { return } hp = ensurePort(host, "80") } dialAddrs, why := coderVettedAddrs(ctx, hp, allowLAN) if why != "" { atomic.AddInt64(refused, 1) coderRefuse(client, why) return } upstream, err := dialEach(ctx, coderDialOut, dialAddrs) if err != nil { _, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")) return } if connect { if _, err := client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil { _ = upstream.Close() return } } else if _, err := upstream.Write([]byte(head.String())); err != nil { _ = upstream.Close() return } splice(br, client, upstream) } // The live proxies, one per run, in this daemon. type coderEgressRun struct { ln net.Listener refused int64 } var ( coderEgressMu sync.Mutex coderEgress = map[string]*coderEgressRun{} ) // coderListenEgress opens /egress.sock for one run: owned by the // engine's uid, 0600, so only the run's own processes can connect. func coderListenEgress(runID, dir string, uid int) error { coderEgressMu.Lock() defer coderEgressMu.Unlock() if old, ok := coderEgress[runID]; ok { _ = old.ln.Close() delete(coderEgress, runID) } p := filepath.Join(dir, coderEgressSock) _ = os.Remove(p) ln, err := coderListenUnix(dir, coderEgressSock) if err != nil { return err } if ul, ok := ln.(*net.UnixListener); ok { ul.SetUnlinkOnClose(false) // run-down removes the directory; a restart re-listens } if err := os.Chmod(p, 0o600); err != nil { _ = ln.Close() return err } if err := coderChown(p, uid, uid); err != nil { _ = ln.Close() return err } run := &coderEgressRun{ln: ln} coderEgress[runID] = run go func() { for { c, err := ln.Accept() if err != nil { return } // allow_lan is read per connection: the owner's edit applies to // running runs at once. go handleCoderEgressConn(c, loadCoderConfig().AllowLAN, &run.refused) } }() return nil } // coderCloseEgress stops one run's proxy and returns how many requests it // refused (0 when this daemon held no proxy for it). func coderCloseEgress(runID string) int64 { coderEgressMu.Lock() defer coderEgressMu.Unlock() run, ok := coderEgress[runID] if !ok { return 0 } _ = run.ln.Close() delete(coderEgress, runID) return atomic.LoadInt64(&run.refused) } // coderReconcileEgress makes the live proxies match the records: a run that // outlived a daemon restart gets its proxy back, and a run whose record was // removed by another process (the CLI's `coders drop`) loses it. func coderReconcileEgress() { recs := loadCoderRecords() want := map[string]coderRecord{} for _, r := range recs { want[r.RunID] = r } coderMu.Lock() starting := map[string]bool{} for id := range coderStarting { starting[id] = true } coderMu.Unlock() coderEgressMu.Lock() var drop []string for id := range coderEgress { if _, ok := want[id]; !ok && !starting[id] { drop = append(drop, id) } } var add []coderRecord for id, r := range want { if _, ok := coderEgress[id]; !ok { add = append(add, r) } } coderEgressMu.Unlock() for _, id := range drop { coderCloseEgress(id) } for _, r := range add { if R, err := coderRunDir(r.RunID); err == nil { _ = coderListenEgress(r.RunID, filepath.Join(R, "egress"), r.UID) } } }