26da5a21
proxy
a73x 2026-03-29 10:22
diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ccbce3d --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ PREFIX ?= $(HOME)/.local/bin .PHONY: build test install clean build: go build -o nono-proxy ./cmd/nono-proxy/ test: go test ./... install: build install -m 755 nono $(PREFIX)/nono install -m 755 nono-proxy $(PREFIX)/nono-proxy clean: rm -f nono-proxy diff --git a/cmd/nono-proxy/main.go b/cmd/nono-proxy/main.go new file mode 100644 index 0000000..d85b0b6 --- /dev/null +++ b/cmd/nono-proxy/main.go @@ -0,0 +1,44 @@ package main import ( "fmt" "log" "net/http" "os" "path/filepath" "github.com/xanderle/nono/proxy" ) func main() { if len(os.Args) > 1 && os.Args[1] == "allow" { if len(os.Args) < 3 { fmt.Fprintln(os.Stderr, "usage: nono-proxy allow <host>") os.Exit(1) } hostsFile := approvedHostsPath() if err := proxy.Allow(hostsFile, os.Args[2]); err != nil { log.Fatalf("failed to allow host: %v", err) } fmt.Printf("allowed %s\n", os.Args[2]) return } addr := ":9854" hostsFile := approvedHostsPath() os.MkdirAll(filepath.Dir(hostsFile), 0755) p := proxy.New(hostsFile) log.Printf("nono-proxy listening on %s (hosts: %s)", addr, hostsFile) log.Fatal(http.ListenAndServe(addr, p)) } func approvedHostsPath() string { store := os.Getenv("NONO_STORE") if store == "" { home, _ := os.UserHomeDir() store = filepath.Join(home, ".local", "share", "nono") } return filepath.Join(store, "approved_hosts") } diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2bfa58b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ module nono go 1.26.1 diff --git a/nono b/nono index e2138c9..f68f50f 100755 --- a/nono +++ b/nono @@ -7,6 +7,18 @@ SESSION_NAME=$(echo "$BASE" | tr '/' '-' | sed 's/^-//') SESSION_DIR=$STORE/sessions/$SESSION_NAME UPPER=$SESSION_DIR/upper WORK=$SESSION_DIR/work PROXY_PORT=9854 PROXY_BIN="$(dirname "$(realpath "$0")")/nono-proxy" # --- subcommands --- if [[ "${1:-}" == "allow" ]]; then shift "$PROXY_BIN" allow "$@" exit fi # --- sandbox --- mkdir -p "$UPPER" "$WORK" @@ -18,6 +30,21 @@ args=( --setenv TERM "${TERM:-xterm}" ) # proxy (optional — only set if nono-proxy is listening) if curl -s -o /dev/null -x "http://localhost:$PROXY_PORT" http://0.0.0.0/ 2>/dev/null; then args+=( --setenv HTTP_PROXY "http://localhost:$PROXY_PORT" --setenv HTTPS_PROXY "http://localhost:$PROXY_PORT" --setenv http_proxy "http://localhost:$PROXY_PORT" --setenv https_proxy "http://localhost:$PROXY_PORT" ) else echo "warning: nono-proxy not running on port $PROXY_PORT, no network filtering" >&2 fi args+=( ) # system (read-only) for p in \ /opt/claude-code/bin/claude \ diff --git a/proxy/proxy.go b/proxy/proxy.go new file mode 100644 index 0000000..a8e630f --- /dev/null +++ b/proxy/proxy.go @@ -0,0 +1,139 @@ package proxy import ( "bufio" "fmt" "io" "log" "net" "net/http" "os" "strings" ) // Proxy is an HTTP proxy that only allows connections to approved hosts. type Proxy struct { hostsFile string } // New creates a new Proxy that checks hosts against the given allowlist file. func New(hostsFile string) *Proxy { return &Proxy{hostsFile: hostsFile} } func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { host := extractHost(r.Host) if !p.isApproved(host) { log.Printf("DENIED %s %s", r.Method, r.Host) http.Error(w, fmt.Sprintf("host %q not approved. Run: nono allow %s", host, host), http.StatusForbidden) return } log.Printf("ALLOWED %s %s", r.Method, r.Host) if r.Method == http.MethodConnect { p.handleConnect(w, r) return } p.handleHTTP(w, r) } func (p *Proxy) isApproved(host string) bool { f, err := os.Open(p.hostsFile) if err != nil { return false } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == host { return true } } return false } func (p *Proxy) handleConnect(w http.ResponseWriter, r *http.Request) { targetConn, err := net.Dial("tcp", r.Host) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } hj, ok := w.(http.Hijacker) if !ok { http.Error(w, "hijacking not supported", http.StatusInternalServerError) return } clientConn, _, err := hj.Hijack() if err != nil { targetConn.Close() return } clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")) go io.Copy(targetConn, clientConn) io.Copy(clientConn, targetConn) clientConn.Close() targetConn.Close() } func (p *Proxy) handleHTTP(w http.ResponseWriter, r *http.Request) { r.RequestURI = "" resp, err := http.DefaultTransport.RoundTrip(r) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } defer resp.Body.Close() for k, vv := range resp.Header { for _, v := range vv { w.Header().Add(k, v) } } w.WriteHeader(resp.StatusCode) io.Copy(w, resp.Body) } // Allow adds a host to the approved hosts file, deduplicating. func Allow(hostsFile, host string) error { existing := make(map[string]bool) if data, err := os.ReadFile(hostsFile); err == nil { scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line != "" { existing[line] = true } } } if existing[host] { return nil } f, err := os.OpenFile(hostsFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return err } defer f.Close() _, err = fmt.Fprintln(f, host) return err } func extractHost(hostport string) string { if i := strings.LastIndex(hostport, ":"); i != -1 { return hostport[:i] } return hostport } diff --git a/proxy/proxy_test.go b/proxy/proxy_test.go new file mode 100644 index 0000000..666ba3d --- /dev/null +++ b/proxy/proxy_test.go @@ -0,0 +1,117 @@ package proxy_test import ( "net/http" "net/http/httptest" "net/url" "os" "path/filepath" "strings" "testing" "github.com/xanderle/nono/proxy" ) func newProxyClient(t *testing.T, proxyURL string) *http.Client { t.Helper() return &http.Client{ Transport: &http.Transport{ Proxy: func(*http.Request) (*url.URL, error) { return url.Parse(proxyURL) }, }, } } func TestShouldDenyUnapprovedHost(t *testing.T) { hostsFile := filepath.Join(t.TempDir(), "approved_hosts") os.WriteFile(hostsFile, []byte(""), 0644) p := proxy.New(hostsFile) srv := httptest.NewServer(p) defer srv.Close() client := newProxyClient(t, srv.URL) resp, err := client.Get("http://example.com/") if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusForbidden { t.Errorf("expected 403 Forbidden, got %d", resp.StatusCode) } } func TestShouldAllowApprovedHost(t *testing.T) { // Backend server simulating the target backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("hello from backend")) })) defer backend.Close() backendURL, _ := url.Parse(backend.URL) hostsFile := filepath.Join(t.TempDir(), "approved_hosts") os.WriteFile(hostsFile, []byte(backendURL.Hostname()+"\n"), 0644) p := proxy.New(hostsFile) srv := httptest.NewServer(p) defer srv.Close() client := newProxyClient(t, srv.URL) resp, err := client.Get(backend.URL + "/test") if err != nil { t.Fatalf("unexpected error: %v", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Errorf("expected 200 OK, got %d", resp.StatusCode) } } func TestAllowShouldAddHostToFile(t *testing.T) { hostsFile := filepath.Join(t.TempDir(), "approved_hosts") os.WriteFile(hostsFile, []byte(""), 0644) if err := proxy.Allow(hostsFile, "example.com"); err != nil { t.Fatalf("unexpected error: %v", err) } data, _ := os.ReadFile(hostsFile) if !strings.Contains(string(data), "example.com") { t.Errorf("expected approved_hosts to contain example.com, got %q", string(data)) } } func TestAllowShouldDeduplicateHosts(t *testing.T) { hostsFile := filepath.Join(t.TempDir(), "approved_hosts") os.WriteFile(hostsFile, []byte("example.com\n"), 0644) if err := proxy.Allow(hostsFile, "example.com"); err != nil { t.Fatalf("unexpected error: %v", err) } data, _ := os.ReadFile(hostsFile) count := strings.Count(string(data), "example.com") if count != 1 { t.Errorf("expected 1 occurrence, got %d in %q", count, string(data)) } } func TestAllowShouldCreateFileIfMissing(t *testing.T) { hostsFile := filepath.Join(t.TempDir(), "approved_hosts") if err := proxy.Allow(hostsFile, "example.com"); err != nil { t.Fatalf("unexpected error: %v", err) } data, _ := os.ReadFile(hostsFile) if !strings.Contains(string(data), "example.com") { t.Errorf("expected approved_hosts to contain example.com, got %q", string(data)) } }