Practices
Rules for working in this codebase. Each has the rule, why it matters for Apranik specifically, a real example from the repository, and a counter-example. Counter-examples are illustrative unless they name a file.
Layering and coupling
A module imports only spec, config/types and shared/
Why. The project's claim is that a new technique is one folder. A module that imports core/ can no longer be reviewed, tested or moved on its own, and every change to core becomes a change to it.
Good: module/transport/base/tcp/transport.go imports config/types and spec, and nothing else from the project. module/proxy/trojan also imports shared/socksaddr, the address codec it shares with SOCKS5, never module/proxy/socks5.
Bad (illustrative):
import "github.com/we-apranik/apranik/core/logging" // a module reaching into coreIf a module needs something core has, the question is whether it belongs in spec. Raise it rather than import around it.
Name each module once, in the registry
Why. Registration that happens in init() is invisible in the call graph and forces one global registry per process, which Go programs using Apranik cannot control.
Good: core/registry/default.go:
moduleRegistry.Transports().RegisterBaseStream(tcp.NewBaseStreamTransportModule),Bad:
func init() { registry.Global.Register("tcp", New) } // global state, hidden orderingProxy protocols never dial
Why. If a protocol opens its own connection, it can no longer run over TLS, WebSocket or a DNS tunnel, and the stack model stops working.
Good: streamOutbound.DialStream in core/outbound/proxy.go dials the server through the transport stack, then hands the stream to proxyClient.RequestStream.
Bad:
func (c *client) RequestStream(ctx context.Context, _ spec.Stream, m spec.Metadata) (spec.Stream, error) {
conn, err := net.Dial("tcp", serverAddress) // bypasses the stack
...
}Connection ownership and lifetimes
Whoever ends the work closes the stream
Why. A leaked socket in a long-running core becomes file-descriptor exhaustion on a phone. Ownership has to be obvious from the code.
Good: relay.Stream in core/relay/stream.go defers Close on both streams and closes both when the context is cancelled, because a blocked Read has no other way out.
Good: when Session.openOrReuseOutboundDatagram loses a race to create an outbound datagram, it closes the one it made before returning the winner's.
Good: overlayStreamDialer.DialStream in core/builder/builder.go closes the stream it dialed when an overlay's handshake fails, because an overlay leaves the stream below open on error by contract.
Bad: returning an error from a function that opened a stream without closing it:
serverStream, err := dialer.DialStream(ctx, server)
payload, err := client.RequestStream(ctx, serverStream, metadata)
if err != nil {
return nil, err // serverStream leaks
}(streamOutbound.DialStream closes serverStream on this path.)
Silence is the only end of a UDP flow
Why. UDP has no close. Without timeouts, every client that ever sent one packet keeps a socket open forever.
Good: core/net/udp/session ends a session after its idle timeout, and relay.Stream closes a TCP connection silent in both directions. Activity only stores a timestamp; the timer checks it, so senders never race the timer.
Bad: two timers on different layers bounding the same resource. The session once released a freedom datagram after 1 minute, which silently overrode the NAT's configurable 5-minute socket timeout. Each resource gets one idle bound, configured beside it.
Error handling
- Wrap with
%wand say which component failed:fmt.Errorf("outbound %q: %w", o.tag, err). - Use the sentinels in
spec(ErrNoHalfClose,ErrMessageTooLong,ErrUnsupportedNetwork,ErrSniffNeedMoreData) instead of inventing parallel ones, so callers can useerrors.Isacross module boundaries. - In a per-packet loop, one bad packet must not end the session.
relay.Datagramsdrops a packet it cannot route or send and logs it at Debug.
Bad:
var errTooBig = errors.New("mymodule: too big") // callers can't match spec.ErrMessageTooLongConcurrency
- Do slow work outside locks.
openOrReuseOutboundDatagramcallsCreateDatagramwith the lock released, then checks again, so a slow outbound does not block sends to others. - Close a
donechannel under the lock that guards the state it protects, so "closed" and "done" can never disagree (session.Session.endWithReason). - Every test runs under
-race. A test that only passes without it is a bug.
Testing
- Module tests never import core.
module/sniffer/tls/sniffer_test.gotests against real ClientHellos fromcrypto/tls, with no core at all. - Prefer real bytes and real sockets on
127.0.0.1over mocks of the thing being tested.core/route/handler/handler_test.gochecks that two UDP sessions to one destination do not cross, using a real UDP echo server. - Test what a user would notice: a startup error with the right location (
config/config_test.gochecks the path in each diagnostic), not internal call counts.
Configuration
- Refuse at startup, not on the first packet.
Validaterefuses impossible values;config.Checkrefuses impossible combinations; listeners bind at build time. - Defaults live in the constructor, and JSON overrides them.
- Never default a map field:
encoding/jsonmerges into an existing map, so a user cannot remove the default. - Name the location: every diagnostic carries a path such as
routing.rules[0].action.settings.sniffers[1].
Security-sensitive parsing
- Bound every length before allocating.
module/proxy/http/wirecaps a request head at 64 KiB and 128 headers;core/sniffpeeks at most 16 KiB. - Decide early on garbage. The HTTP sniffer refuses a stream from its first bytes when they cannot be a method, so a non-HTTP client is not held waiting.
- Compare credentials in constant time (
crypto/subtleinmodule/proxy/socks5/server.goandmodule/proxy/http/wire/wire.go). - Keep secrets out of text. Use
types.Secretfor passwords and keys;%vand%qprint it redacted.
Bad:
size := binary.BigEndian.Uint32(header)
buffer := make([]byte, size) // attacker chooses a 4 GiB allocationNaming and comments
- Long, self-describing names instead of comments:
udpSessionRoutingCache,forwardClientDatagramsToSession. - Keep a comment only for rationale the code cannot show, such as why an alternative was rejected, an RFC constraint or a security consequence.
- A function that only creates a local socket is
CreateDatagram, neverDial*.