Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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"
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```

12 changes: 7 additions & 5 deletions doc.go
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions docs/PROXY_L7_EGRESS.md
Original file line number Diff line number Diff line change
@@ -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)
```
73 changes: 38 additions & 35 deletions docs/PROXY_PHASE1_ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -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`.

---

Expand Down Expand Up @@ -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)

Expand All @@ -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`).

---

Expand Down Expand Up @@ -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).
51 changes: 51 additions & 0 deletions l7/connect.go
Original file line number Diff line number Diff line change
@@ -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
}
10 changes: 10 additions & 0 deletions l7/doc.go
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions l7/policy.go
Original file line number Diff line number Diff line change
@@ -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
Comment thread
abhishek9686 marked this conversation as resolved.
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
}
Loading