a73x

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))
	}
}