a73x

26da5a21

proxy

a73x   2026-03-29 10:22

Commit message
proxy

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 }