control

package
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 8, 2026 License: LGPL-2.1, LGPL-2.1-or-later Imports: 7 Imported by: 0

Documentation

Overview

Package control reads and classifies the plaintext messages the server sends down the OpenVPN control channel once TLS is up: PUSH_REPLY, AUTH_FAILED in its plain, temporary and challenge forms, and the RESTART and HALT pushes that end a session.

Every one of these is stock OpenVPN, present in every connection whatever its authentication. What a challenge body means is the authentication method's business — auth/saml reads a CRV1 body as a state id and an IdP URL — so that stays with the method, which imports this package rather than the other way round.

Reference: openvpn-2.6.22 src/openvpn/push.c (receive_auth_failed, server_pushed_signal, process_incoming_push_reply).

Index

Constants

This section is empty.

Variables

View Source
var ErrAuthTemp = errors.New("control: server rejected this attempt temporarily")

ErrAuthTemp marks a rejection the server said was temporary. It is not a statement about credentials, and a caller that retries on it is doing what the reference does: openvpn-2.6.22 src/openvpn/push.c:75 raises SIGUSR1 — a soft restart — where a plain AUTH_FAILED raises SIGTERM.

View Source
var ErrServerPushedSignal = errors.New("control: server ended the session")

ErrServerPushedSignal marks a session the server ended on purpose, by pushing RESTART or HALT down the control channel of a tunnel that was working.

It is not a failure of ours and not a network fault, which is what it used to be recorded as: the message hit the monitor's default arm, was logged and dropped, and the session then sat there until the dead-link timer fired tens of seconds later and blamed the link.

Reference: openvpn-2.6.22 src/openvpn/push.c:131-186 (server_pushed_signal), which raises SIGUSR1 for RESTART — a reconnect — and SIGTERM for HALT.

Functions

This section is empty.

Types

type AuthTemp

type AuthTemp struct {
	// Backoff is how long the server asked us to wait. Zero when unstated.
	Backoff time.Duration
	// Advance says which remote to try next.
	Advance AuthTempAdvance
	// Reason is the server's human-readable text, or empty. It is server-
	// supplied and must be treated as untrusted when displayed.
	Reason string
}

AuthTemp is a server's temporary rejection: it declined this attempt and said to come back, optionally saying when and where. It is emphatically not a credential rejection — a server under load emits it, and a client that treats it as permanent records a working account as a failing one.

Wire form, from openvpn-2.6.22 src/openvpn/push.c:64-76 and options_util.c:34-95:

AUTH_FAILED,TEMP[backoff 30,advance no]:human readable reason

The bracketed flag list and the reason are both optional.

func ParseAuthTemp

func ParseAuthTemp(body string) AuthTemp

ParseAuthTemp parses the body of an "AUTH_FAILED,TEMP..." message, that is, everything after the "TEMP" token. An unparseable flag is skipped rather than failing the whole message, which is what the reference does (options_util.c:81, a warning and carry on): a temporary rejection nobody can parse is still a temporary rejection, and treating it as permanent is the worse error.

type AuthTempAdvance

type AuthTempAdvance int

AuthTempAdvance says which remote a temporary rejection wants tried next.

Reference: openvpn-2.6.22 src/openvpn/options_util.c:59-79, which sets no_advance, advance_next_remote or neither from the "advance" flag.

const (
	// AdvanceAddr is the default: move on to the next address.
	AdvanceAddr AuthTempAdvance = iota
	// AdvanceNo retries the same remote.
	AdvanceNo
	// AdvanceRemote moves on to the next remote entry.
	AdvanceRemote
)

func (AuthTempAdvance) String

func (a AuthTempAdvance) String() string

String returns a stable lowercase token.

type Message

type Message struct {
	// Kind is the classified message type.
	Kind MsgKind
	// Raw is the original message text with the trailing NUL stripped. It can
	// contain authentication challenge material; never include it in logs or
	// generic errors.
	Raw string
}

Message holds a classified control-channel message.

func ParseControlMsg

func ParseControlMsg(msg string) *Message

ParseControlMsg classifies a raw control-channel message. msg may include a trailing NUL byte.

