From 9856642701068d5b4aa4726efef030aec03f12b9 Mon Sep 17 00:00:00 2001 From: Abhishek Kondur Date: Mon, 1 Jun 2026 10:13:47 +0530 Subject: [PATCH 1/2] Merge pull request #1 from gravitl/dependabot-yaml Configure Dependabot for Go module updates --- .github/dependabot.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..bf6baf5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,15 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file + + +version: 2 +updates: + # Enable version updates for netmaker + - package-ecosystem: "gomod" + directory: "/" + # Check for updates every day (weekdays) + schedule: + interval: "weekly" + target-branch: "develop" From 273ae2face32046c593a51f4d0a7e3eb3e9688ee Mon Sep 17 00:00:00 2001 From: abhishek9686 Date: Mon, 3 Aug 2026 14:45:08 +0800 Subject: [PATCH 2/2] NM-295: organise the code, add L7 HTTP CONECT proxy --- README.md | 20 +- doc.go | 12 +- docs/PROXY_L7_EGRESS.md | 57 ++++ docs/PROXY_PHASE1_ARCHITECTURE.md | 73 ++--- l7/connect.go | 51 ++++ l7/doc.go | 10 + l7/policy.go | 58 ++++ l7/server.go | 248 ++++++++++++++++ l7/server_test.go | 273 ++++++++++++++++++ l7/types.go | 51 ++++ client.go => uplink/client.go | 2 +- uplink/doc.go | 9 + errors.go => uplink/errors.go | 2 +- example_test.go => uplink/example_test.go | 10 +- frame.go => uplink/frame.go | 2 +- frame_test.go => uplink/frame_test.go | 2 +- hellomac.go => uplink/hellomac.go | 2 +- hellomac_test.go => uplink/hellomac_test.go | 2 +- .../integration_test.go | 2 +- interfaces.go => uplink/interfaces.go | 2 +- noop.go => uplink/noop.go | 2 +- protocol.go => uplink/protocol.go | 2 +- registry.go => uplink/registry.go | 2 +- server.go => uplink/server.go | 2 +- types.go => uplink/types.go | 2 +- 25 files changed, 834 insertions(+), 64 deletions(-) create mode 100644 docs/PROXY_L7_EGRESS.md create mode 100644 l7/connect.go create mode 100644 l7/doc.go create mode 100644 l7/policy.go create mode 100644 l7/server.go create mode 100644 l7/server_test.go create mode 100644 l7/types.go rename client.go => uplink/client.go (99%) create mode 100644 uplink/doc.go rename errors.go => uplink/errors.go (98%) rename example_test.go => uplink/example_test.go (80%) rename frame.go => uplink/frame.go (99%) rename frame_test.go => uplink/frame_test.go (98%) rename hellomac.go => uplink/hellomac.go (99%) rename hellomac_test.go => uplink/hellomac_test.go (99%) rename integration_test.go => uplink/integration_test.go (99%) rename interfaces.go => uplink/interfaces.go (98%) rename noop.go => uplink/noop.go (96%) rename protocol.go => uplink/protocol.go (95%) rename registry.go => uplink/registry.go (98%) rename server.go => uplink/server.go (99%) rename types.go => uplink/types.go (99%) diff --git a/README.md b/README.md index 0aec21a..d2c49b4 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,29 @@ # proxy -WireGuard TCP/TLS uplink transport for Netmaker-style relay paths. +Netmaker proxy libraries (one Go module, feature packages). -## Library +## Packages -Import path: `github.com/gravitl/proxy` +| Import | Role | +|--------|------| +| [`github.com/gravitl/proxy/uplink`](uplink/) | Phase 1: TCP/TLS framed WireGuard uplink (C ↔ relay/gateway B) | +| [`github.com/gravitl/proxy/l7`](l7/) | L7: HTTP CONNECT forward proxy for app-domain egress | + +There is **no** root package API — import the subpackage you need. + +### Uplink - **Client**: TCP + TLS + framed `MsgData` carrying WireGuard packet bytes to the relay. - **Server** (relay / gateway, also a WireGuard peer): terminates TLS, authenticates `ClientHello`, registers sessions, and supports `SendToPeer` for reverse traffic. -See package documentation and `example_test.go` for wiring patterns. +See [`uplink/example_test.go`](uplink/example_test.go) and [docs/PROXY_PHASE1_ARCHITECTURE.md](docs/PROXY_PHASE1_ARCHITECTURE.md). -Detailed Phase 1 plan and architecture: [docs/PROXY_PHASE1_ARCHITECTURE.md](docs/PROXY_PHASE1_ARCHITECTURE.md). +### L7 + +Name-based egress via HTTP CONNECT over the mesh (underlay remains WireGuard). See [docs/PROXY_L7_EGRESS.md](docs/PROXY_L7_EGRESS.md). ## Develop ```bash go test ./... -race ``` - diff --git a/doc.go b/doc.go index 603cdb7..9b330fa 100644 --- a/doc.go +++ b/doc.go @@ -1,7 +1,9 @@ -// Package proxy provides a TCP/TLS framed transport for carrying WireGuard packet -// payloads between a relay-attached peer and its relay/gateway (Phase 1 uplink). +// Module github.com/gravitl/proxy hosts Netmaker proxy libraries as subpackages. // -// It owns connection setup, TLS, framing, session lifecycle, keepalive, and -// peer→session registration for reverse traffic. It does not implement routing policy, -// relay selection, or Netmaker control-plane logic—integrate those in a separate adapter. +// Import paths: +// +// github.com/gravitl/proxy/uplink — TCP/TLS framed WireGuard uplink transport +// github.com/gravitl/proxy/l7 — HTTP CONNECT app-domain egress proxy +// +// The module root has no public API; use the packages above. package proxy diff --git a/docs/PROXY_L7_EGRESS.md b/docs/PROXY_L7_EGRESS.md new file mode 100644 index 0000000..8762a97 --- /dev/null +++ b/docs/PROXY_L7_EGRESS.md @@ -0,0 +1,57 @@ +# L7 egress proxy — HTTP CONNECT for app domains + +**Package:** `github.com/gravitl/proxy/l7` +**Status:** HTTP CONNECT MVP implemented (listen, ACL, dial, bidirectional tunnel). + +## Problem + +Egress “app domains” today often resolve domain → IPs, publish ranges, and install WireGuard routes. That is brittle (CDN churn, shared IPs, wildcards). + +## Approach + +Keep the **WireGuard underlay** to the egress gateway. For listed domains, the **client** directs traffic to an L7 proxy on the gateway (proxy settings / PAC / netclient-managed). The gateway dials by **hostname**. + +```text +App --HTTPS--> client proxy settings + --CONNECT api.foo.com:443--> GW_mesh_IP:l7_port (over WG) + --GW dials api.foo.com--> internet +``` + +| Role | Responsibility | +|------|----------------| +| Client / control plane | Which domains use egress L7 | +| WireGuard | Path from client to egress GW | +| `l7.Server` on GW | CONNECT + domain ACL + dial-out | + +CIDR / network egress remains L3. L7 is additive for named apps. + +## Package API + +- `DomainMatcher` / `Allowlist` / `AllowAll` — exact and `*.suffix` domain rules +- `ServerOptions` — `ListenAddr`, `Matcher` (required), optional `Dialer`, timeouts, logger +- `Server.Start` / `Stop` / `Addr` — TCP listen and CONNECT handling +- Responses: `200 Connection Established`, `403` deny, `400` bad request, `502` dial failure + +## Non-goals (for now) + +- SOCKS5, transparent TPROXY, TLS MITM +- PAC generation, UI, control-plane domain publishing (netclient follow-up) +- Sharing code with `uplink` framed WG transport + +## Relation to `uplink` + +| `uplink` | `l7` | +|----------|------| +| Framed WG ciphertext C↔B | HTTP CONNECT to egress GW | +| Userspace Bind inject | `net.Dial` to internet hostnames | +| Same module, separate import | `github.com/gravitl/proxy/l7` | + +## Example (gateway side) + +```go +srv, err := l7.NewServer(l7.ServerOptions{ + ListenAddr: "0.0.0.0:3128", // prefer mesh IP in production + Matcher: l7.Allowlist{Domains: []string{"api.foo.com", "*.saas.com"}}, +}) +// srv.Start(ctx) … srv.Stop(ctx) +``` diff --git a/docs/PROXY_PHASE1_ARCHITECTURE.md b/docs/PROXY_PHASE1_ARCHITECTURE.md index 169ff44..ecb7269 100644 --- a/docs/PROXY_PHASE1_ARCHITECTURE.md +++ b/docs/PROXY_PHASE1_ARCHITECTURE.md @@ -1,8 +1,8 @@ # Netmaker Phase 1 Proxy — Implemented Plan & Current Architecture -**Module:** `github.com/gravitl/proxy` (flat package at repo root; import `github.com/gravitl/proxy`) -**Commit:** `feat(proxy): add TCP/TLS framed transport for relay uplinks` -**Status:** Phase 1 **transport library is implemented and tested**. Netclient/relay integration is **not** currently in the netclient tree. +**Module:** `github.com/gravitl/proxy` +**Import:** `github.com/gravitl/proxy/uplink` (Phase 1 transport; not at module root) +**Status:** Phase 1 **transport library is implemented and tested** under `uplink/`. Netclient wires it via `internal/proxyuplink`. --- @@ -32,34 +32,34 @@ Phase 1: C -- TCP/TLS (WG packets) --> B -- relay --> A/D | Principle | How it shows up | |-----------|-----------------| | Package-first | Standalone Go module, importable | -| Transport ≠ policy | No Netmaker DB, routing, or relay selection inside `proxy` | +| Transport ≠ policy | No Netmaker DB, routing, or relay selection inside `uplink` | | Small API | `Client` + `Server` + hooks | | Plug-and-play | Auth, packet handling, registry, logger, metrics are interfaces/callbacks | -| Extensible later | TLS config, hooks; no HTTP CONNECT / mTLS / multitenancy yet | +| Feature folders | WG uplink in `uplink/`; HTTP CONNECT egress in sibling `l7/` | -**Out of scope (still):** UDP→TCP fallback, HTTP CONNECT, WebSocket, mTLS requirement, active-active relay, node-global proxy mode. +**Out of scope for uplink:** UDP→TCP fallback, WebSocket, mTLS requirement, active-active relay, node-global proxy mode. HTTP CONNECT lives in package `l7` (see PROXY_L7_EGRESS.md). --- ## 3. What was implemented (checklist) -### Done in `gravitl/proxy` +### Done in `gravitl/proxy/uplink` | Item | Status | Location | |------|--------|----------| -| Module + `go.mod` | Done | `go.mod` (Go 1.22) | -| Framing codec | Done | `frame.go` | -| Protocol constants | Done | `protocol.go` | -| Types / states / hello | Done | `types.go` | -| Hooks | Done | `interfaces.go` | -| Errors | Done | `errors.go` | -| In-memory registry | Done | `registry.go` | -| No-op logger/metrics | Done | `noop.go` | -| Client (TLS, HELLO, DATA, ping, reconnect) | Done | `client.go` | -| Server (TLS, auth, registry, SendToPeer) | Done | `server.go` | -| Frame unit tests | Done | `frame_test.go` | -| TLS integration round-trip | Done | `integration_test.go` | -| Package doc + README + example | Done | `doc.go`, `README.md`, `example_test.go` | +| Module + `go.mod` | Done | `go.mod` | +| Framing codec | Done | `uplink/frame.go` | +| Protocol constants | Done | `uplink/protocol.go` | +| Types / states / hello | Done | `uplink/types.go` | +| Hooks | Done | `uplink/interfaces.go` | +| Errors | Done | `uplink/errors.go` | +| In-memory registry | Done | `uplink/registry.go` | +| No-op logger/metrics | Done | `uplink/noop.go` | +| Client (TLS, HELLO, DATA, ping, reconnect) | Done | `uplink/client.go` | +| Server (TLS, auth, registry, SendToPeer) | Done | `uplink/server.go` | +| Frame unit tests | Done | `uplink/frame_test.go` | +| TLS integration round-trip | Done | `uplink/integration_test.go` | +| Package doc + example | Done | `uplink/doc.go`, `uplink/example_test.go` | ### Planned but not landed (outside this repo) @@ -78,22 +78,25 @@ Phase 1: C -- TCP/TLS (WG packets) --> B -- relay --> A/D github.com/gravitl/proxy/ ├── go.mod ├── README.md -├── doc.go # package role / non-goals -├── protocol.go # version, Msg* types, DefaultMaxFrameSize -├── types.go # ClientHello, AuthResult, states, BackoffConfig -├── interfaces.go # PacketHandler, Authenticator, SessionRegistry, Session, Logger, Metrics -├── errors.go -├── frame.go # 12-byte header encode/decode -├── registry.go # InMemoryRegistry (replace-on-attach) -├── noop.go -├── client.go # Client API + supervisor/reconnect -├── server.go # Server API + accept/session loops -├── frame_test.go -├── integration_test.go -└── example_test.go +├── doc.go # module overview (no public API) +├── docs/ +├── uplink/ # Phase 1 WG TCP/TLS transport (import …/uplink) +│ ├── doc.go +│ ├── client.go +│ ├── server.go +│ ├── frame.go +│ ├── protocol.go +│ ├── types.go +│ ├── interfaces.go +│ ├── registry.go +│ ├── hellomac.go +│ ├── errors.go +│ ├── noop.go +│ └── *_test.go +└── l7/ # HTTP CONNECT egress (sibling package) ``` -Note: Original plan used `pkg/proxy`; **as shipped**, types live at module root (`import "github.com/gravitl/proxy"`). +Import: `github.com/gravitl/proxy/uplink` (e.g. `uplink.Client`, `uplink.Server`). --- @@ -336,4 +339,4 @@ Schema: `schema.Node` fields in netmaker; converters in `logic/nodes.go`; popula --- -**One-line summary:** Phase 1 **TCP/TLS framed WG transport library** is complete; **control-plane opt-in flags** for gateway TCP listen and per-node uplink are published via peer updates. Making traffic actually flow still needs **userspace WireGuard (or inject) + netclient adapters** on client and gateway. +**One-line summary:** Phase 1 **TCP/TLS framed WG transport** lives in `github.com/gravitl/proxy/uplink` and is wired by netclient `internal/proxyuplink`. Sibling package `l7` implements HTTP CONNECT egress with domain ACL (see PROXY_L7_EGRESS.md). diff --git a/l7/connect.go b/l7/connect.go new file mode 100644 index 0000000..ea81661 --- /dev/null +++ b/l7/connect.go @@ -0,0 +1,51 @@ +package l7 + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "strings" +) + +const maxConnectHeaderBytes = 64 << 10 + +// parseConnect reads one HTTP request from br and returns the CONNECT target. +// Only Method CONNECT is accepted. The remainder of br (if any) must be forwarded +// to the upstream after a successful tunnel setup. +func parseConnect(br *bufio.Reader) (ConnectTarget, *http.Request, error) { + req, err := http.ReadRequest(br) + if err != nil { + return ConnectTarget{}, nil, fmt.Errorf("%w: %v", ErrBadRequest, err) + } + if req.Method != http.MethodConnect { + return ConnectTarget{}, req, fmt.Errorf("%w: method %s", ErrBadRequest, req.Method) + } + hostPort := req.Host + if hostPort == "" && req.URL != nil { + hostPort = req.URL.Host + } + hostPort = strings.TrimSpace(hostPort) + if hostPort == "" { + return ConnectTarget{}, req, fmt.Errorf("%w: missing host", ErrBadRequest) + } + host, port, err := net.SplitHostPort(hostPort) + if err != nil { + // CONNECT without port — default 443 for HTTPS-style use. + host = hostPort + port = "443" + if strings.Contains(host, ":") { + return ConnectTarget{}, req, fmt.Errorf("%w: invalid host %q", ErrBadRequest, hostPort) + } + } + if host == "" || port == "" { + return ConnectTarget{}, req, fmt.Errorf("%w: invalid host %q", ErrBadRequest, hostPort) + } + return ConnectTarget{Host: host, Port: port}, req, nil +} + +func writeConnectResponse(w io.Writer, status int, reason string) error { + _, err := fmt.Fprintf(w, "HTTP/1.1 %d %s\r\n\r\n", status, reason) + return err +} diff --git a/l7/doc.go b/l7/doc.go new file mode 100644 index 0000000..e55f98f --- /dev/null +++ b/l7/doc.go @@ -0,0 +1,10 @@ +// Package l7 provides an HTTP CONNECT forward proxy for name-based egress. +// +// Product intent: clients direct listed app domains to an egress gateway over the +// mesh (WireGuard underlay). The gateway runs this server, matches CONNECT targets +// against a domain policy, and dials the internet by hostname — avoiding brittle +// domain→IP→route collection used for L3 egress ranges. +// +// This package does not own Netmaker control-plane config, PAC generation, or +// WireGuard. See docs/PROXY_L7_EGRESS.md. +package l7 diff --git a/l7/policy.go b/l7/policy.go new file mode 100644 index 0000000..69fbc5f --- /dev/null +++ b/l7/policy.go @@ -0,0 +1,58 @@ +package l7 + +import ( + "fmt" + "strings" +) + +// DomainMatcher decides whether a CONNECT destination is allowed. +// Implementations are supplied by the integrator (e.g. netclient from control-plane lists). +type DomainMatcher interface { + // Allow returns nil if host (and optional port) may be dialed; otherwise a reason error. + Allow(host, port string) error +} + +// Allowlist matches exact hostnames and optional "*.suffix" wildcards (one or more labels). +// Matching is case-insensitive. Empty Allowlist denies all hosts. +type Allowlist struct { + // Domains are exact names (example.com) or wildcards (*.example.com). + Domains []string +} + +// Allow implements DomainMatcher. +func (a Allowlist) Allow(host, port string) error { + _ = port + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return fmt.Errorf("%w: empty host", ErrBadRequest) + } + for _, raw := range a.Domains { + pat := strings.ToLower(strings.TrimSpace(raw)) + if pat == "" { + continue + } + if strings.HasPrefix(pat, "*.") { + suf := pat[1:] // ".example.com" + if strings.HasSuffix(host, suf) && len(host) > len(suf) { + return nil + } + continue + } + if host == pat { + return nil + } + } + return fmt.Errorf("%w: %s", ErrForbidden, host) +} + +// AllowAll permits any non-empty host (useful for tests; not for production egress). +type AllowAll struct{} + +// Allow implements DomainMatcher. +func (AllowAll) Allow(host, port string) error { + _ = port + if strings.TrimSpace(host) == "" { + return fmt.Errorf("%w: empty host", ErrBadRequest) + } + return nil +} diff --git a/l7/server.go b/l7/server.go new file mode 100644 index 0000000..ed7d6f7 --- /dev/null +++ b/l7/server.go @@ -0,0 +1,248 @@ +package l7 + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "sync" + "time" +) + +const ( + defaultDialTimeout = 15 * time.Second + defaultIdleTimeout = 5 * time.Minute +) + +// Server is an HTTP CONNECT forward proxy intended to listen on a mesh address +// of an egress gateway. +type Server struct { + opts ServerOptions + log Logger + dialer Dialer + mu sync.Mutex + ln net.Listener + cancel context.CancelFunc + closed bool + wg sync.WaitGroup +} + +// Dialer dials TCP destinations for CONNECT tunnels. +type Dialer interface { + DialContext(ctx context.Context, network, address string) (net.Conn, error) +} + +// NewServer validates options and returns a Server. Does not listen until Start. +func NewServer(opts ServerOptions) (*Server, error) { + if opts.ListenAddr == "" { + return nil, errors.New("l7: ListenAddr is required") + } + if opts.Matcher == nil { + return nil, errors.New("l7: Matcher is required") + } + log := opts.Logger + if log == nil { + log = noopLogger{} + } + dialer := opts.Dialer + if dialer == nil { + d := &net.Dialer{Timeout: opts.DialTimeout} + if d.Timeout <= 0 { + d.Timeout = defaultDialTimeout + } + dialer = d + } + return &Server{opts: opts, log: log, dialer: dialer}, nil +} + +// Start binds ListenAddr and serves HTTP CONNECT. +func (s *Server) Start(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ErrServerClosed + } + if s.ln != nil { + return errors.New("l7: server already started") + } + ln, err := net.Listen("tcp", s.opts.ListenAddr) + if err != nil { + return fmt.Errorf("l7: listen: %w", err) + } + runCtx, cancel := context.WithCancel(ctx) + s.ln = ln + s.cancel = cancel + s.log.Info("l7 CONNECT proxy listening", "addr", ln.Addr().String()) + s.wg.Add(1) + go func() { + defer s.wg.Done() + s.serve(runCtx, ln) + }() + return nil +} + +// Addr returns the bound address, or "" if not listening. +func (s *Server) Addr() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.ln == nil { + return "" + } + return s.ln.Addr().String() +} + +// Stop closes the listener and waits for in-flight handlers to finish (bounded by ctx). +func (s *Server) Stop(ctx context.Context) error { + s.mu.Lock() + s.closed = true + if s.cancel != nil { + s.cancel() + s.cancel = nil + } + var err error + if s.ln != nil { + err = s.ln.Close() + s.ln = nil + } + s.mu.Unlock() + + done := make(chan struct{}) + go func() { + s.wg.Wait() + close(done) + }() + select { + case <-done: + case <-ctx.Done(): + if err == nil { + err = ctx.Err() + } + } + return err +} + +func (s *Server) serve(ctx context.Context, ln net.Listener) { + for { + conn, err := ln.Accept() + if err != nil { + select { + case <-ctx.Done(): + return + default: + s.log.Debug("accept ended", "err", err) + return + } + } + s.wg.Add(1) + go func(c net.Conn) { + defer s.wg.Done() + s.handleConn(ctx, c) + }(conn) + } +} + +func (s *Server) handleConn(ctx context.Context, client net.Conn) { + defer client.Close() + + _ = client.SetDeadline(time.Now().Add(s.headerTimeout())) + br := bufio.NewReaderSize(client, maxConnectHeaderBytes) + target, _, err := parseConnect(br) + if err != nil { + s.log.Debug("bad CONNECT", "err", err) + _ = writeConnectResponse(client, 400, "Bad Request") + return + } + _ = client.SetDeadline(time.Time{}) + + if err := s.opts.Matcher.Allow(target.Host, target.Port); err != nil { + s.log.Info("CONNECT denied", "host", target.Host, "port", target.Port, "err", err) + status := 403 + reason := "Forbidden" + if errors.Is(err, ErrBadRequest) { + status = 400 + reason = "Bad Request" + } + _ = writeConnectResponse(client, status, reason) + return + } + + dialTimeout := s.opts.DialTimeout + if dialTimeout <= 0 { + dialTimeout = defaultDialTimeout + } + dialCtx, cancel := context.WithTimeout(ctx, dialTimeout) + upstream, err := s.dialer.DialContext(dialCtx, "tcp", target.HostPort()) + cancel() + if err != nil { + s.log.Warn("CONNECT dial failed", "target", target.HostPort(), "err", err) + _ = writeConnectResponse(client, 502, "Bad Gateway") + return + } + defer upstream.Close() + + if err := writeConnectResponse(client, 200, "Connection Established"); err != nil { + s.log.Debug("write 200 failed", "err", err) + return + } + + idle := s.opts.IdleTimeout + if idle <= 0 { + idle = defaultIdleTimeout + } + deadline := time.Now().Add(idle) + _ = client.SetDeadline(deadline) + _ = upstream.SetDeadline(deadline) + + // Any bytes buffered past the CONNECT headers (e.g. TLS ClientHello) must go upstream. + clientReader := io.Reader(br) + if br.Buffered() == 0 { + clientReader = client + } else { + clientReader = io.MultiReader(br, client) + } + + s.log.Debug("CONNECT tunnel up", "target", target.HostPort()) + tunnel(client, upstream, clientReader) +} + +func (s *Server) headerTimeout() time.Duration { + if s.opts.DialTimeout > 0 { + return s.opts.DialTimeout + } + return defaultDialTimeout +} + +func tunnel(client, upstream net.Conn, clientReader io.Reader) { + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + _, _ = io.Copy(upstream, clientReader) + _ = closeWrite(upstream) + }() + go func() { + defer wg.Done() + _, _ = io.Copy(client, upstream) + _ = closeWrite(client) + }() + wg.Wait() +} + +func closeWrite(c net.Conn) error { + type closeWriter interface { + CloseWrite() error + } + if cw, ok := c.(closeWriter); ok { + return cw.CloseWrite() + } + return nil +} + +type noopLogger struct{} + +func (noopLogger) Debug(string, ...any) {} +func (noopLogger) Info(string, ...any) {} +func (noopLogger) Warn(string, ...any) {} +func (noopLogger) Error(string, ...any) {} diff --git a/l7/server_test.go b/l7/server_test.go new file mode 100644 index 0000000..5d155f2 --- /dev/null +++ b/l7/server_test.go @@ -0,0 +1,273 @@ +package l7_test + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/gravitl/proxy/l7" +) + +func TestAllowlist(t *testing.T) { + m := l7.Allowlist{Domains: []string{"api.example.com", "*.saas.com"}} + if err := m.Allow("api.example.com", "443"); err != nil { + t.Fatalf("exact allow: %v", err) + } + if err := m.Allow("app.saas.com", "443"); err != nil { + t.Fatalf("wildcard allow: %v", err) + } + if err := m.Allow("evil.com", "443"); err == nil { + t.Fatal("expected deny for evil.com") + } +} + +func TestNewServerRequiresListenAddrAndMatcher(t *testing.T) { + if _, err := l7.NewServer(l7.ServerOptions{}); err == nil { + t.Fatal("expected error for empty options") + } + if _, err := l7.NewServer(l7.ServerOptions{ListenAddr: "127.0.0.1:0"}); err == nil { + t.Fatal("expected error without Matcher") + } +} + +func TestConnectAllowedRoundTrip(t *testing.T) { + backend, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backend.Close() + + backendDone := make(chan struct{}) + go func() { + defer close(backendDone) + c, err := backend.Accept() + if err != nil { + return + } + defer c.Close() + buf := make([]byte, 5) + if _, err := io.ReadFull(c, buf); err != nil { + return + } + _, _ = c.Write([]byte("world")) + }() + + host, port, _ := net.SplitHostPort(backend.Addr().String()) + srv, err := l7.NewServer(l7.ServerOptions{ + ListenAddr: "127.0.0.1:0", + Matcher: l7.Allowlist{Domains: []string{host}}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop(context.Background()) }() + + client, err := net.DialTimeout("tcp", srv.Addr(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + req := fmt.Sprintf("CONNECT %s:%s HTTP/1.1\r\nHost: %s:%s\r\n\r\n", host, port, host, port) + if _, err := io.WriteString(client, req); err != nil { + t.Fatal(err) + } + br := bufio.NewReader(client) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status=%d", resp.StatusCode) + } + + if _, err := io.WriteString(client, "hello"); err != nil { + t.Fatal(err) + } + out := make([]byte, 5) + if _, err := io.ReadFull(br, out); err != nil { + t.Fatal(err) + } + if string(out) != "world" { + t.Fatalf("got %q", out) + } + <-backendDone +} + +func TestConnectDenied(t *testing.T) { + srv, err := l7.NewServer(l7.ServerOptions{ + ListenAddr: "127.0.0.1:0", + Matcher: l7.Allowlist{Domains: []string{"allowed.example"}}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop(context.Background()) }() + + client, err := net.DialTimeout("tcp", srv.Addr(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + _, _ = io.WriteString(client, "CONNECT evil.example:443 HTTP/1.1\r\nHost: evil.example:443\r\n\r\n") + br := bufio.NewReader(client) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 403 { + t.Fatalf("status=%d want 403", resp.StatusCode) + } +} + +func TestConnectBadMethod(t *testing.T) { + srv, err := l7.NewServer(l7.ServerOptions{ + ListenAddr: "127.0.0.1:0", + Matcher: l7.AllowAll{}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop(context.Background()) }() + + client, err := net.DialTimeout("tcp", srv.Addr(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + _, _ = io.WriteString(client, "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n") + br := bufio.NewReader(client) + resp, err := http.ReadResponse(br, nil) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 400 { + t.Fatalf("status=%d want 400", resp.StatusCode) + } +} + +func TestConnectDialFailure(t *testing.T) { + // Port with nothing listening. + srv, err := l7.NewServer(l7.ServerOptions{ + ListenAddr: "127.0.0.1:0", + Matcher: l7.AllowAll{}, + DialTimeout: 500 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop(context.Background()) }() + + client, err := net.DialTimeout("tcp", srv.Addr(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + // Use a high port unlikely to be open on localhost. + req := "CONNECT 127.0.0.1:1 HTTP/1.1\r\nHost: 127.0.0.1:1\r\n\r\n" + if _, err := io.WriteString(client, req); err != nil { + t.Fatal(err) + } + br := bufio.NewReader(client) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 502 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 64)) + t.Fatalf("status=%d want 502 body=%q", resp.StatusCode, body) + } +} + +func TestParseConnectViaServerBufferedPayload(t *testing.T) { + // Ensure bytes after CONNECT headers reach the backend (TLS ClientHello case). + backend, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer backend.Close() + + got := make(chan string, 1) + go func() { + c, err := backend.Accept() + if err != nil { + return + } + defer c.Close() + buf := make([]byte, 64) + n, _ := c.Read(buf) + got <- string(buf[:n]) + }() + + host, port, _ := net.SplitHostPort(backend.Addr().String()) + srv, err := l7.NewServer(l7.ServerOptions{ + ListenAddr: "127.0.0.1:0", + Matcher: l7.AllowAll{}, + }) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if err := srv.Start(ctx); err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop(context.Background()) }() + + client, err := net.DialTimeout("tcp", srv.Addr(), 2*time.Second) + if err != nil { + t.Fatal(err) + } + defer client.Close() + + payload := "early-bytes" + msg := fmt.Sprintf("CONNECT %s:%s HTTP/1.1\r\nHost: %s:%s\r\n\r\n%s", host, port, host, port, payload) + if _, err := io.WriteString(client, msg); err != nil { + t.Fatal(err) + } + br := bufio.NewReader(client) + resp, err := http.ReadResponse(br, &http.Request{Method: http.MethodConnect}) + if err != nil { + t.Fatal(err) + } + if resp.StatusCode != 200 { + t.Fatalf("status=%d", resp.StatusCode) + } + select { + case s := <-got: + if !strings.HasPrefix(s, payload) { + t.Fatalf("backend got %q", s) + } + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for backend") + } +} diff --git a/l7/types.go b/l7/types.go new file mode 100644 index 0000000..ce8df9f --- /dev/null +++ b/l7/types.go @@ -0,0 +1,51 @@ +package l7 + +import ( + "errors" + "time" +) + +// Common errors returned by the L7 proxy. +var ( + ErrServerClosed = errors.New("l7: server closed") + ErrForbidden = errors.New("l7: destination not allowed") + ErrBadRequest = errors.New("l7: bad CONNECT request") +) + +// ConnectTarget is the host:port from an HTTP CONNECT request. +type ConnectTarget struct { + Host string + Port string // e.g. "443" +} + +// HostPort returns host:port for dialing. +func (t ConnectTarget) HostPort() string { + if t.Port == "" { + return t.Host + } + return t.Host + ":" + t.Port +} + +// ServerOptions configures the CONNECT forward proxy. +type ServerOptions struct { + // ListenAddr is the TCP address to bind (typically a mesh IP:port on the egress GW). + ListenAddr string + // Matcher decides whether a CONNECT target is allowed (required). + Matcher DomainMatcher + // Dialer is optional; nil uses net.Dialer with DialTimeout. + Dialer Dialer + // Logger is optional; nil uses a no-op logger. + Logger Logger + // DialTimeout bounds outbound dials and CONNECT header read (default 15s). + DialTimeout time.Duration + // IdleTimeout is the max lifetime of an established tunnel (default 5m). + IdleTimeout time.Duration +} + +// Logger is a minimal structured logging facade. +type Logger interface { + Debug(msg string, kv ...any) + Info(msg string, kv ...any) + Warn(msg string, kv ...any) + Error(msg string, kv ...any) +} diff --git a/client.go b/uplink/client.go similarity index 99% rename from client.go rename to uplink/client.go index 4fec7a1..49e181f 100644 --- a/client.go +++ b/uplink/client.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "context" diff --git a/uplink/doc.go b/uplink/doc.go new file mode 100644 index 0000000..3b116ad --- /dev/null +++ b/uplink/doc.go @@ -0,0 +1,9 @@ +// Package uplink provides a TCP/TLS framed transport for carrying WireGuard packet +// payloads between a relay-attached peer and its relay/gateway (Phase 1 uplink). +// +// It owns connection setup, TLS, framing, session lifecycle, keepalive, and +// peer→session registration for reverse traffic. It does not implement routing policy, +// relay selection, or Netmaker control-plane logic—integrate those in a separate adapter. +// +// For application-layer (HTTP CONNECT) egress, see package l7. +package uplink diff --git a/errors.go b/uplink/errors.go similarity index 98% rename from errors.go rename to uplink/errors.go index 0a87316..7320931 100644 --- a/errors.go +++ b/uplink/errors.go @@ -1,4 +1,4 @@ -package proxy +package uplink import "errors" diff --git a/example_test.go b/uplink/example_test.go similarity index 80% rename from example_test.go rename to uplink/example_test.go index d77e06e..6c5bcab 100644 --- a/example_test.go +++ b/uplink/example_test.go @@ -1,4 +1,4 @@ -package proxy_test +package uplink_test import ( "context" @@ -6,19 +6,19 @@ import ( "fmt" "time" - "github.com/gravitl/proxy" + "github.com/gravitl/proxy/uplink" ) func ExampleClient() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - c, err := proxy.NewClient(proxy.ClientOptions{ + c, err := uplink.NewClient(uplink.ClientOptions{ Addr: "relay.example.com:443", ServerName: "relay.example.com", TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, - HelloFactory: func() (proxy.ClientHello, error) { - return proxy.ClientHello{ + HelloFactory: func() (uplink.ClientHello, error) { + return uplink.ClientHello{ Version: 1, NodeID: "node", RelayPeerID: "relay", PublicKey: "wg-pubkey", Proof: "proof", Timestamp: time.Now().Unix(), diff --git a/frame.go b/uplink/frame.go similarity index 99% rename from frame.go rename to uplink/frame.go index 92c1384..82fd26e 100644 --- a/frame.go +++ b/uplink/frame.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "encoding/binary" diff --git a/frame_test.go b/uplink/frame_test.go similarity index 98% rename from frame_test.go rename to uplink/frame_test.go index 6bc4244..c55456b 100644 --- a/frame_test.go +++ b/uplink/frame_test.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "bytes" diff --git a/hellomac.go b/uplink/hellomac.go similarity index 99% rename from hellomac.go rename to uplink/hellomac.go index f0983b9..b1a6b08 100644 --- a/hellomac.go +++ b/uplink/hellomac.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "crypto/ecdh" diff --git a/hellomac_test.go b/uplink/hellomac_test.go similarity index 99% rename from hellomac_test.go rename to uplink/hellomac_test.go index 9c7d855..5738035 100644 --- a/hellomac_test.go +++ b/uplink/hellomac_test.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "bytes" diff --git a/integration_test.go b/uplink/integration_test.go similarity index 99% rename from integration_test.go rename to uplink/integration_test.go index a3446b7..a522c31 100644 --- a/integration_test.go +++ b/uplink/integration_test.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "context" diff --git a/interfaces.go b/uplink/interfaces.go similarity index 98% rename from interfaces.go rename to uplink/interfaces.go index 4597552..4196b1d 100644 --- a/interfaces.go +++ b/uplink/interfaces.go @@ -1,4 +1,4 @@ -package proxy +package uplink import "context" diff --git a/noop.go b/uplink/noop.go similarity index 96% rename from noop.go rename to uplink/noop.go index 409e83f..6ee1c17 100644 --- a/noop.go +++ b/uplink/noop.go @@ -1,4 +1,4 @@ -package proxy +package uplink type noopLogger struct{} diff --git a/protocol.go b/uplink/protocol.go similarity index 95% rename from protocol.go rename to uplink/protocol.go index e9e6657..bf5ef73 100644 --- a/protocol.go +++ b/uplink/protocol.go @@ -1,4 +1,4 @@ -package proxy +package uplink // Protocol version for Phase 1 framing. const ProtocolVersion uint8 = 1 diff --git a/registry.go b/uplink/registry.go similarity index 98% rename from registry.go rename to uplink/registry.go index 4043935..76e4391 100644 --- a/registry.go +++ b/uplink/registry.go @@ -1,4 +1,4 @@ -package proxy +package uplink import "sync" diff --git a/server.go b/uplink/server.go similarity index 99% rename from server.go rename to uplink/server.go index eaad0b3..65da31e 100644 --- a/server.go +++ b/uplink/server.go @@ -1,4 +1,4 @@ -package proxy +package uplink import ( "context" diff --git a/types.go b/uplink/types.go similarity index 99% rename from types.go rename to uplink/types.go index b4ba197..9a08861 100644 --- a/types.go +++ b/uplink/types.go @@ -1,4 +1,4 @@ -package proxy +package uplink import "time"