26da5a21
proxy
a73x 2026-03-29 10:22
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,16 @@ | |||
| 1 | PREFIX ?= $(HOME)/.local/bin | ||
| 2 | |||
| 3 | .PHONY: build test install clean | ||
| 4 | |||
| 5 | build: | ||
| 6 | go build -o nono-proxy ./cmd/nono-proxy/ | ||
| 7 | |||
| 8 | test: | ||
| 9 | go test ./... | ||
| 10 | |||
| 11 | install: build | ||
| 12 | install -m 755 nono $(PREFIX)/nono | ||
| 13 | install -m 755 nono-proxy $(PREFIX)/nono-proxy | ||
| 14 | |||
| 15 | clean: | ||
| 16 | rm -f nono-proxy | ||
cmd/nono-proxy/main.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,44 @@ | |||
| 1 | package main | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "fmt" | ||
| 5 | "log" | ||
| 6 | "net/http" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | |||
| 10 | "github.com/xanderle/nono/proxy" | ||
| 11 | ) | ||
| 12 | |||
| 13 | func main() { | ||
| 14 | if len(os.Args) > 1 && os.Args[1] == "allow" { | ||
| 15 | if len(os.Args) < 3 { | ||
| 16 | fmt.Fprintln(os.Stderr, "usage: nono-proxy allow <host>") | ||
| 17 | os.Exit(1) | ||
| 18 | } | ||
| 19 | hostsFile := approvedHostsPath() | ||
| 20 | if err := proxy.Allow(hostsFile, os.Args[2]); err != nil { | ||
| 21 | log.Fatalf("failed to allow host: %v", err) | ||
| 22 | } | ||
| 23 | fmt.Printf("allowed %s\n", os.Args[2]) | ||
| 24 | return | ||
| 25 | } | ||
| 26 | |||
| 27 | addr := ":9854" | ||
| 28 | hostsFile := approvedHostsPath() | ||
| 29 | |||
| 30 | os.MkdirAll(filepath.Dir(hostsFile), 0755) | ||
| 31 | |||
| 32 | p := proxy.New(hostsFile) | ||
| 33 | log.Printf("nono-proxy listening on %s (hosts: %s)", addr, hostsFile) | ||
| 34 | log.Fatal(http.ListenAndServe(addr, p)) | ||
| 35 | } | ||
| 36 | |||
| 37 | func approvedHostsPath() string { | ||
| 38 | store := os.Getenv("NONO_STORE") | ||
| 39 | if store == "" { | ||
| 40 | home, _ := os.UserHomeDir() | ||
| 41 | store = filepath.Join(home, ".local", "share", "nono") | ||
| 42 | } | ||
| 43 | return filepath.Join(store, "approved_hosts") | ||
| 44 | } | ||
go.mod
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,3 @@ | |||
| 1 | module nono | ||
| 2 | |||
| 3 | go 1.26.1 | ||
nono
| Old | New | ||
|---|---|---|---|
| @@ -7,6 +7,18 @@ SESSION_NAME=$(echo "$BASE" | tr '/' '-' | sed 's/^-//') | |||
| 7 | SESSION_DIR=$STORE/sessions/$SESSION_NAME | 7 | SESSION_DIR=$STORE/sessions/$SESSION_NAME |
| 8 | UPPER=$SESSION_DIR/upper | 8 | UPPER=$SESSION_DIR/upper |
| 9 | WORK=$SESSION_DIR/work | 9 | WORK=$SESSION_DIR/work |
| 10 | PROXY_PORT=9854 | ||
| 11 | PROXY_BIN="$(dirname "$(realpath "$0")")/nono-proxy" | ||
| 12 | |||
| 13 | # --- subcommands --- | ||
| 14 | |||
| 15 | if [[ "${1:-}" == "allow" ]]; then | ||
| 16 | shift | ||
| 17 | "$PROXY_BIN" allow "$@" | ||
| 18 | exit | ||
| 19 | fi | ||
| 20 | |||
| 21 | # --- sandbox --- | ||
| 10 | 22 | ||
| 11 | mkdir -p "$UPPER" "$WORK" | 23 | mkdir -p "$UPPER" "$WORK" |
| 12 | 24 | ||
| @@ -18,6 +30,21 @@ args=( | |||
| 18 | --setenv TERM "${TERM:-xterm}" | 30 | --setenv TERM "${TERM:-xterm}" |
| 19 | ) | 31 | ) |
| 20 | 32 | ||
| 33 | # proxy (optional — only set if nono-proxy is listening) | ||
| 34 | if curl -s -o /dev/null -x "http://localhost:$PROXY_PORT" http://0.0.0.0/ 2>/dev/null; then | ||
| 35 | args+=( | ||
| 36 | --setenv HTTP_PROXY "http://localhost:$PROXY_PORT" | ||
| 37 | --setenv HTTPS_PROXY "http://localhost:$PROXY_PORT" | ||
| 38 | --setenv http_proxy "http://localhost:$PROXY_PORT" | ||
| 39 | --setenv https_proxy "http://localhost:$PROXY_PORT" | ||
| 40 | ) | ||
| 41 | else | ||
| 42 | echo "warning: nono-proxy not running on port $PROXY_PORT, no network filtering" >&2 | ||
| 43 | fi | ||
| 44 | |||
| 45 | args+=( | ||
| 46 | ) | ||
| 47 | |||
| 21 | # system (read-only) | 48 | # system (read-only) |
| 22 | for p in \ | 49 | for p in \ |
| 23 | /opt/claude-code/bin/claude \ | 50 | /opt/claude-code/bin/claude \ |
proxy/proxy.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,139 @@ | |||
| 1 | package proxy | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "bufio" | ||
| 5 | "fmt" | ||
| 6 | "io" | ||
| 7 | "log" | ||
| 8 | "net" | ||
| 9 | "net/http" | ||
| 10 | "os" | ||
| 11 | "strings" | ||
| 12 | ) | ||
| 13 | |||
| 14 | // Proxy is an HTTP proxy that only allows connections to approved hosts. | ||
| 15 | type Proxy struct { | ||
| 16 | hostsFile string | ||
| 17 | } | ||
| 18 | |||
| 19 | // New creates a new Proxy that checks hosts against the given allowlist file. | ||
| 20 | func New(hostsFile string) *Proxy { | ||
| 21 | return &Proxy{hostsFile: hostsFile} | ||
| 22 | } | ||
| 23 | |||
| 24 | func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 25 | host := extractHost(r.Host) | ||
| 26 | |||
| 27 | if !p.isApproved(host) { | ||
| 28 | log.Printf("DENIED %s %s", r.Method, r.Host) | ||
| 29 | http.Error(w, fmt.Sprintf("host %q not approved. Run: nono allow %s", host, host), http.StatusForbidden) | ||
| 30 | return | ||
| 31 | } | ||
| 32 | |||
| 33 | log.Printf("ALLOWED %s %s", r.Method, r.Host) | ||
| 34 | |||
| 35 | if r.Method == http.MethodConnect { | ||
| 36 | p.handleConnect(w, r) | ||
| 37 | return | ||
| 38 | } | ||
| 39 | |||
| 40 | p.handleHTTP(w, r) | ||
| 41 | } | ||
| 42 | |||
| 43 | func (p *Proxy) isApproved(host string) bool { | ||
| 44 | f, err := os.Open(p.hostsFile) | ||
| 45 | if err != nil { | ||
| 46 | return false | ||
| 47 | } | ||
| 48 | defer f.Close() | ||
| 49 | |||
| 50 | scanner := bufio.NewScanner(f) | ||
| 51 | for scanner.Scan() { | ||
| 52 | line := strings.TrimSpace(scanner.Text()) | ||
| 53 | if line == host { | ||
| 54 | return true | ||
| 55 | } | ||
| 56 | } | ||
| 57 | return false | ||
| 58 | } | ||
| 59 | |||
| 60 | func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { | ||
| 61 | targetConn, err := net.Dial("tcp", r.Host) | ||
| 62 | if err != nil { | ||
| 63 | http.Error(w, err.Error(), http.StatusBadGateway) | ||
| 64 | return | ||
| 65 | } | ||
| 66 | |||
| 67 | hj, ok := w.(http.Hijacker) | ||
| 68 | if !ok { | ||
| 69 | http.Error(w, "hijacking not supported", http.StatusInternalServerError) | ||
| 70 | return | ||
| 71 | } | ||
| 72 | |||
| 73 | clientConn, _, err := hj.Hijack() | ||
| 74 | if err != nil { | ||
| 75 | targetConn.Close() | ||
| 76 | return | ||
| 77 | } | ||
| 78 | |||
| 79 | clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) | ||
| 80 | |||
| 81 | go io.Copy(targetConn, clientConn) | ||
| 82 | io.Copy(clientConn, targetConn) | ||
| 83 | |||
| 84 | clientConn.Close() | ||
| 85 | targetConn.Close() | ||
| 86 | } | ||
| 87 | |||
| 88 | func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { | ||
| 89 | r.RequestURI = "" | ||
| 90 | resp, err := http.DefaultTransport.RoundTrip(r) | ||
| 91 | if err != nil { | ||
| 92 | http.Error(w, err.Error(), http.StatusBadGateway) | ||
| 93 | return | ||
| 94 | } | ||
| 95 | defer resp.Body.Close() | ||
| 96 | |||
| 97 | for k, vv := range resp.Header { | ||
| 98 | for _, v := range vv { | ||
| 99 | w.Header().Add(k, v) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | w.WriteHeader(resp.StatusCode) | ||
| 103 | io.Copy(w, resp.Body) | ||
| 104 | } | ||
| 105 | |||
| 106 | // Allow adds a host to the approved hosts file, deduplicating. | ||
| 107 | func Allow(hostsFile, host string) error { | ||
| 108 | existing := make(map[string]bool) | ||
| 109 | |||
| 110 | if data, err := os.ReadFile(hostsFile); err == nil { | ||
| 111 | scanner := bufio.NewScanner(strings.NewReader(string(data))) | ||
| 112 | for scanner.Scan() { | ||
| 113 | line := strings.TrimSpace(scanner.Text()) | ||
| 114 | if line != "" { | ||
| 115 | existing[line] = true | ||
| 116 | } | ||
| 117 | } | ||
| 118 | } | ||
| 119 | |||
| 120 | if existing[host] { | ||
| 121 | return nil | ||
| 122 | } | ||
| 123 | |||
| 124 | f, err := os.OpenFile(hostsFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) | ||
| 125 | if err != nil { | ||
| 126 | return err | ||
| 127 | } | ||
| 128 | defer f.Close() | ||
| 129 | |||
| 130 | _, err = fmt.Fprintln(f, host) | ||
| 131 | return err | ||
| 132 | } | ||
| 133 | |||
| 134 | func extractHost(hostport string) string { | ||
| 135 | if i := strings.LastIndex(hostport, ":"); i != -1 { | ||
| 136 | return hostport[:i] | ||
| 137 | } | ||
| 138 | return hostport | ||
| 139 | } | ||
proxy/proxy_test.go
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,117 @@ | |||
| 1 | package proxy_test | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "net/http/httptest" | ||
| 6 | "net/url" | ||
| 7 | "os" | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | |||
| 12 | "github.com/xanderle/nono/proxy" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func newProxyClient(t *testing.T, proxyURL string) *http.Client { | ||
| 16 | t.Helper() | ||
| 17 | return &http.Client{ | ||
| 18 | Transport: &http.Transport{ | ||
| 19 | Proxy: func(*http.Request) (*url.URL, error) { | ||
| 20 | return url.Parse(proxyURL) | ||
| 21 | }, | ||
| 22 | }, | ||
| 23 | } | ||
| 24 | } | ||
| 25 | |||
| 26 | func TestShouldDenyUnapprovedHost(t *testing.T) { | ||
| 27 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 28 | os.WriteFile(hostsFile, []byte(""), 0644) | ||
| 29 | |||
| 30 | p := proxy.New(hostsFile) | ||
| 31 | srv := httptest.NewServer(p) | ||
| 32 | defer srv.Close() | ||
| 33 | |||
| 34 | client := newProxyClient(t, srv.URL) | ||
| 35 | |||
| 36 | resp, err := client.Get("http://example.com/") | ||
| 37 | if err != nil { | ||
| 38 | t.Fatalf("unexpected error: %v", err) | ||
| 39 | } | ||
| 40 | defer resp.Body.Close() | ||
| 41 | |||
| 42 | if resp.StatusCode != http.StatusForbidden { | ||
| 43 | t.Errorf("expected 403 Forbidden, got %d", resp.StatusCode) | ||
| 44 | } | ||
| 45 | } | ||
| 46 | |||
| 47 | func TestShouldAllowApprovedHost(t *testing.T) { | ||
| 48 | // Backend server simulating the target | ||
| 49 | backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 50 | w.WriteHeader(http.StatusOK) | ||
| 51 | w.Write([]byte("hello from backend")) | ||
| 52 | })) | ||
| 53 | defer backend.Close() | ||
| 54 | |||
| 55 | backendURL, _ := url.Parse(backend.URL) | ||
| 56 | |||
| 57 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 58 | os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644) | ||
| 59 | |||
| 60 | p := proxy.New(hostsFile) | ||
| 61 | srv := httptest.NewServer(p) | ||
| 62 | defer srv.Close() | ||
| 63 | |||
| 64 | client := newProxyClient(t, srv.URL) | ||
| 65 | |||
| 66 | resp, err := client.Get(backend.URL + "/test") | ||
| 67 | if err != nil { | ||
| 68 | t.Fatalf("unexpected error: %v", err) | ||
| 69 | } | ||
| 70 | defer resp.Body.Close() | ||
| 71 | |||
| 72 | if resp.StatusCode != http.StatusOK { | ||
| 73 | t.Errorf("expected 200 OK, got %d", resp.StatusCode) | ||
| 74 | } | ||
| 75 | } | ||
| 76 | |||
| 77 | func TestAllowShouldAddHostToFile(t *testing.T) { | ||
| 78 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 79 | os.WriteFile(hostsFile, []byte(""), 0644) | ||
| 80 | |||
| 81 | if err := proxy.Allow(hostsFile, "example.com"); err != nil { | ||
| 82 | t.Fatalf("unexpected error: %v", err) | ||
| 83 | } | ||
| 84 | |||
| 85 | data, _ := os.ReadFile(hostsFile) | ||
| 86 | if !strings.Contains(string(data), "example.com") { | ||
| 87 | t.Errorf("expected approved_hosts to contain example.com, got %q", string(data)) | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | func TestAllowShouldDeduplicateHosts(t *testing.T) { | ||
| 92 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 93 | os.WriteFile(hostsFile, []byte("example.com\n"), 0644) | ||
| 94 | |||
| 95 | if err := proxy.Allow(hostsFile, "example.com"); err != nil { | ||
| 96 | t.Fatalf("unexpected error: %v", err) | ||
| 97 | } | ||
| 98 | |||
| 99 | data, _ := os.ReadFile(hostsFile) | ||
| 100 | count := strings.Count(string(data), "example.com") | ||
| 101 | if count != 1 { | ||
| 102 | t.Errorf("expected 1 occurrence, got %d in %q", count, string(data)) | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | func TestAllowShouldCreateFileIfMissing(t *testing.T) { | ||
| 107 | hostsFile := filepath.Join(t.TempDir(), "approved_hosts") | ||
| 108 | |||
| 109 | if err := proxy.Allow(hostsFile, "example.com"); err != nil { | ||
| 110 | t.Fatalf("unexpected error: %v", err) | ||
| 111 | } | ||
| 112 | |||
| 113 | data, _ := os.ReadFile(hostsFile) | ||
| 114 | if !strings.Contains(string(data), "example.com") { | ||
| 115 | t.Errorf("expected approved_hosts to contain example.com, got %q", string(data)) | ||
| 116 | } | ||
| 117 | } | ||