It returns no error because it has none to return: ClassifyMsg has a default arm, so every byte string is a Message and an unrecognised one is MsgKindUnknown rather than a failure.

A CRV1 challenge is classified, not parsed: its body belongs to the authentication method that asked for it (auth/saml.ParseCRV1 for the SAML flow), which reads it from Raw.

func ReadControlMsg

func ReadControlMsg(r io.Reader, maxBytes int) (*Message, error)

ReadControlMsg reads one NUL-terminated control message from r (typically a *tls.Conn) and returns the classified Message.

It consumes the message and not one byte beyond its terminator, which is the whole point. Taking "whatever one Read returned" as one message is wrong in both directions: two messages written together fold into one, the second appended to Raw with its classification lost; a message split across two reads classifies from a prefix, "AUTH_FAI" as MsgKindUnknown. Leaving the bytes after the terminator in r is what lets callers reading several messages off one connection — joinPushContinuation, SessionMonitor — see each of them.

The cost is one Read per byte, because a plain io.Reader offers no way to find a terminator without reading past it. On a *tls.Conn those are copies out of a record already in memory, and no data-channel traffic comes here.

At most maxBytes bytes are read; pass 0 for the default (65536). Reaching that bound with no terminator is an error rather than a truncated Message, which would classify from a prefix. A stream that *ends* before the terminator is not an error — a server that says AUTH_FAILED and closes has told us why the session ended.

func ReadServerReply

func ReadServerReply(r io.Reader) (*Message, error)

ReadServerReply reads the server's answer to PUSH_REQUEST from r (typically the TLS session over the control channel) and classifies it.

A PUSH_REPLY comes back with no error. So does a CRV1 challenge: it is not a rejection but a request for a second exchange, and its body is for the authentication method that understands it (auth/saml.ParseCRV1 for the SAML flow) to read out of Raw. A plain AUTH_FAILED is returned with an error, and so is an AUTH_FAILED,TEMP, whose error wraps ErrAuthTemp so that a caller can tell a server that said "come back later" from one that rejected the credentials. Anything else is returned unclassified for the caller to reject.

The message is returned alongside the error in the rejection cases so that the caller can classify the failure by Kind.

func (*Message) AuthTemp

func (m *Message) AuthTemp() AuthTemp

AuthTemp returns the temporary-rejection detail carried by an "AUTH_FAILED,TEMP..." message, and the zero AuthTemp for any other kind. It exists so a caller holding the Message need not know that the flags live after the "AUTH_FAILED,TEMP" prefix; that string surgery is this package's business, not its callers'.

type MsgKind

type MsgKind int

MsgKind classifies a control-channel plaintext message received from the VPN server after TLS negotiation.

const (
	// MsgKindUnknown is returned for messages that do not match any known pattern.
	MsgKindUnknown MsgKind = iota
	// MsgKindPushReply is a successful "PUSH_REPLY,..." tunnel configuration message.
	MsgKindPushReply
	// MsgKindAuthFailedCRV1 is an "AUTH_FAILED,CRV1:..." dynamic challenge:
	// the server wants a second exchange, with a response built from the
	// challenge body. The shape is OpenVPN's own (doc/management-notes.txt,
	// "CRV1"); what the body means belongs to the authentication method.
	MsgKindAuthFailedCRV1
	// MsgKindAuthFailed is a plain "AUTH_FAILED" rejection with no challenge.
	MsgKindAuthFailed
	// MsgKindAuthFailedTemp is an "AUTH_FAILED,TEMP..." temporary rejection.
	// The server declined this attempt and asked us to come back; it is not a
	// statement about the credentials. Reference: openvpn-2.6.22
	// src/openvpn/push.c:72-76, which raises SIGUSR1 for this and SIGTERM for
	// a plain AUTH_FAILED.
	MsgKindAuthFailedTemp
	// MsgKindRestart is a "RESTART[,...]" push: the server is ending this
	// session and expects us back.
	MsgKindRestart
	// MsgKindHalt is a "HALT[,...]" push: the server is ending this session
	// and does not.
	MsgKindHalt
)

func ClassifyMsg

func ClassifyMsg(msg string) MsgKind

