Skip to content

create HTTP proxy that uses kindling#346

Draft
garmr-ulfr wants to merge 3 commits intomainfrom
kindling-proxy
Draft

create HTTP proxy that uses kindling#346
garmr-ulfr wants to merge 3 commits intomainfrom
kindling-proxy

Conversation

@garmr-ulfr
Copy link
Collaborator

@garmr-ulfr garmr-ulfr commented Feb 27, 2026

This pull request introduces a new HTTP proxy component called KindlingProxy and integrates it into the Radiance application. The proxy is started automatically with Radiance and is configured as an outbound option for VPN traffic. The changes are grouped into two main themes: new feature implementation and integration with the VPN system.

New Feature: Kindling HTTP Proxy

  • Added a new file kindling/proxy.go implementing the KindlingProxy, which acts as an HTTP proxy server listening on 127.0.0.1:14988. It forwards requests and responses between clients and remote servers.

Integration with Radiance and VPN

  • Updated the Radiance struct in radiance.go to include a kindlingProxy field, and modified the initialization logic to create, start, and close the proxy during application startup and shutdown. [1] [2]
  • Registered the Kindling proxy as an HTTP outbound option in the VPN configuration (vpn/boxoptions.go), enabling VPN traffic to be routed through the proxy.
  • Added the necessary import for the new kindling package in vpn/boxoptions.go to support the proxy integration.

Copilot AI review requested due to automatic review settings February 27, 2026 22:17
@garmr-ulfr garmr-ulfr requested review from Copilot and myleshorton and removed request for Copilot February 27, 2026 22:19
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new local HTTP proxy component (KindlingProxy) backed by the kindling HTTP client, starts it automatically as part of Radiance startup/shutdown, and registers it as an HTTP outbound option in the VPN (sing-box) configuration.

Changes:

  • Added kindling/proxy.go implementing a local HTTP proxy server on 127.0.0.1:14988.
  • Updated radiance.go to create/start the proxy and close it during shutdown.
  • Updated vpn/boxoptions.go to register the proxy as an HTTP outbound (kindling-proxy) pointing at 127.0.0.1:${kindling.ProxyPort}.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.

File Description
kindling/proxy.go New Kindling-backed local HTTP proxy implementation.
radiance.go Starts/stops the Kindling proxy as part of Radiance lifecycle.
vpn/boxoptions.go Adds a new HTTP outbound option targeting the local Kindling proxy.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +108 to +117
{
Type: C.TypeHTTP,
Tag: "kindling-proxy",
Options: &O.HTTPOutboundOptions{
ServerOptions: O.ServerOptions{
Server: "127.0.0.1",
ServerPort: kindling.ProxyPort,
},
},
},
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an HTTP outbound that points at the local Kindling proxy. For HTTPS/TLS traffic, sing-box (and most HTTP proxy clients) will use CONNECT tunneling, so the local proxy server must implement CONNECT correctly; otherwise selecting this outbound will break most traffic. Please ensure the proxy implementation supports CONNECT before wiring it in as an outbound option.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +30
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}

outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The proxy handler currently forwards all methods using http.Client.Do. This will not work for HTTP CONNECT requests (commonly used to tunnel TLS through an HTTP proxy): net/http will treat CONNECT as a normal request instead of hijacking and tunneling bytes, so HTTPS/proxied TCP traffic will fail. Add explicit CONNECT handling (hijack the client conn, dial r.Host, write a 200 response, then bidirectionally copy until close).

Copilot uses AI. Check for mistakes.
Comment on lines +15 to +51
}

