netbirdio/netbird · error

proxy not started

Error message

proxy not started

What it means

Returned by WGUDPProxy.InjectPacket when remoteConn is nil, and by WGUDPProxy.CloseConn when cancel is nil. Both fields are only set by a successful AddTurnConn (it dials the local WG port and stores ctx/cancel/conns). The proxy is explicitly documented as not thread safe, and there is no constructor-time state, so this error is a lifecycle guard: the method was called before AddTurnConn ever ran or before it succeeded.

Source

Thrown at client/iface/wgproxy/udp/proxy.go:164

	p.srcFakerConn = srcFakerConn
	p.sendPkg = p.srcFakerConn.SendPkg
}

// InjectPacket writes b to the remote peer over the underlying transport.
func (p *WGUDPProxy) InjectPacket(b []byte) error {
	if p.remoteConn == nil {
		return errors.New("proxy not started")
	}
	if _, err := p.remoteConn.Write(b); err != nil {
		return err
	}
	return nil
}

// CloseConn close the localConn
func (p *WGUDPProxy) CloseConn() error {
	if p.cancel == nil {
		return fmt.Errorf("proxy not started")
	}
	return p.close()
}

func (p *WGUDPProxy) close() error {
	var result *multierror.Error

	p.closeMu.Lock()
	defer p.closeMu.Unlock()

	// prevent double close
	if p.closed {
		return nil
	}

	p.closeListener.SetCloseListener(nil)
	p.closed = true

View on GitHub (pinned to 93e97f4bf1)

Solutions

  1. Ensure AddTurnConn succeeded (check its error) before any InjectPacket/CloseConn/Work call on the same proxy instance
  2. Order teardown as: stop using the conn, then CloseConn once; guard with your own started flag if multiple goroutines can trigger close
  3. Remember NewWGUDPProxy only allocates - Work() and EndpointAddr() no-op silently until AddTurnConn populates the fields

Example fix

// before
proxy := NewWGUDPProxy(wgPort, mtu)
_ = proxy.InjectPacket(pkt) // "proxy not started"

// after
proxy := NewWGUDPProxy(wgPort, mtu)
if err := proxy.AddTurnConn(ctx, nil, remoteConn); err != nil {
    return fmt.Errorf("add turn conn: %w", err)
}
if err := proxy.InjectPacket(pkt); err != nil {
    return fmt.Errorf("inject: %w", err)
}
Defensive patterns

Strategy: validation

Validate before calling

// call order contract: AddTurnConn must succeed first
if err := proxy.AddTurnConn(ctx, nil, remoteConn); err != nil {
    return fmt.Errorf("start proxy: %w", err)
}
// only now inject/close
if err := proxy.InjectPacket(pkt); err != nil { ... }

Type guard

// wrap the proxy to make the started state explicit for callers
type startedUDPProxy struct{ p *udp.WGUDPProxy }

func startProxy(ctx context.Context, p *udp.WGUDPProxy, rc net.Conn) (*startedUDPProxy, error) {
    if err := p.AddTurnConn(ctx, nil, rc); err != nil {
        return nil, err
    }
    return &startedUDPProxy{p}, nil
}

Try / catch

if err := proxy.InjectPacket(b); err != nil {
    if errors.Is(err, errProxyNotStarted) || strings.Contains(err.Error(), "proxy not started") {
        return fmt.Errorf("lifecycle bug: proxy used before AddTurnConn: %w", err)
    }
    return err
}

Prevention

When it happens

Trigger: Calling InjectPacket or CloseConn on a WGUDPProxy obtained from NewWGUDPProxy before AddTurnConn; calling them after AddTurnConn failed its dial (fields stay nil); races where a second AddTurnConn overwrites state while another goroutine proxies.

Common situations: Integrations/embedders (client/embed) driving the UDP proxy manually; test code exercising close paths; ICE agent code paths that tear down candidates before the proxy is fully wired. In shipped code it surfaces as a programming-order bug rather than an environment issue.

Related errors


AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16). Data as JSON: /api/errors/76cdcbafb745ec3c. Report an issue: GitHub.