ClassifyMsg classifies the raw control-channel message msg. Trailing NUL bytes (OpenVPN's message terminator) are stripped before matching.

func (MsgKind) String

func (k MsgKind) String() string

String returns a non-sensitive description of the control-message kind.

type ServerPushedSignal

type ServerPushedSignal struct {
	// Restart distinguishes RESTART (the server wants us back) from HALT
	// (it does not).
	Restart bool
	// PreserveCreds is the "[P]" flag: keep the cached credentials rather
	// than purging them, so a reconnect need not re-authenticate. The
	// reference purges unless it is present (push.c:160-166).
	PreserveCreds bool
	// NextServer is the "[N]" flag, which asks that the reconnect go to the
	// next remote rather than this one (push.c:154-158).
	NextServer bool
	// Reason is the free text the server appended after the flags. It is
	// peer-authored and goes in the report, never in a credential path.
	Reason string
}

ServerPushedSignal is a parsed RESTART or HALT push.

This client records it and ends the session; it does not reconnect on the client's behalf, so Restart and NextServer are what a caller reads to decide that for itself.

func ParsePushedSignal

func ParsePushedSignal(msg string) (*ServerPushedSignal, bool)

ParsePushedSignal parses a RESTART or HALT control message.

The shape is the directive, then an optional comma and a message which may itself begin with a bracketed flag word — "RESTART,[P]session expired". The reference reads the flags one character at a time and ignores any it does not know (push.c:148-166), so an unrecognised letter is not an error here either; a server is free to add one and we still end the session.

func (*ServerPushedSignal) Directive

func (s *ServerPushedSignal) Directive() string

Directive is the message the server sent, "RESTART" or "HALT". It never includes the flags or the reason, so it is safe to put in a report.

func (*ServerPushedSignal) Error

func (s *ServerPushedSignal) Error() string

Error implements error so a ServerPushedSignal can travel as one.

func (*ServerPushedSignal) Is

func (s *ServerPushedSignal) Is(target error) bool

Is makes errors.Is(err, ErrServerPushedSignal) true for any pushed signal.

type SessionExpiredError

type SessionExpiredError struct {
	// Msg is the non-sensitive classification of the AUTH_FAILED message.
	Msg string
}

SessionExpiredError is returned when the VPN server sends AUTH_FAILED mid-session (i.e. after PUSH_REPLY was already received), indicating that the session has expired and must be re-authenticated.

func (*SessionExpiredError) Error

func (e *SessionExpiredError) Error() string

Error implements the error interface.

type SessionMonitor

type SessionMonitor struct {
	// contains filtered or unexported fields
}

SessionMonitor watches an active VPN control channel for server-side AUTH_FAILED messages that indicate session expiry.

After PUSH_REPLY and with the tunnel up, the server may send AUTH_FAILED at any time to signal that the session has expired and must be re-authenticated. SessionMonitor reads control messages from an io.Reader in a goroutine and delivers a *SessionExpiredError, or any other read error, via Done.

Usage:

mon := control.NewSessionMonitor(tlsConn)
mon.Start(ctx)
// ... use VPN tunnel ...
select {
case err := <-mon.Done():
    if se, ok := err.(*control.SessionExpiredError); ok {
        // re-authenticate
    }
}

func NewSessionMonitor

func NewSessionMonitor(r io.Reader) *SessionMonitor

NewSessionMonitor creates a SessionMonitor that reads from r, typically the TLS connection to the VPN server, read after PUSH_REPLY.

A reader that carries SetReadDeadline — every net.Conn, so every *tls.Conn — lets a cancelled monitor unblock its own read and shut down on the spot. A plain io.Reader cannot be interrupted from outside, so its reader goroutine stays parked until the reader returns; Done reports the cancellation either way.

func (*SessionMonitor) Done

func (m *SessionMonitor) Done() <-chan error

Done returns a channel that receives exactly one value when monitoring ends. A *SessionExpiredError indicates the server sent AUTH_FAILED mid-session. io.EOF or context errors indicate normal connection close / cancellation.

func (*SessionMonitor) Start

func (m *SessionMonitor) Start(ctx context.Context)

Start begins monitoring in a background goroutine. The goroutine stops when ctx is cancelled or when a message is received. Errors (including *SessionExpiredError) are delivered via Done().

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL