Documentation
¶
Overview ¶
Package server implements the Bolt v5 TCP server for the GoGraph Cypher engine. It handles connection acceptance, Bolt protocol negotiation, session lifecycle, and authentication.
Concurrency ¶
Server is safe for concurrent use by multiple goroutines. Session and State are NOT safe for concurrent use; each connection owns exactly one Session.
Index ¶
- Constants
- Variables
- func ConstantTimeValidate(wantPrincipal, wantCredentials string) func(principal, credentials string) error
- func DefaultTLSConfig() *tls.Config
- func ExtractBookmarks(extra map[string]packstream.Value) []string
- func FailureCode(err error) string
- func NextBookmark() string
- func RoutingTable(addr string) map[string]packstream.Value
- type AuthHandler
- type BasicAuthHandler
- type CertReloader
- type Identity
- type NoAuthHandler
- type Options
- type Server
- type Session
- type State
- type Tx
Examples ¶
Constants ¶
const ( // DefaultMaxInFlightPerConnection is the default value applied to // Options.MaxInFlightPerConnection when the caller leaves it at // zero. The count tracks all Result cursors appended to the // in-progress explicit transaction since BEGIN (both open and // already-drained), so it bounds the total number of RUN statements // a client may issue without committing. The default of 1024 allows // any legitimate workload while still bounding pathological // RUN-loop attacks that grow tx.results without bound. Operators // that need a stricter limit may lower this value explicitly. DefaultMaxInFlightPerConnection = 1024 // DefaultConnTimeout is the default value applied to Options.ConnTimeout // when the caller leaves it at zero. It is the per-message idle deadline // applied throughout the post-handshake message loop: the server resets it // before each read, so it bounds the time a connection may sit silent // between messages, not the total session duration. A non-zero default is // mandatory: with no deadline a client that completes the handshake but then // stops sending bytes would hold its connection slot and goroutine forever, // a Slowloris-style denial of service. The default of 30 s is generous // enough not to disturb a legitimate interactive session pausing between // queries while still reclaiming abandoned connections. Operators may set a // larger value for long-lived idle sessions or a smaller one to reclaim // connections more aggressively. DefaultConnTimeout = 30 * time.Second // DefaultHandshakeTimeout is the deadline that bounds the unauthenticated // version-negotiation handshake — the cheapest phase for an attacker to // abuse, since it requires no valid protocol bytes (a client may open a // socket, send a single byte, and otherwise stall). The deadline is applied // to the connection before [proto.Negotiate] and cleared on success so it // never bleeds into normal operation. It is deliberately shorter than // DefaultConnTimeout: a legitimate client sends its 20-byte handshake // immediately, so 10 s is ample, while a stalled handshake is reclaimed // promptly. The handshake bound is fixed (not configurable via Options) to // keep the Options struct small; the package var handshakeTimeout is seeded // from this const and overridable only by tests. DefaultHandshakeTimeout = 10 * time.Second )
Variables ¶
var ( // ErrAuthFailed is returned when credentials are invalid. ErrAuthFailed = errors.New("bolt: authentication failed") // ErrSchemeUnknown is returned when the auth scheme is not supported. ErrSchemeUnknown = errors.New("bolt: unknown auth scheme") )
Common auth errors.
var ErrInvalidTransition = errors.New("bolt: invalid state transition")
ErrInvalidTransition is returned by Transition when the given message type is not permitted in the current state.
var ErrNoAuthHandler = errors.New("bolt: no auth handler configured; set Options.Auth to a real AuthHandler, or to NoAuthHandler{} to run without authentication")
ErrNoAuthHandler is returned by NewServer when Options.Auth is nil. The server is secure-by-default: running without authentication must be an explicit opt-in, never an accidental default. Set Options.Auth to a real AuthHandler to require credentials, or set Options.Auth to a NoAuthHandler{} value to run the open-door handler on purpose (development and testing only).
Functions ¶
func ConstantTimeValidate ¶
func ConstantTimeValidate(wantPrincipal, wantCredentials string) func(principal, credentials string) error
ConstantTimeValidate returns a Validate function that accepts only the given principal and credentials, using crypto/subtle.ConstantTimeCompare for both comparisons. The comparison time is independent of the values being compared, eliminating timing side-channels.
Example:
handler := server.BasicAuthHandler{
Validate: server.ConstantTimeValidate("alice", "correct-horse-battery-staple"),
}
func DefaultTLSConfig ¶
DefaultTLSConfig returns a hardened baseline tls.Config that operators should use as the STARTING POINT for the server's transport security.
The configuration sets a TLS 1.2 floor and a modern, AEAD-only cipher list for the TLS 1.2 handshake; TLS 1.3 is negotiated automatically when both peers support it (TLS 1.3 cipher suites are fixed by the Go runtime and are always safe, so they are not — and cannot be — listed here). No MaxVersion is set, so a 1.3-capable client always upgrades to 1.3.
The returned config is INCOMPLETE on its own: it carries no certificate. Callers MUST populate it with their own server identity before use, by setting one of:
- tls.Config.Certificates, or
- tls.Config.GetCertificate (for example by wiring the on-disk hot reloader: cfg.GetCertificate = reloader.GetCertificate; see CertReloader).
Then pass the result in [Options.TLSConfig].
The server does NOT impose this baseline automatically. It wraps whatever [Options.TLSConfig] the operator supplies verbatim: passing a nil TLSConfig keeps the existing behaviour of running PLAINTEXT TCP (no TLS at all). DefaultTLSConfig only provides and documents a safe default; it never overrides an operator-supplied config, so embedders are never surprised.
A fresh, independent config is returned on every call (no shared mutable global), so callers may freely mutate the result — adding Certificates, GetCertificate, client-auth policy, etc. — without aliasing another caller's configuration.
func ExtractBookmarks ¶
func ExtractBookmarks(extra map[string]packstream.Value) []string
ExtractBookmarks returns the bookmark list from RUN/BEGIN extra metadata. It reads the "bookmarks" key, which may be a []packstream.Value of strings. Returns nil (not an error) when the key is absent or the value is not a list.
func FailureCode ¶
FailureCode returns the Neo4j-style dot-delimited error code for err. Falls back to "Neo.DatabaseError.General.UnknownError" for unrecognised errors. The lookup uses errors.As and errors.Is so wrapped errors are matched correctly.
func NextBookmark ¶
func NextBookmark() string
NextBookmark generates a new bookmark string for a committed transaction. The format is "FB:kXXXXXX" where XXXXXX is a monotonically increasing counter expressed as a zero-padded 8-digit hexadecimal value.
NextBookmark is safe for concurrent use.
func RoutingTable ¶
func RoutingTable(addr string) map[string]packstream.Value
RoutingTable returns the single-host routing table for the server at addr. The TTL is hardcoded to 300 seconds. All three roles (WRITE, READ, ROUTE) point to the same single-host address.
The returned map matches the Bolt v5 routing table format expected inside a SUCCESS metadata "rt" key.
Types ¶
type AuthHandler ¶
type AuthHandler interface {
// Authenticate validates the auth scheme, principal, and credentials.
// On success it returns an Identity; on failure it returns a non-nil error.
// Returning ErrAuthFailed causes the server to send a Failure with code
// "Neo.ClientError.Security.Unauthorized". Returning ErrSchemeUnknown
// causes a Failure with code "Neo.ClientError.Security.AuthProviderFailed".
Authenticate(scheme, principal, credentials string) (Identity, error)
}
AuthHandler is the pluggable authentication interface. Implementations must be safe for concurrent use.
type BasicAuthHandler ¶
type BasicAuthHandler struct {
// Validate is called with the principal and credentials from the client.
// It must return nil on success and a non-nil error on failure.
// See the type-level documentation for timing side-channel guidance.
Validate func(principal, credentials string) error
}
BasicAuthHandler validates credentials by delegating to a caller-supplied Validate function. The Validate function must return nil on success and a non-nil error (typically ErrAuthFailed) on failure.
Timing side-channels ¶
Validate is called with the raw credential string from the client. If the implementation compares credentials with == or strings.Equal, an attacker can infer the correct value by measuring response latency (timing side-channel). Always use ConstantTimeValidate or crypto/subtle.ConstantTimeCompare for credential comparison:
handler := BasicAuthHandler{
Validate: ConstantTimeValidate("alice", "correct-horse-battery-staple"),
}
Do not add rate-limiting or account-lockout logic inside Validate; place it in a middleware wrapping the AuthHandler instead so the Bolt server remains stateless per-connection.
BasicAuthHandler is safe for concurrent use as long as Validate is.
func (BasicAuthHandler) Authenticate ¶
func (h BasicAuthHandler) Authenticate(scheme, principal, credentials string) (Identity, error)
Authenticate implements AuthHandler. It accepts only the "basic" scheme; any other scheme returns ErrSchemeUnknown. It calls h.Validate with the principal and credentials; if Validate returns a non-nil error, Authenticate returns ErrAuthFailed.
type CertReloader ¶
type CertReloader struct {
// contains filtered or unexported fields
}
CertReloader watches a (certificate, key) PEM file pair on disk and serves the most recent successfully loaded pair via the CertReloader.GetCertificate hook installable on tls.Config.GetCertificate.
The intent is operational: rotate the server's TLS material (e.g. cert-manager / Let's Encrypt) without restarting the Bolt server. The previous certificate stays in service until the new pair is fully validated and only then is the swap performed atomically via sync/atomic.Pointer. A reload that fails to parse leaves the live certificate untouched and surfaces the error via the provided OnError callback (or via stderr when nil).
CertReloader is safe for concurrent use; the hot path is a single atomic.Pointer.Load.
func NewCertReloader ¶
func NewCertReloader(certPath, keyPath string, onError func(error)) (*CertReloader, error)
NewCertReloader loads the certificate + key from disk and returns a CertReloader holding the result. The initial load is mandatory: if the files cannot be read or parsed, NewCertReloader returns the error and the caller MUST fail fast (do not start the server with a broken TLS config).
onError is invoked when a later reload (triggered by Reload or by the optional Watch goroutine) fails to parse the new pair. A nil onError defaults to printing to stderr via fmt.Fprintln.
func (*CertReloader) GetCertificate ¶
func (r *CertReloader) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error)
GetCertificate is the hook to install on tls.Config.GetCertificate. It returns the most recently loaded certificate. The signature matches the standard library's expectation so callers can do:
cfg := &tls.Config{GetCertificate: reloader.GetCertificate}
The returned *tls.Certificate is shared across all concurrent handshakes; callers must NOT mutate the returned value.
func (*CertReloader) Reload ¶
func (r *CertReloader) Reload() error
Reload re-reads the certificate + key from disk and atomically swaps the live certificate when the parse succeeds. A parse failure leaves the live certificate untouched and returns the error so the caller (or the OnError callback installed via NewCertReloader) can record the incident.
func (*CertReloader) Watch ¶
func (r *CertReloader) Watch(interval time.Duration, stop <-chan struct{})
Watch starts a background goroutine that polls the certificate and key files every interval and calls Reload when either has a fresh mtime. The goroutine exits when stop is closed. Watch returns immediately; pair it with sync.WaitGroup if the caller wants to block on shutdown.
Common usage:
stop := make(chan struct{})
go reloader.Watch(30*time.Second, stop)
defer close(stop)
Errors from Reload are surfaced via the onError callback installed at construction time; Watch itself never returns an error.
type Identity ¶
type Identity struct {
// Principal is the authenticated username or identifier.
Principal string
}
Identity carries the authenticated principal's metadata after a successful authentication exchange.
type NoAuthHandler ¶
type NoAuthHandler struct{}
NoAuthHandler accepts any credentials without validation. Suitable for development and testing only.
Because it admits every client, NoAuthHandler is never installed by default: the server is secure-by-default. To run without authentication an embedder must opt in explicitly by setting Options.Auth to a NoAuthHandler{} value. The explicit value is itself the opt-in — self-documenting at the call site and impossible to set by accident — and NewServer logs a loud warning when it sees one. Constructing a server with a nil Options.Auth fails closed with ErrNoAuthHandler. Never expose a NoAuthHandler-backed server on an untrusted network.
NoAuthHandler is safe for concurrent use.
func (NoAuthHandler) Authenticate ¶
func (NoAuthHandler) Authenticate(_, principal, _ string) (Identity, error)
Authenticate implements AuthHandler. It always returns an Identity with the given principal and a nil error.
type Options ¶
type Options struct {
// MaxConnections is the upper bound on concurrent accepted connections.
// Zero or negative values default to 1024.
MaxConnections int
// MaxMessageBytes caps the cumulative payload size of a single Bolt
// message reassembled from per-chunk fragments. Zero or negative
// values default to [proto.DefaultMaxMessageBytes] (16 MiB).
// Bolt's wire format limits each chunk to 65535 bytes but the
// chunk count is unbounded; this cap closes the Slowloris-style
// DoS vector in which a malicious client streams non-zero chunks
// indefinitely until the server OOMs.
MaxMessageBytes int
// MaxInFlightPerConnection caps the total number of RUN statements
// that may be issued within a single explicit transaction before
// COMMIT or ROLLBACK. Zero or negative values default to
// [DefaultMaxInFlightPerConnection] (1024). The count includes both
// open (not yet fully PULL'd) and already-drained cursors
// accumulated in tx.results since BEGIN; auto-commit cursors are
// not counted (the Bolt v5 state machine already prevents two
// concurrent auto-commit streams). The cap surfaces as a typed
// Bolt FAILURE with code "Neo.ClientError.General.LimitExceeded".
MaxInFlightPerConnection int
// ConnTimeout is the per-connection idle read deadline applied throughout
// the post-handshake message loop. Each time the server is about to read
// the next message, the deadline is reset to now+ConnTimeout, so it bounds
// the silent gap between messages rather than the total session duration.
// Zero or negative values default to [DefaultConnTimeout] (30 s); a
// non-zero deadline is always applied so an idle connection cannot hold its
// slot and goroutine forever. Set a larger value for long-lived idle
// sessions. The unauthenticated handshake phase is bounded separately and
// is not configurable here; see [DefaultHandshakeTimeout].
ConnTimeout time.Duration
// MaxStatementTimeout is the server-side upper bound on per-statement
// execution time. When a client supplies a timeout via the RUN or BEGIN
// extra metadata, it is silently clamped to MaxStatementTimeout. When
// a client supplies no timeout and MaxStatementTimeout is positive, the
// server applies MaxStatementTimeout unconditionally. Zero means no
// server-side cap (client controls its own timeout).
MaxStatementTimeout time.Duration
// TLSConfig, when non-nil, wraps accepted connections with TLS using
// the given configuration verbatim. nil means plain TCP (no TLS).
//
// The server applies no MinVersion or cipher policy of its own: whatever
// config is supplied here is used as-is. To start from a hardened baseline
// (TLS 1.2 floor, modern AEAD/ECDHE cipher list), begin with
// [DefaultTLSConfig] and add your own Certificates or GetCertificate before
// assigning it here.
TLSConfig *tls.Config
// Auth is the authentication handler invoked during HELLO/LOGON. It is
// the security boundary of the server: every client must satisfy it
// before any Cypher statement executes.
//
// Auth must be set; leave it nil and [NewServer] returns
// [ErrNoAuthHandler]. The server is secure-by-default: a nil Auth is NOT
// silently replaced with an open, accept-everyone handler, so a careless
// embedder writing Options{} cannot accidentally expose an
// unauthenticated server. To enforce credentials, set Auth to a real
// [AuthHandler] such as [BasicAuthHandler]. To run without authentication
// (development or testing only) set Auth: [NoAuthHandler]{} explicitly:
// the explicit NoAuthHandler value is itself the opt-in, it is
// self-documenting at the call site, and it is impossible to set by
// accident. In that case [NewServer] still emits a loud warning.
Auth AuthHandler
// Logger is the structured logger for server events. When nil, the
// default slog handler is used.
Logger *slog.Logger
}
Options configures a Server.
type Server ¶
type Server struct {
// contains filtered or unexported fields
}
Server is the Bolt v5 TCP server. It accepts connections from a net.Listener, negotiates the protocol version, and runs the Bolt message loop on each connection.
Server is safe for concurrent use by multiple goroutines.
func NewServer ¶
NewServer creates a Server backed by eng. Zero-value Options fields are filled with sensible defaults.
NewServer is secure-by-default: it never silently installs an accept-everyone authentication handler. If Options.Auth is nil it fails closed and returns ErrNoAuthHandler so that an unauthenticated server is never started by accident. To run without authentication on purpose (development or testing), set Options.Auth to a NoAuthHandler{} value explicitly: NewServer then admits every client and logs a loud warning that the operator has knowingly disabled authentication. The explicit NoAuthHandler value is itself the opt-in — self-documenting at the call site and impossible to set by accident. When Options.Auth is any other (real) handler it is used as-is.
func (*Server) ListenAndServe ¶
ListenAndServe creates a TCP listener on addr and calls Serve. It blocks until the server stops. The listener is closed when Serve returns.
func (*Server) Serve ¶
Serve accepts connections from ln until ctx is cancelled or Shutdown is called. It blocks until all active connections have closed. The provided ln is closed by Serve when the accept loop exits.
Example ¶
ExampleServer_Serve starts a Bolt server backed by an in-memory graph, connects a Bolt client, and runs a query over the session. The listener binds to 127.0.0.1:0 so the OS assigns a free port. Teardown closes the client first, then cancels Serve and waits for it to drain every connection goroutine — leaving no leaked goroutine behind.
The network round-trip is non-deterministic in timing, so the example asserts the deterministic query result rather than any wire-level output.
package main
import (
"context"
"fmt"
"net"
"time"
"github.com/FlavioCFOliveira/GoGraph/bolt/server"
"github.com/FlavioCFOliveira/GoGraph/cypher"
"github.com/FlavioCFOliveira/GoGraph/graph/adjlist"
"github.com/FlavioCFOliveira/GoGraph/graph/lpg"
"github.com/neo4j/neo4j-go-driver/v5/neo4j"
)
func main() {
// Engine over an empty in-memory labelled property graph.
g := lpg.New[string, float64](adjlist.Config{})
eng := cypher.NewEngine(g)
// The explicit NoAuthHandler{} value is the opt-in that lets this example
// run without credentials; the server is secure-by-default and otherwise
// refuses to start with a nil Auth handler.
srv, err := server.NewServer(eng, server.Options{ConnTimeout: 5 * time.Second, Auth: server.NoAuthHandler{}})
if err != nil {
fmt.Println("new server:", err)
return
}
// Ephemeral port; ln.Addr() reveals the chosen port for the client.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Println("listen:", err)
return
}
addr := ln.Addr().String()
ctx, cancel := context.WithCancel(context.Background())
serveErr := make(chan error, 1)
go func() { serveErr <- srv.Serve(ctx, ln) }()
// Connect a Bolt client and run a trivial read query.
driver, err := neo4j.NewDriverWithContext("bolt://"+addr, neo4j.NoAuth())
if err != nil {
fmt.Println("driver:", err)
cancel()
<-serveErr
return
}
sess := driver.NewSession(ctx, neo4j.SessionConfig{})
result, err := sess.Run(ctx, "RETURN 1 AS n", nil)
if err != nil {
fmt.Println("run:", err)
} else if rec, err := result.Single(ctx); err != nil {
fmt.Println("single:", err)
} else {
n, _ := rec.Get("n")
fmt.Println("n =", n)
}
_ = sess.Close(ctx)
// Clean shutdown: close the client so server-side connection goroutines
// observe EOF, then cancel Serve and wait for it to return. Serve only
// returns after every connection goroutine has finished.
_ = driver.Close(ctx)
cancel()
<-serveErr
}
Output: n = 1
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session holds all per-connection state for a single Bolt v5 client connection.
Session is NOT safe for concurrent use. Each accepted TCP connection owns exactly one Session, and the message loop is single-threaded per connection.
func (*Session) HandleMessage ¶
HandleMessage dispatches msg to the correct per-state handler and returns the response messages to send to the client.
On an illegal state transition or internal error the session moves to FAILED and HandleMessage returns a single *proto.Failure response. The caller is responsible for encoding and sending all returned messages.
type State ¶
type State uint8
State represents the Bolt v5 per-connection protocol state machine state.
const ( // StateConnected is the initial state: TCP connection established, no // protocol negotiation has occurred yet. StateConnected State = iota // StateNegotiation is reached after version negotiation; the server awaits // the client's HELLO message. StateNegotiation // StateReady is the idle state after a successful HELLO or after a result // set has been fully consumed, committed, or rolled back. StateReady // StateStreaming is active when a query has been run (auto-commit) and // records are available to pull. StateStreaming // StateTxReady is reached after BEGIN; the server awaits RUN, COMMIT, or // ROLLBACK within an explicit transaction. StateTxReady // StateTxStreaming is active when a query has been run inside an explicit // transaction and records are available to pull. StateTxStreaming // StateFailed is entered when a request fails; the server ignores further // requests until RESET is received. StateFailed // StateDefunct is the terminal state: the connection is closed and no // further messages are processed. StateDefunct )
func StreamingTransition ¶
StreamingTransition is a variant of Transition for PULL in STREAMING or TX_STREAMING states when there are more records to deliver (has_more=true). In that case the connection remains in the same streaming state instead of returning to READY/TX_READY.
func Transition ¶
Transition computes the next state given the current state, the incoming message, and whether the operation succeeded.
msg must be one of the pointer types from the proto package (e.g. *proto.Run, *proto.Pull, etc.). success indicates whether the server-side operation succeeded; on failure the next state is StateFailed (unless the transition itself is illegal).
Returns (StateFailed, ErrInvalidTransition) for illegal state/message combinations.
type Tx ¶
type Tx struct {
// contains filtered or unexported fields
}
Tx wraps a cypher.Engine transaction context for an explicit Bolt transaction opened by a BEGIN message.
Tx is NOT safe for concurrent use; it is owned by a single Session whose message loop is single-threaded per connection.
func (*Tx) Commit ¶
Commit completes the transaction by closing the last open result cursor (so that index changes are applied) and cancelling the transaction context.
For the in-memory engine, "commit" means draining the last result cursor to let any buffered writes flush. Earlier results have already been drained by the PULL/DISCARD handlers.