frp — Architecture#
Architectural style#
Client-Server Reverse Proxy Tunnel — Layered with Protocol Abstraction
frp is a purpose-built reverse-proxy tunneling system organized around two symmetrical domains: a server binary (frps) that runs on the public-IP machine, and a client binary (frpc) that runs behind NAT. The architecture is layered:
- Transport layer — multi-protocol listeners (TCP/KCP/QUIC/WebSocket/TLS) that all funnel into a single stream-multiplex abstraction (yamux or QUIC streams)
- Control plane — a persistent, multiplexed JSON-message channel between frpc and frps (
pkg/msg+pkg/transport) that handles login, proxy registration, heartbeat, and work-connection allocation - Data plane — per-proxy “work connections” that carry actual user traffic, established on demand by the control plane
- Plugin / extension layer — HTTP webhook plugins (server-side) and visitor p2p tunnels (client-side) that hook into lifecycle events
Evidence: server/service.go wires all transports in NewService; client/control.go wraps a msg.Dispatcher and transport.MessageTransporter for the multiplexed control channel; server/proxy/ and client/proxy/ are parallel sets of per-proxy-type handlers that form the data plane.
Component diagram (textual)#
┌─────────────────────────────────────────────────────────────────┐
│ frps (server) │
│ │
│ ┌────────────┐ demux by first-byte ┌─────────────────┐ │
│ │ mux.Mux │ ──────────────────────► │ websocket / TLS │ │
│ │ (TCP:7000) │ ─(default)────────────► │ TCP listener │ │
│ └────────────┘ └────────┬────────┘ │
│ │ │
│ ┌─────────────┐ ┌────────────┐ │ Accept │
│ │ KCP :7000 │ │ QUIC :7000 │ ▼ │
│ └──────┬──────┘ └─────┬──────┘ ┌─────────────────┐ │
│ │ │ │ HandleListener │ │
│ └───────────────┘ │ (yamux session) │ │
│ ▼ └────────┬────────┘ │
│ handleConnection │ │
│ │ │ │
│ ┌─────────┴──────────┐ ┌─────────┴─────────┐ │
│ │ msg.Login │ │ msg.NewWorkConn │ │
│ │ → RegisterControl │ │ → RegisterWorkConn│ │
│ └─────────┬──────────┘ └─────────┬─────────┘ │
│ │ │ │
│ ┌─────────▼──────────┐ ┌─────────▼─────────┐ │
│ │ server.Control │ request │ server/proxy/ │ │
│ │ (per frpc client) │ ──────────► │ Handler (per type)│ │
│ │ ControlManager │ └───────────────────┘ │
│ └─────────┬──────────┘ │
│ │ ResourceController │
│ ┌─────────▼──────────────────────────────────────┐ │
│ │ ports.Manager │ group.Ctl │ vhost.Routers │ │
│ │ visitor.Manager │ plugin.Manager │ │
│ └────────────────────────────────────────────────┘ │
│ │
│ web dashboard (HTTP) SSH gateway NatHole controller │
└─────────────────────────────────────────────────────────────────┘
▲ TCP/KCP/QUIC control connection (yamux mux)
│ JSON-framed msg.* protocol
│
┌─────────────────────────────────────────────────────────────────┐
│ frpc (client) │
│ │
│ cmd/frpc → client.Service │
│ │ │
│ loopLoginUntilSuccess ──► login() ──► msg.Login/LoginResp │
│ │ │
│ ┌────▼────────────────────────────────────────────────────┐ │
│ │ client.Control │ │
│ │ msg.Dispatcher ◄──── control conn (encrypted) ──── │ │
│ │ transport.MessageTransporter │ │
│ │ │ │ │
│ │ ┌─────▼──────┐ ┌───────────────┐ │ │
│ │ │proxy.Manager│ │visitor.Manager│ │ │
│ │ │(per proxy) │ │(STCP/XTCP) │ │ │
│ │ └─────┬──────┘ └───────────────┘ │ │
│ └────────┼──────────────────────────────────────────────-─┘ │
│ │ │
│ Work connection pool ────► local service (e.g. :22, :80) │
│ │
│ admin web server │ vnet WireGuard controller │
└─────────────────────────────────────────────────────────────────┘Core components#
server.Service#
- Package:
server - Responsibility: Top-level server lifecycle. Owns all network listeners (TCP, KCP, QUIC, WebSocket, TLS, SSH gateway), a protocol-sniffing mux (
mux.Mux) on the main port, the web dashboard server, the NatHole controller, and all sub-managers. Dispatches every accepted connection tohandleConnectionafter optional yamux stream setup. - Key types:
Servicestruct,NewService(*v1.ServerConfig),Run(context.Context) - Dependencies:
server/controller,server/proxy,server/ports,server/group,server/registry,server/visitor,pkg/auth,pkg/msg,pkg/transport,pkg/util/vhost,pkg/nathole,pkg/ssh,github.com/hashicorp/yamux,github.com/quic-go/quic-go
server.Control / ControlManager#
- Package:
server - Responsibility: Per-frpc-client session on the server side. Holds the control connection, drives the
msg.Dispatcherfor the control protocol, routesNewProxy/CloseProxy/Ping/ReqWorkConnmessages, and manages per-proxyserver.proxy.Handlerinstances.ControlManageris an in-memory map ofControlobjects indexed byrunID. - Key types:
Control,SessionContext,ControlManager - Dependencies:
server/proxy,pkg/msg,pkg/transport,pkg/auth,pkg/config/v1
client.Service#
- Package:
client - Responsibility: Top-level client lifecycle. Drives the reconnect loop (
loopLoginUntilSuccesswith exponential backoff), manages config hot-reload viasource.Aggregator, owns the admin web server and optional WireGuardvnet.Controller. - Key types:
Service,ServiceOptions(functional-options-style struct),ConnectorCreatorfunc field (enablesVirtualClient) - Dependencies:
client,pkg/auth,pkg/config/v1,pkg/config/source,pkg/vnet
client.Control#
- Package:
client - Responsibility: Per-connection client-side control session. Wraps a
msg.Dispatcher(over an optionally encrypted conn) and atransport.MessageTransporter(HTTP/2-like lane-keyed request-response on top of the dispatcher). Runsproxy.Managerandvisitor.Managerfor this session. - Key types:
Control,SessionContext - Dependencies:
client/proxy,client/visitor,pkg/msg,pkg/transport,pkg/auth
client.Connector#
- Package:
client - Responsibility: Abstracts the underlying physical connection — TCP/TLS/WebSocket, KCP, or QUIC — and (when
TCPMuxis enabled) produces yamux streams from a single connection. Swappable:ServiceOptions.ConnectorCreatorcan inject a pipe-basedVirtualConnectorfor in-process testing/VirtualNet. - Key types:
Connectorinterface (Open(),Connect(),Close()),defaultConnectorImpl - Dependencies:
github.com/hashicorp/yamux,github.com/quic-go/quic-go,pkg/transport
pkg/msg#
- Package:
pkg/msg - Responsibility: Defines the complete frpc↔frps wire protocol as plain Go structs with JSON tags. A single-byte type tag prefix identifies each message type;
msg.ReadMsg/msg.WriteMsgperform length-prefixed framing. Also providesmsg.Dispatcher— a goroutine-safe channel-based message dispatcher that demultiplexes incoming messages to registered handlers. - Key types:
Login,LoginResp,NewProxy,NewProxyResp,NewWorkConn,ReqWorkConn,StartWorkConn,NewVisitorConn,Ping,Pong,UDPPacket,NatHole*,Dispatcher - Dependencies: stdlib only
pkg/transport.MessageTransporter#
- Package:
pkg/transport - Responsibility: HTTP/2-like multiplexed request-response over the control connection. Callers register a “lane key” and message type, call
Do()to send a request and wait for the matching response, or callDispatch()to route an incoming response to the right waiting goroutine. Used forNewProxy/NewProxyRespand NatHole coordination, where concurrent proxy registrations may overlap on a single connection. - Key types:
MessageTransporterinterface,transporterImpl - Dependencies:
pkg/msg
server/controller.ResourceController#
- Package:
server/controller - Responsibility: Aggregates all server-side resource managers:
TCPPortManager,UDPPortManager,VisitorManager,HTTPReverseProxy,VhostHTTPSMuxer,TCPMuxHTTPConnectMuxer,NatHoleController,PluginManager, and the three group controllers (TCP, HTTP, TCPMux). Passed into everyserver.Controlas a dependency bag. - Key types:
ResourceControllerstruct - Dependencies:
server/ports,server/visitor,server/group,pkg/util/vhost,pkg/nathole,pkg/plugin/server
client/proxy.Manager + server/proxy.Manager#
- Package:
client/proxy,server/proxy - Responsibility (client side): Creates, starts, and hot-reloads proxy handler goroutines for each configured proxy. Each handler opens work connections on demand (via
Connector) and forwards data to the local service. - Responsibility (server side): Registry for active
Handlerobjects. EachHandlerlistens on the allocated remote port (or vhost route) and, when a connection arrives, instructs frpc to open a work connection viaReqWorkConn. - Key types:
Manager,WorkingStatus,Handlerinterface (server side) - Dependencies (client):
pkg/msg,pkg/transport,pkg/plugin/client,client/health,pkg/util/net
Data flow#
Typical TCP proxy (e.g., exposing an SSH server behind NAT)#
1. frpc starts → loopLoginUntilSuccess → connector.Open() → TCP connect to frps:7000
(yamux session established over the TCP conn if TCPMux=true)
2. frpc sends msg.Login over control stream
frps: handleConnection → RegisterControl → NewControl → ctl.Start()
3. frpc sends msg.NewProxy{ProxyType:"tcp", RemotePort:6000}
frps: handles NewProxy → server/proxy.Handler created → binds 0.0.0.0:6000
4. Internet user connects to frps:6000
frps proxy.Handler: sends msg.ReqWorkConn on control channel
5. frpc receives ReqWorkConn → Connector.Connect() → new TCP/yamux stream to frps:7000
frpc sends msg.NewWorkConn on the new stream
6. frps: RegisterWorkConn → routes stream to waiting proxy.Handler
frps sends msg.StartWorkConn on the work conn
7. frpc: proxy handler opens conn to local SSH :22
frpc ↔ frps: raw bidirectional copy between work conn and local conn
frps ↔ internet user: raw bidirectional copy between work conn and user connHTTP vhost proxy flow#
The flow is the same except frps routes to vhost.Routers (a per-hostname/location reverse-proxy router) instead of a bound port. The HTTP reverse proxy in pkg/util/vhost handles Host-header routing to the correct work-connection pool.
NAT hole-punching (XTCP)#
A more complex flow using pkg/nathole (STUN-based): visitor sends NatHoleVisitor to frps, frps forwards NatHoleClient to frpc, both sides use STUN reflection to discover mapped addresses, then attempt direct UDP/TCP hole-punch. If successful, traffic flows peer-to-peer without traversing frps.
Initialization / Bootstrap#
frps bootstrap sequence#
main()
└── system.EnableCompatibilityMode() # OS ulimits/RLIMIT_NOFILE
└── rootCmd.Execute() (Cobra)
└── runServer(svrCfg)
├── log.InitLogger(...)
├── server.NewService(cfg)
│ ├── transport.NewServerTLSConfig(...)
│ ├── httppkg.NewServer(...) # dashboard
│ ├── auth.BuildServerAuth(...)
│ ├── plugin.Manager + group controllers init
│ ├── net.Listen("tcp", bindAddr)
│ ├── mux.NewMux(ln) # protocol demux
│ ├── optional KCP, QUIC listeners
│ ├── ssh.NewGateway(...)
│ ├── websocket listener from mux
│ ├── vhost HTTP/HTTPS servers
│ ├── TLS listener from mux
│ └── nathole.NewController(...)
└── svr.Run(ctx)
├── go webServer.Run()
├── go HandleListener(sshTunnelListener)
├── go HandleQUICListener(quicListener)
├── go HandleListener(websocketListener)
├── go HandleListener(tlsListener)
├── go NatHoleController.CleanWorker()
├── go sshTunnelGateway.Run()
└── HandleListener(listener) # blocks; primary TCPfrpc bootstrap sequence#
main()
└── system.EnableCompatibilityMode()
└── sub.Execute() (Cobra)
└── runClient(cfgFile)
├── config.LoadClientConfig(...)
├── client.NewService(ServiceOptions{...})
│ ├── auth.BuildClientAuth(...)
│ ├── aggregator.Load() → proxyCfgs, visitorCfgs
│ ├── httppkg.NewServer(...) # admin API
│ └── vnet.NewController(...) # if VirtualNet configured
└── svr.Run(ctx)
├── go webServer.Run()
├── go vnetController.Run()
├── loopLoginUntilSuccess(10s, loginFailExit)
│ └── login() → connector.Open → connector.Connect
│ → msg.Login/LoginResp handshake
│ → NewControl(sessionCtx)
│ → ctl.Run(proxyCfgs, visitorCfgs)
└── go keepControllerWorking() # reconnect loopDependency injection style: Manual constructor wiring. All dependencies are passed as constructor arguments or via SessionContext structs. No DI framework (no wire, dig, or fx). ServiceOptions acts as a functional-options-like configuration bag that lets callers inject a custom ConnectorCreator (used by VirtualClient for in-process frp tunnels).
Configuration#
- Format: YAML, TOML, or JSON (v1 format,
pkg/config/v1). Legacy ini format is still parsed for backward compatibility (pkg/config/legacy) but deprecated with a printed warning. - Loading:
config.LoadServerConfig/config.LoadClientConfigdetect format from file extension and strict-parse to typed structs. - Flags: Cobra persistent flags bound directly to
v1.ServerConfig/v1.ClientCommonConfigstruct fields viaconfig.RegisterServerConfigFlags. Flags override file values. - Client-side hot reload:
source.Aggregatoraggregates aConfigSource(file-backed) and optionalStoreSource. The clientServicewatches for changes and callsUpdateAllConfigurer→proxy.Manager.UpdateAll, which adds/removes/updates proxy goroutines without restarting the control connection. - No Viper: frp uses its own
pkg/configloading pipeline built onmapstructure+github.com/spf13/pflag. - Environment variables: No direct env-var binding for proxy/server config. The
pkg/config/sourcepackage has aStoreSourcefor dynamic config stores (e.g., a sidecar API), not env vars.
Key design decisions#
Single multiplexed control connection (yamux/QUIC) for all signaling. Rather than opening a new TCP connection per proxy or per message, frp uses yamux to multiplex all control streams over one underlying TCP connection. This dramatically reduces connection overhead when a client has dozens of proxies, and avoids NAT mapping exhaustion. The
transport.MessageTransporterthen layers HTTP/2-style request-response lanes on top of this, enabling concurrentNewProxy/NewProxyRespexchanges on the same stream.Work connections opened on demand, not pre-allocated. The server sends
ReqWorkConnonly when a real user connection arrives. The client opens a fresh connection (or yamux stream) for each work request. This avoids idle resource consumption on the client, at the cost of slight per-connection latency. APoolCountconfig option allows pre-allocating a small pool of work connections to amortize this cost.Protocol-sniffing demultiplexer on a single port.
mux.Muxinspects the first bytes of incoming connections to route WebSocket, HTTPS/TLS, and plain frp connections through the same bind port. This means frps can serve its entire surface — control plane, HTTP vhost, HTTPS vhost, WebSocket — on a single port, which is friendly to firewalled environments.pkg/msgas an explicit, versioned protocol boundary. All frpc↔frps messages are defined in a single package as plain structs with JSON tags and single-byte type discriminants. This makes the wire protocol inspectable, testable in isolation, and decoupled from both the client and server domains. Version skew is handled at login (LoginResp.Errorif version unsupported).ConnectorCreatorinjection enables an in-process VirtualClient. By making the transport creation a first-class parameter (ServiceOptions.ConnectorCreator), frp’spkg/virtualpackage can instantiate a fully functional frpc inside the same process as frps, communicating via in-memory pipes. This is used for theVirtualNetWireGuard overlay feature and for programmatic SDK usage, without any changes to the core control/proxy logic.