Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions cmd/usher/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ const (
statePath = "/srv/usher/state/users.json"
)

// environment holds the server facts from the process environment and the fixed paths.
type environment struct {
pass.Settings
Host netip.Addr // USHER_HOST
Expand Down Expand Up @@ -70,7 +69,6 @@ func environmentFrom(getenv func(string) string) (environment, error) {
}, nil
}

// parseDNS parses the comma-separated USHER_DNS list that the awg backend puts into client configs.
func parseDNS(value string) ([]netip.Addr, error) {
var dns []netip.Addr
for _, field := range strings.Split(value, ",") {
Expand All @@ -94,7 +92,6 @@ func parseIPv4(name, value string) (netip.Addr, error) {
return addr, nil
}

// parseAbsPath requires a non-empty absolute path.
func parseAbsPath(name, value string) (string, error) {
if value == "" {
return "", fmt.Errorf("%s is required", name)
Expand Down
4 changes: 1 addition & 3 deletions cmd/usher/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,16 +84,14 @@ func xrayBackend(env environment, log *slog.Logger) pass.Backend {
}
}

// asSession converts a backend session to pass.Session. On error it returns a nil interface, not
// an interface that holds a nil pointer.
// asSession returns a nil interface on error, not an interface holding a nil pointer.
func asSession[S pass.Session](session S, err error) (pass.Session, error) {
if err != nil {
return nil, err
}
return session, nil
}

// dialUnix returns a dial function that opens a gRPC client over the unix socket at path.
func dialUnix[C any](path string, newClient func(grpc.ClientConnInterface) C) func() (C, func(), error) {
target := "unix://" + path
return func() (C, func(), error) {
Expand Down
4 changes: 1 addition & 3 deletions deploy/compose.e2e-xray.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,7 @@ services:
USHER_HOST: 172.30.98.2
USHER_XRAY_SOCKET: /run/xray/api.sock
USHER_XRAY_CONFIG: /etc/xray/config.json
# Client of one user; the test writes its config to ../.e2e-xray/client/<user>/config.json. The
# config has one HTTP proxy inbound per tested link, and the test reaches them at the fixed
# address. SSL_CERT_FILE stands in for the public CA that a real certificate chains to.
# SSL_CERT_FILE stands in for the public CA that a real certificate chains to.
client-alice:
profiles: [client]
image: ghcr.io/xtls/xray-core:26.10.10
Expand Down
8 changes: 2 additions & 6 deletions deploy/compose.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ services:
ulimits:
nofile: { soft: 65536, hard: 65536 }
ports:
# Publish the UDP port of every interface 1:1.
- "51820:51820/udp"
volumes:
# One <interface>.conf per interface, [Interface] only.
Expand All @@ -22,8 +21,7 @@ services:
# Must equal usher's group.
AWG_GRPC_SOCKET_GID: "1000"
xray:
# The tag follows the xray-core version in usher's go.mod. A different version can change how
# Xray builds an inbound, and usher then reports every inbound as drifted.
# Match the xray-core version in usher's go.mod, or usher may report every inbound as drifted.
image: ghcr.io/xtls/xray-core:26.10.10
restart: unless-stopped
# Must equal usher's group, so that usher can open the API socket.
Expand All @@ -38,9 +36,7 @@ services:
# The certificates and keys that the config names. Only Xray mounts them: usher needs no
# access to a key file, and Xray reloads the files itself.
- ./xray-certs:/etc/xray-certs:ro
# A host directory that uid 1000 owns (create it before the first start), not a named volume,
# which belongs to root: after a crash usher unlinks the stale API socket, and that needs
# write access to the directory.
# Owned by uid 1000, not a named volume: usher unlinks a stale API socket after a crash.
- ./xray-sock:/run/xray
usher:
image: ghcr.io/mrcsin/usher:v0.1.0
Expand Down
5 changes: 2 additions & 3 deletions internal/atomicfile/atomicfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,8 @@ import (
"path/filepath"
)

// Write puts data into path with the given mode: it writes a temp file in the same directory,
// syncs it, renames it over path and syncs the directory so the rename survives power loss. The
// directory of path must exist.
// Write replaces path atomically and syncs the directory so the rename survives power loss.
// The directory of path must exist.
func Write(path string, data []byte, mode os.FileMode) error {
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".atomic-*.tmp")
Expand Down
12 changes: 3 additions & 9 deletions internal/awg/enroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,8 @@ import (
"github.com/mrcsin/usher/internal/state"
)

// enroll makes st hold a valid entry for every user in users on the interface. It creates missing
// entries (X25519 key pair, random PSK, lowest free address) and reassigns stored addresses that
// break the allocation rules. users must be sorted: of two entries with the same address the
// first keeps it. It reports whether any entry was created or changed. On error st is left as it
// was.
// enroll reports whether st changed; on error st is unchanged. users must be sorted: of two
// entries with the same address the first keeps it.
func enroll(log *slog.Logger, st *state.State, iface *awgv1.InterfaceStatus, users []string) (bool, error) {
if len(users) == 0 {
return false, nil
Expand All @@ -43,8 +40,7 @@ func enroll(log *slog.Logger, st *state.State, iface *awgv1.InterfaceStatus, use
}
}

// Every valid stored address is claimed before any new one is allocated, so a new user never
// takes the address of an existing one.
// Claim every stored address first, so a new user never takes an existing one's.
var needAddress []string
for _, user := range users {
entry := st.AWG[state.EntryName(ifaceName, user)]
Expand Down Expand Up @@ -100,7 +96,6 @@ func interfaceNetwork(iface *awgv1.InterfaceStatus) (netip.Prefix, map[netip.Add
return network, reserved, nil
}

// usable reports whether addr is a host address of prefix that nothing else holds.
func usable(prefix netip.Prefix, reserved, taken map[netip.Addr]bool, addr netip.Addr) bool {
if !prefix.Contains(addr) || addr == prefix.Addr() || addr == broadcast(prefix) {
return false
Expand All @@ -119,7 +114,6 @@ func lowestFree(prefix netip.Prefix, reserved, taken map[netip.Addr]bool) (netip
return netip.Addr{}, fmt.Errorf("no free address in %s", prefix)
}

// broadcast returns the highest address of an IPv4 prefix.
func broadcast(prefix netip.Prefix) netip.Addr {
network := prefix.Masked().Addr().As4()
hostMask := uint32(1)<<(32-prefix.Bits()) - 1
Expand Down
1 change: 0 additions & 1 deletion internal/awg/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"google.golang.org/grpc"
)

// fakeClient implements awgv1.ManagementServiceClient with func fields and records requests.
type fakeClient struct {
status func() (*awgv1.GetStatusResponse, error)
apply func(*awgv1.ApplyPeersRequest) (*awgv1.ApplyPeersResponse, error)
Expand Down
3 changes: 1 addition & 2 deletions internal/awg/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ import (
"github.com/mrcsin/usher/internal/state"
)

// render returns the client config of one user on one interface. The error never quotes a
// client_params value.
// render never quotes a client_params value in its errors.
func render(entry state.Entry, iface *awgv1.InterfaceStatus, host netip.Addr, dns []netip.Addr) ([]byte, error) {
port := iface.GetListenPort()
if port > math.MaxUint16 {
Expand Down
13 changes: 1 addition & 12 deletions internal/awg/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,12 @@ import (
"github.com/mrcsin/usher/internal/state"
)

// Suffix is the file suffix of an AmneziaWG client config.
const Suffix = ".conf"

// callTimeout bounds each call to awg-grpc.
const callTimeout = 10 * time.Second

// Dial opens a connection to awg-grpc. It returns the client and the function that closes the
// connection.
type Dial func() (awgv1.ManagementServiceClient, func(), error)

// Session is one pass's view of awg-grpc: the interfaces it reported when the session opened.
type Session struct {
client awgv1.ManagementServiceClient
closeConn func()
Expand Down Expand Up @@ -67,14 +62,12 @@ func Open(ctx context.Context, dial Dial, host netip.Addr, dns []netip.Addr, log
return s, nil
}

// Interfaces returns the names of the interfaces awg-grpc reported, in the order it reported them.
func (s *Session) Interfaces() []string {
return s.names
}

// Prepare enrolls the users on the interface and renders their client configs, keyed by
// clients.Path. It reports whether it changed st. It fails an interface that is not present in
// the kernel.
// clients.Path. It reports whether it changed st and fails an interface absent from the kernel.
func (s *Session) Prepare(st *state.State, name string, users []string) (map[string][]byte, bool, error) {
iface := s.ifaces[name]
if !iface.GetPresent() {
Expand All @@ -95,7 +88,6 @@ func (s *Session) Prepare(st *state.State, name string, users []string) (map[str
return files, changed, nil
}

// Apply sends the peers of the users to awg-grpc and logs what changed.
func (s *Session) Apply(ctx context.Context, st *state.State, name string, users []string) error {
peers := make([]*awgv1.Peer, 0, len(users))
for _, user := range users {
Expand Down Expand Up @@ -126,13 +118,10 @@ func (s *Session) Apply(ctx context.Context, st *state.State, name string, users
return nil
}

// Close closes the connection to awg-grpc.
func (s *Session) Close() {
s.closeConn()
}

// publicKeyNames maps the public key of every entry of the interface, switched off or not, to
// its user name.
func publicKeyNames(st *state.State, ifaceName string) map[string]string {
names := make(map[string]string)
for user, e := range st.InterfaceEntries(ifaceName) {
Expand Down
14 changes: 4 additions & 10 deletions internal/clients/mirror.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,8 @@ func Path(user, ifaceName, suffix string) string {
return user + "/" + ifaceName + suffix
}

// Existing returns the current content of the files in dir that end in one of suffixes and belong
// to an interface for which keep returns true, keyed by Path. It reads as much as it can and returns
// the errors of the rest joined.
// Existing returns the files in dir with one of suffixes on an interface that keep accepts, keyed
// by Path. It reads what it can and joins the errors of the rest.
func Existing(dir string, suffixes []string, keep func(ifaceName string) bool) (map[string][]byte, error) {
files := make(map[string][]byte)
userDirs, err := os.ReadDir(dir)
Expand Down Expand Up @@ -62,7 +61,6 @@ func Existing(dir string, suffixes []string, keep func(ifaceName string) bool) (
return files, errors.Join(errs...)
}

// cutSuffixes strips the first of suffixes that name ends in and returns it.
func cutSuffixes(name string, suffixes []string) (base, suffix string, ok bool) {
for _, suffix := range suffixes {
if base, ok := strings.CutSuffix(name, suffix); ok {
Expand All @@ -72,10 +70,8 @@ func cutSuffixes(name string, suffixes []string) (base, suffix string, ok bool)
return "", "", false
}

// Mirror makes dir hold exactly the desired files. Keys of desired are slash-separated paths
// relative to dir, such as "phone/awg0.conf". Files are written atomically with mode 0600 and
// only when the content differs. Every other file and every empty directory is
// removed.
// Mirror makes dir hold exactly desired, keyed by slash-separated path relative to dir. Files are
// written atomically with mode 0600 and only when the content differs.
func Mirror(dir string, desired map[string][]byte) error {
wanted := make(map[string]bool, len(desired))
for rel := range desired {
Expand All @@ -94,8 +90,6 @@ func Mirror(dir string, desired map[string][]byte) error {
return nil
}

// removeUnwanted deletes every non-directory entry under dir that is not wanted, then every
// directory left empty. dir itself stays.
func removeUnwanted(dir string, wanted map[string]bool) error {
var dirs []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
Expand Down
6 changes: 2 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,12 @@ var (
interfaceNamePattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_=+.-]{0,31}$`)
)

// ValidInterfaceName reports whether name is acceptable as an interface name in usher.yml.
func ValidInterfaceName(name string) bool {
return interfaceNamePattern.MatchString(name)
}

// Load reads path and returns the interface names of every user. A user with an empty list is
// valid, but a file without any user/interface pair is not. Every error names the file, and the
// line when the parser or a rule violation has one.
// Load returns the interface names of every user; a file without any user/interface pair is an
// error. Every error names the file, and the line when there is one.
func Load(path string) (map[string][]string, error) {
data, err := os.ReadFile(path)
if err != nil {
Expand Down
7 changes: 1 addition & 6 deletions internal/pass/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,16 @@ import (
"github.com/mrcsin/usher/internal/state"
)

// Session is one pass's connection to a backend. Open lists the backend's interfaces; the
// session prepares and applies one interface at a time.
// Session is one pass's connection to a backend, working one interface at a time.
type Session interface {
// Interfaces returns the names of the interfaces the backend reported.
Interfaces() []string
// Prepare enrolls the users on the interface in st and returns their client files, keyed by
// clients.Path, and whether it changed st. The pass saves st before any Apply.
Prepare(st *state.State, name string, users []string) (files map[string][]byte, changed bool, err error)
// Apply makes the interface hold exactly the users.
Apply(ctx context.Context, st *state.State, name string, users []string) error
// Close releases the connection.
Close()
}

// Backend is a VPN server usher moves users into.
type Backend struct {
Name string // used in logs, such as "awg-grpc"
Suffixes []string // suffixes of the client files the backend renders, such as ".conf"
Expand Down
22 changes: 4 additions & 18 deletions internal/pass/pass.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,34 +13,30 @@ import (
"github.com/mrcsin/usher/internal/state"
)

// Pass runs reconcile passes. It keeps the last valid usher.yml it read.
// Pass keeps the last valid usher.yml it read.
type Pass struct {
settings Settings
backends []Backend
log *slog.Logger
last map[string][]string
}

// New returns a Pass that works with the paths in settings and moves users into backends.
func New(settings Settings, backends []Backend, log *slog.Logger) *Pass {
return &Pass{settings: settings, backends: backends, log: log}
}

// openBackend is a backend with its session for this pass.
type openBackend struct {
Backend
session Session
}

// interfaceKey identifies an interface of one backend, by the backend's index in the open list.
type interfaceKey struct {
backend int
name string
}

// Run executes one pass. It logs every failure and never panics on one; a failure of one
// interface leaves the others to apply, and a backend that is down leaves the other backends to
// apply.
// Run executes one pass. It logs every failure; one failed interface or down backend never
// blocks the others.
func (p *Pass) Run(ctx context.Context) {
cfg, ok := p.loadConfig()
if !ok {
Expand Down Expand Up @@ -80,9 +76,6 @@ func (p *Pass) Run(ctx context.Context) {
p.mirrorClients(open, down, rendered, failed)
}

// prepareAll prepares every interface that exactly one backend reports. It marks a failed
// interface in failed, and returns the rendered client files by interface and whether any
// backend changed the state.
func (p *Pass) prepareAll(st *state.State, open []openBackend, byInterface map[string][]string,
owners map[string][]string, failed map[interfaceKey]bool) (map[interfaceKey]map[string][]byte, bool) {
rendered := make(map[interfaceKey]map[string][]byte)
Expand All @@ -109,7 +102,6 @@ func (p *Pass) prepareAll(st *state.State, open []openBackend, byInterface map[s
return rendered, changed
}

// applyAll applies every interface that Prepare did not fail and marks the ones that fail now.
func (p *Pass) applyAll(ctx context.Context, st *state.State, open []openBackend,
byInterface map[string][]string, failed map[interfaceKey]bool) {
for i, b := range open {
Expand All @@ -126,8 +118,7 @@ func (p *Pass) applyAll(ctx context.Context, st *state.State, open []openBackend
}
}

// mirrorClients writes the client files of the interfaces that did not fail and keeps the current
// files of the failed interfaces and of the backends that are down.
// mirrorClients keeps the current files of failed interfaces and of backends that are down.
func (p *Pass) mirrorClients(open []openBackend, down []Backend,
rendered map[interfaceKey]map[string][]byte, failed map[interfaceKey]bool) {
desired := make(map[string][]byte)
Expand All @@ -147,7 +138,6 @@ func (p *Pass) mirrorClients(open []openBackend, down []Backend,
}
}

// openBackends opens a session for every backend and returns the backends that failed to open.
func (p *Pass) openBackends(ctx context.Context) ([]openBackend, []Backend) {
var open []openBackend
var down []Backend
Expand All @@ -163,8 +153,6 @@ func (p *Pass) openBackends(ctx context.Context) ([]openBackend, []Backend) {
return open, down
}

// keepFiles copies the current client files of the backend's interfaces that keep selects into
// desired.
func (p *Pass) keepFiles(desired map[string][]byte, b Backend, keep func(ifaceName string) bool) {
kept, err := clients.Existing(p.settings.ClientsDir, b.Suffixes, keep)
if err != nil {
Expand All @@ -187,8 +175,6 @@ func (p *Pass) loadConfig() (map[string][]string, bool) {
return p.last, true
}

// interfaceOwners maps every reported interface name to the names of the backends that
// reported it.
func interfaceOwners(open []openBackend) map[string][]string {
owners := make(map[string][]string)
for _, b := range open {
Expand Down
2 changes: 0 additions & 2 deletions internal/pass/pass_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import (
"github.com/mrcsin/usher/internal/state"
)

// fakeSession is a Session that renders "new <interface>" into one file per user and suffix and
// enrolls a state entry the first time an interface has users.
type fakeSession struct {
names []string
suffixes []string
Expand Down
1 change: 0 additions & 1 deletion internal/pass/settings.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// requests to each backend and the clients directory.
package pass

// Settings holds the paths one pass works with.
type Settings struct {
ConfigPath string
ClientsDir string
Expand Down
Loading
Loading