func NewKindlingProxy(addr string) *KindlingProxy {
proxy := &KindlingProxy{
server: &http.Server{
Addr: addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}

outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
outReq.Header = r.Header.Clone()

resp, err := HTTPClient().Do(outReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()

for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),
},
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HTTPClient() is called for every proxied request. In this codebase HTTPClient() constructs a new kindling-backed http.Client each time, which can be expensive and may create many transports/connections under load. Store a single *http.Client on KindlingProxy (created once in NewKindlingProxy) and reuse it for Do calls.

Suggested change
}
func NewKindlingProxy(addr string) *KindlingProxy {
proxy := &KindlingProxy{
server: &http.Server{
Addr: addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}
outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
outReq.Header = r.Header.Clone()
resp, err := HTTPClient().Do(outReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),
},
client *http.Client
}
func NewKindlingProxy(addr string) *KindlingProxy {
proxy := &KindlingProxy{
client: HTTPClient(),
}
proxy.server = &http.Server{
Addr: addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}
outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
outReq.Header = r.Header.Clone()
resp, err := proxy.client.Do(outReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),

Copilot uses AI. Check for mistakes.
Comment on lines +43 to +50
for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Response forwarding should filter hop-by-hop headers (e.g., Connection, Proxy-Connection, Keep-Alive, Transfer-Encoding, Upgrade, etc.) instead of copying all headers verbatim, otherwise clients can see incorrect connection semantics. Also io.Copy's error is ignored here; please handle it (and consider logging/aborting the request on copy failure).

Copilot uses AI. Check for mistakes.
Comment on lines +17 to +54
func NewKindlingProxy(addr string) *KindlingProxy {
proxy := &KindlingProxy{
server: &http.Server{
Addr: addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}

outReq, err := http.NewRequestWithContext(r.Context(), r.Method, r.URL.String(), r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
outReq.Header = r.Header.Clone()

resp, err := HTTPClient().Do(outReq)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()

for key, values := range resp.Header {
for _, value := range values {
w.Header().Add(key, value)
}
}
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
}),
},
}
return proxy
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing tests in kindling/ (e.g., client_test.go), but the new proxy has no coverage. Please add tests for the proxy behavior, especially CONNECT tunneling (and at least one plain HTTP request) to ensure the sing-box HTTP outbound integration won’t regress.

Copilot uses AI. Check for mistakes.
Comment on lines +140 to +144
go func() {
if err := r.kindlingProxy.ListenAndServe(); err != nil {
slog.Error("Kindling proxy stopped with error", "error", err)
}
}()
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListenAndServe returns http.ErrServerClosed on normal shutdown; logging that as an error will produce noisy/false error logs during clean app exits. Consider treating ErrServerClosed as expected (return without logging or log at debug level).

Copilot uses AI. Check for mistakes.
slog.Error("Kindling proxy stopped with error", "error", err)
}
}()
r.addShutdownFunc(func(ctx context.Context) error { return r.kindlingProxy.Close() })
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shutdown currently calls server.Close(), which aborts active connections immediately. If you intend a graceful shutdown (as described in the PR), prefer server.Shutdown(ctx) and pass through a context with a timeout from Radiance.Close so in-flight proxy requests can complete cleanly.

Suggested change
r.addShutdownFunc(func(ctx context.Context) error { return r.kindlingProxy.Close() })
r.addShutdownFunc(func(ctx context.Context) error { return r.kindlingProxy.Shutdown(ctx) })

Copilot uses AI. Check for mistakes.
Comment on lines +22 to +27
if r.URL.Scheme == "" {
r.URL.Scheme = "https"
}
if r.URL.Host == "" {
r.URL.Host = r.Host
}
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defaulting requests with an empty URL scheme to "https" can misroute origin-form proxy requests (which often omit scheme/host) and will break plain HTTP destinations. Consider deriving the scheme from the incoming request/headers (or default to "http"), and only rewrite when you can do so unambiguously.

Copilot uses AI. Check for mistakes.
Comment on lines +135 to +145
kindlingProxy: kindling.NewKindlingProxy(kindling.ProxyAddr),
shutdownFuncs: shutdownFuncs,
stopChan: make(chan struct{}),
closeOnce: sync.Once{},
}
go func() {
if err := r.kindlingProxy.ListenAndServe(); err != nil {
slog.Error("Kindling proxy stopped with error", "error", err)
}
}()
r.addShutdownFunc(func(ctx context.Context) error { return r.kindlingProxy.Close() })
Copy link

Copilot AI Feb 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Kindling proxy is started asynchronously and any bind/startup error is only logged; NewRadiance still returns success. If the port is in use or the listener can’t start, the VPN outbound that points at 127.0.0.1:14988 will silently be broken. Consider creating the listener synchronously (net.Listen) and returning an error from NewRadiance on failure, or otherwise exposing readiness/failure to the caller.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +20 to +74
func NewKindlingProxy(addr string) *KindlingProxy {
proxy := &KindlingProxy{
server: &http.Server{
Addr: addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "only CONNECT method is supported", http.StatusMethodNotAllowed)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer clientConn.Close()

// Send 200 OK to client
_, err = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
if err != nil {
return
}
req, err := http.ReadRequest(bufio.NewReader(clientConn))
if err != nil {
writeErrorToConn(clientConn, http.StatusBadRequest, err.Error())
return
}
req.URL = &url.URL{
Scheme: "http",
Host: r.Host,
Path: req.URL.Path,
RawQuery: req.URL.RawQuery,
}
req.RequestURI = ""
resp, err := HTTPClient().Do(req)
if err != nil {
writeErrorToConn(clientConn, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()

resp.Write(clientConn)
}),
},
}
return proxy
}

func (p *KindlingProxy) ListenAndServe() error {
return p.server.ListenAndServe()
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are existing CONNECT-tunneling tests in bypass/bypass_test.go, but this new proxy implementation isn’t covered by tests. Adding unit tests for the proxy’s CONNECT behavior (successful tunnel, bad upstream dial, and ensuring bidirectional copy/close behavior) would help prevent regressions and confirm the proxy works with sing-box’s HTTP outbound expectations.

Copilot uses AI. Check for mistakes.
Comment on lines +24 to +66
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodConnect {
http.Error(w, "only CONNECT method is supported", http.StatusMethodNotAllowed)
return
}
hijacker, ok := w.(http.Hijacker)
if !ok {
http.Error(w, "Hijacking not supported", http.StatusInternalServerError)
return
}
clientConn, _, err := hijacker.Hijack()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer clientConn.Close()

// Send 200 OK to client
_, err = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
if err != nil {
return
}
req, err := http.ReadRequest(bufio.NewReader(clientConn))
if err != nil {
writeErrorToConn(clientConn, http.StatusBadRequest, err.Error())
return
}
req.URL = &url.URL{
Scheme: "http",
Host: r.Host,
Path: req.URL.Path,
RawQuery: req.URL.RawQuery,
}
req.RequestURI = ""
resp, err := HTTPClient().Do(req)
if err != nil {
writeErrorToConn(clientConn, http.StatusBadGateway, err.Error())
return
}
defer resp.Body.Close()

resp.Write(clientConn)
}),
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CONNECT handling here is not implementing an HTTP CONNECT tunnel. After sending "200 Connection Established" the proxy should treat the connection as a raw byte stream and relay traffic to the CONNECT target (r.Host), but this code instead calls http.ReadRequest on the hijacked conn and issues an HTTP request via HTTPClient(). That will hang/fail for typical CONNECT usage (e.g., TLS handshake bytes) and will also close the connection after a single request due to defer clientConn.Close(). Rework this handler to dial the target and bidirectionally copy bytes between clientConn and the upstream connection until either side closes.

Copilot uses AI. Check for mistakes.
Comment on lines +142 to +150
go func() {
if err := r.kindlingProxy.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
slog.Error("Kindling proxy stopped with error", "error", err)
}
}
}()
r.addShutdownFunc(func(ctx context.Context) error { return r.kindlingProxy.Close() })

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NewRadiance starts the Kindling proxy in a goroutine and only logs errors from ListenAndServe. If the port is already in use or bind fails, Radiance will still start successfully but the "kindling-proxy" outbound will be configured to point at a proxy that isn't running, causing hard-to-diagnose runtime failures. Consider binding the listener during startup (e.g., net.Listen) and returning an error if it fails, or otherwise making startup conditional/observable so callers can handle the failure.

Copilot uses AI. Check for mistakes.
@garmr-ulfr garmr-ulfr marked this pull request as draft March 4, 2026 21:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants