diff --git a/README.md b/README.md index 23e9fac..a5b69b5 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,30 @@ package](https://git.zx2c4.com/wireguard-go/tree/device/tun.go#n14). Optionally, MTU can be set explicitly per wg device created by the agent via the configuration file (using the "MTU" key under device config) +#### Device id + +The agent identifies itself to the servers with a device id, a UUID that it +creates on first start and keeps in a file. The servers keep a lease per user +and device id, see [Leases per user](#leases-per-user), so a restart of the +agent keeps its lease and several devices of a user can be connected at the +same time. + +The file is set with `-device-id-file` and is +`/var/lib/wiresteward/device-id` by default, next to the oauth token whose +location is set with `-token-file`. The agent does not start when it cannot read +or create the file, or create the directory of the token, so the directories +have to be writable by the user that runs it. It does not start either when the +file has other content than a UUID, with or without dashes, braces or a +`urn:uuid:` prefix, and it does not replace it. An empty file is replaced. +Delete the file to give the agent a new identity, which takes a new lease. + +Every installation needs its own id. Machines that share the file share a lease +and keep replacing each other's, so do not copy the file to another machine, and +remove it before taking an image of a machine or cloning a virtual machine. + +The agent also sends its version, operating system and architecture as its +`User-Agent`, which the servers show as the client of a peer. + ### Running as Systemd service (Linux) The agent is designed to run as a Systemd service. An example working service diff --git a/android/.gitignore b/android/.gitignore index 3caaa00..51e69db 100644 --- a/android/.gitignore +++ b/android/.gitignore @@ -5,3 +5,4 @@ build/ local.properties *.iml .idea/ +.kotlin/ diff --git a/android/app/src/main/java/com/utilitywarehouse/wiresteward/WirestewardVpnService.kt b/android/app/src/main/java/com/utilitywarehouse/wiresteward/WirestewardVpnService.kt index a025eb3..fa5d426 100644 --- a/android/app/src/main/java/com/utilitywarehouse/wiresteward/WirestewardVpnService.kt +++ b/android/app/src/main/java/com/utilitywarehouse/wiresteward/WirestewardVpnService.kt @@ -10,6 +10,7 @@ import android.net.VpnService import android.os.Build import android.util.Log import mobile.Mobile +import mobile.StartOptions import mobile.TunProvider import java.io.File import java.util.concurrent.Executors @@ -62,7 +63,13 @@ class WirestewardVpnService : VpnService(), TunProvider { // opens files and may touch the network. agentExecutor.execute { try { - Mobile.start(config.absolutePath, filesDir.absolutePath, "", "debug", this) + val options = StartOptions().apply { + configPath = config.absolutePath + filesDir = this@WirestewardVpnService.filesDir.absolutePath + logLevel = "debug" + userAgent = userAgent() + } + Mobile.start(options, this) running = true } catch (e: Exception) { Log.e(TAG, "cannot start agent", e) @@ -72,6 +79,14 @@ class WirestewardVpnService : VpnService(), TunProvider { } } + /** What the servers show as the client: the app version, the Android version and the CPU architecture. */ + @Suppress("DEPRECATION") + private fun userAgent(): String { + val version = packageManager.getPackageInfo(packageName, 0).versionName ?: "unknown" + val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "unknown" + return "wiresteward-android/$version (Android ${Build.VERSION.RELEASE}; $abi)" + } + private fun stopAgent() { starting.set(false) agentExecutor.execute { diff --git a/android/mobile/mobile.go b/android/mobile/mobile.go index 8f480dc..f6126fa 100644 --- a/android/mobile/mobile.go +++ b/android/mobile/mobile.go @@ -32,13 +32,56 @@ type runningAgent struct { tunnel *tunnel } -// Start reads the agent config at configPath and starts the agent. filesDir is -// a writable directory (the app's files dir) where the oauth token and the -// device key are stored. The agent serves its status and login pages on -// listenAddr, "localhost:7773" if empty; the app should open -// http:///renew in a browser to log in. logLevel is "debug" or -// "error". -func Start(configPath, filesDir, listenAddr, logLevel string, provider TunProvider) error { +// StartOptions are the settings of Start. The fields are strings that are easy +// to mix up, so they are named. +type StartOptions struct { + // ConfigPath is the agent config file. + ConfigPath string + // FilesDir is a writable directory, the app's files dir, where the oauth + // token, the device id and the device key are stored. + FilesDir string + // ListenAddr is where the agent serves its status and login pages, + // "localhost:7773" if empty. The app opens http:///renew in + // a browser to log in. + ListenAddr string + // LogLevel is "debug" or "error", "error" if empty. + LogLevel string + // UserAgent is sent to the servers, which show it as the client of the + // device. It is "wiresteward-android" if empty. + UserAgent string +} + +// withDefaults returns the options with the defaults of the empty fields, or an +// error when a required one is missing. +func (o *StartOptions) withDefaults() (StartOptions, error) { + if o == nil { + return StartOptions{}, errors.New("start options are required") + } + opts := *o + if opts.ConfigPath == "" { + return StartOptions{}, errors.New("config path is required") + } + if opts.FilesDir == "" { + return StartOptions{}, errors.New("files dir is required") + } + if opts.ListenAddr == "" { + opts.ListenAddr = "localhost:7773" + } + if opts.LogLevel == "" { + opts.LogLevel = "error" + } + if opts.UserAgent == "" { + opts.UserAgent = "wiresteward-android" + } + return opts, nil +} + +// Start reads the agent config and starts the agent. +func Start(options *StartOptions, provider TunProvider) error { + opts, err := options.withDefaults() + if err != nil { + return err + } mu.Lock() defer mu.Unlock() if running != nil { @@ -47,12 +90,9 @@ func Start(configPath, filesDir, listenAddr, logLevel string, provider TunProvid if provider == nil { return errors.New("tun provider is required") } - logging.SetLogLevel(logLevel) - if listenAddr == "" { - listenAddr = "localhost:7773" - } + logging.SetLogLevel(opts.LogLevel) - cfg, err := config.ReadAgentConfig(configPath) + cfg, err := config.ReadAgentConfig(opts.ConfigPath) if err != nil { return fmt.Errorf("cannot read agent config: %w", err) } @@ -63,20 +103,25 @@ func Start(configPath, filesDir, listenAddr, logLevel string, provider TunProvid mtu = dev.MTU } } - filesDir = filepath.Clean(filesDir) + filesDir := filepath.Clean(opts.FilesDir) tunnel := newTunnel(mtu, filepath.Join(filesDir, "wiresteward.key"), provider) if err := tunnel.Run(); err != nil { return fmt.Errorf("cannot load tunnel key: %w", err) } - a := agent.NewAgent(cfg, agent.Options{ + a, err := agent.NewAgent(cfg, agent.Options{ NewDevice: func(name string, _ int) devicemanager.Backend { return &tunnelDevice{name: name, tunnel: tunnel} }, TokenFile: filepath.Join(filesDir, "token"), - ListenAddress: listenAddr, - UserAgent: "wiresteward-android", + DeviceIDFile: filepath.Join(filesDir, "device-id"), + ListenAddress: opts.ListenAddr, + UserAgent: opts.UserAgent, LoginRedirectPageOverride: loginRedirectPageOverride, }) + if err != nil { + tunnel.Stop() + return fmt.Errorf("cannot create the agent: %w", err) + } running = &runningAgent{agent: a, tunnel: tunnel} go a.ListenAndServe() return nil diff --git a/android/mobile/mobile_test.go b/android/mobile/mobile_test.go new file mode 100644 index 0000000..b018322 --- /dev/null +++ b/android/mobile/mobile_test.go @@ -0,0 +1,51 @@ +package mobile + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" +) + +type noProvider struct{} + +func (noProvider) Establish(string, int, string, int) (int, error) { return -1, nil } + +func TestStartOptions_Defaults(t *testing.T) { + opts, err := (&StartOptions{ConfigPath: "/c", FilesDir: "/f"}).withDefaults() + assert.NoError(t, err) + assert.Equal(t, "localhost:7773", opts.ListenAddr) + assert.Equal(t, "error", opts.LogLevel) + assert.Equal(t, "wiresteward-android", opts.UserAgent, "an empty user agent is not sent empty") + assert.Equal(t, "/c", opts.ConfigPath) + assert.Equal(t, "/f", opts.FilesDir) + + opts, err = (&StartOptions{ + ConfigPath: "/c", FilesDir: "/f", ListenAddr: "127.0.0.1:1", LogLevel: "error", + UserAgent: "wiresteward-android/1 (Android 15; arm64-v8a)", + }).withDefaults() + assert.NoError(t, err) + assert.Equal(t, "127.0.0.1:1", opts.ListenAddr) + assert.Equal(t, "error", opts.LogLevel) + assert.Equal(t, "wiresteward-android/1 (Android 15; arm64-v8a)", opts.UserAgent) +} + +func TestStartOptions_Required(t *testing.T) { + var nilOpts *StartOptions + _, err := nilOpts.withDefaults() + assert.Error(t, err) + _, err = (&StartOptions{FilesDir: "/f"}).withDefaults() + assert.ErrorContains(t, err, "config path") + _, err = (&StartOptions{ConfigPath: "/c"}).withDefaults() + assert.ErrorContains(t, err, "files dir") +} + +func TestStart_RejectsBadArguments(t *testing.T) { + assert.Error(t, Start(nil, noProvider{})) + assert.Error(t, Start(&StartOptions{ConfigPath: "/c", FilesDir: "/f"}, nil)) + // An unreadable config does not leave an agent running. + assert.Error(t, Start(&StartOptions{ConfigPath: "/does/not/exist", FilesDir: t.TempDir()}, noProvider{})) + var status struct{ Running bool } + assert.NoError(t, json.Unmarshal([]byte(Status()), &status)) + assert.False(t, status.Running) +} diff --git a/cmd/wiresteward/main.go b/cmd/wiresteward/main.go index 13f16b5..d26ea20 100644 --- a/cmd/wiresteward/main.go +++ b/cmd/wiresteward/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "os/signal" + "runtime" "runtime/debug" "strings" "syscall" @@ -28,6 +29,7 @@ var ( // looking wiresteward initials hex on ascii table (w = 0x77 and s = 0x73) flagAgentAddress = flag.String("agent-listen-address", "localhost:7773", "Address where the agent http server runs.\nThe URL http:///oauth2/callback must be a valid callback url for the oauth2 application.") flagConfig = flag.String("config", "/etc/wiresteward/config.json", "Config file") + flagDeviceIDFile = flag.String("device-id-file", "/var/lib/wiresteward/device-id", "File where the agent keeps the device id that identifies it to the servers.\nThe agent does not start if it cannot read or create it.") flagDeviceType = flag.String("device-type", "", "Type of the network device to use for the agent, 'tun' or 'wireguard'.\nThe tun device relies on the wireguard-go userspace implementation that is compatible with all platforms.\nA wireguard device relies on wireguard-enabled linux kernels (5.6 or newer or wireguard-dkms module + Linux headers).") flagLogLevel = flag.String("log-level", "error", "Log Level (debug|error)") flagMetricsAddr = flag.String("metrics-address", ":8081", "Metrics server address, meaningful when combined with -server flag") @@ -36,6 +38,12 @@ var ( flagVersion = flag.Bool("version", false, "Prints out application version") ) +// agentUserAgent is the User-Agent of the agent: its version, the operating +// system and the architecture. Servers expose it as the client of a peer. +func agentUserAgent(version string) string { + return fmt.Sprintf("wiresteward-agent/%s (%s; %s)", version, runtime.GOOS, runtime.GOARCH) +} + func main() { flag.Parse() @@ -81,7 +89,7 @@ func main() { } if *flagAgent { - userAgent = "wiresteward-agent/" + version + userAgent = agentUserAgent(version) logger.Verbosef("running as agent version=%s go=%s\n", version, buildInfo.GoVersion) runAgent() return @@ -118,12 +126,17 @@ func runAgent() { signal.Notify(term, syscall.SIGTERM) signal.Notify(term, os.Interrupt) - a := agent.NewAgent(agentConf, agent.Options{ + a, err := agent.NewAgent(agentConf, agent.Options{ DeviceType: *flagDeviceType, ListenAddress: *flagAgentAddress, UserAgent: userAgent, TokenFile: *flagTokenFile, + DeviceIDFile: *flagDeviceIDFile, }) + if err != nil { + logger.Errorf("Cannot start the agent: %v", err) + os.Exit(1) + } go func() { a.ListenAndServe() close(term) diff --git a/cmd/wiresteward/main_test.go b/cmd/wiresteward/main_test.go new file mode 100644 index 0000000..9ab352e --- /dev/null +++ b/cmd/wiresteward/main_test.go @@ -0,0 +1,12 @@ +package main + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAgentUserAgent(t *testing.T) { + assert.Equal(t, "wiresteward-agent/v1.2.3 ("+runtime.GOOS+"; "+runtime.GOARCH+")", agentUserAgent("v1.2.3")) +} diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4196fdd..9c08107 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -40,6 +40,9 @@ type Options struct { UserAgent string // TokenFile is where the oauth token is stored. TokenFile string + // DeviceIDFile is where the device id is stored. The agent does not run + // when the id cannot be read or created there. + DeviceIDFile string // NewDevice optionally creates the backend of a configured device, // replacing the default linux and darwin device setup. NewDevice func(name string, mtu int) devicemanager.Backend @@ -50,13 +53,23 @@ type Options struct { // NewAgent creates an Agent from an AgentConfig. It generates a DeviceManager // per device specified in the configuration, sets up and starts the associated -// resources. -func NewAgent(cfg *config.AgentConfig, opts Options) *Agent { +// resources. It fails when the device id cannot be read or created: the agent +// does not run without one. +func NewAgent(cfg *config.AgentConfig, opts Options) (*Agent, error) { agent := &Agent{ listenAddress: opts.ListenAddress, loginRedirectPageOverride: opts.LoginRedirectPageOverride, quit: make(chan struct{}), } + deviceID, err := loadOrCreateDeviceID(opts.DeviceIDFile) + if err != nil { + return nil, err + } + // The agent cannot get a token when it cannot store it. + tokenDir := filepath.Dir(opts.TokenFile) + if err := os.MkdirAll(tokenDir, 0750); err != nil { + return nil, fmt.Errorf("unable to create directory %s: %w", tokenDir, err) + } for _, dev := range cfg.Devices { urls := []string{} for _, peer := range dev.Peers { @@ -64,9 +77,9 @@ func NewAgent(cfg *config.AgentConfig, opts Options) *Agent { } var dm *devicemanager.DeviceManager if opts.NewDevice != nil { - dm = devicemanager.NewWithBackend(opts.NewDevice(dev.Name, dev.MTU), urls, cfg.HTTPClient.Timeout, cfg.HealthCheck, opts.UserAgent) + dm = devicemanager.NewWithBackend(opts.NewDevice(dev.Name, dev.MTU), urls, cfg.HTTPClient.Timeout, cfg.HealthCheck, deviceID, opts.UserAgent) } else { - dm = devicemanager.New(dev.Name, dev.MTU, urls, cfg.HTTPClient.Timeout, cfg.HealthCheck, opts.DeviceType, opts.UserAgent) + dm = devicemanager.New(dev.Name, dev.MTU, urls, cfg.HTTPClient.Timeout, cfg.HealthCheck, opts.DeviceType, deviceID, opts.UserAgent) } if err := dm.Run(); err != nil { logger.Errorf("Error starting device `%s`: %v", dm.Name(), err) @@ -74,11 +87,6 @@ func NewAgent(cfg *config.AgentConfig, opts Options) *Agent { } agent.deviceManagers = append(agent.deviceManagers, dm) } - tokenDir := filepath.Dir(opts.TokenFile) - err := os.MkdirAll(tokenDir, 0750) - if err != nil { - logger.Errorf("Unable to create directory=%s", tokenDir) - } agent.oa = oauth.NewTokenHandler( cfg.OAuth.AuthURL, cfg.OAuth.TokenURL, @@ -93,7 +101,7 @@ func NewAgent(cfg *config.AgentConfig, opts Options) *Agent { mux.HandleFunc("/renew", agent.renewHandler) mux.HandleFunc("/", agent.mainHandler) agent.server = &http.Server{Addr: opts.ListenAddress, Handler: mux} - return agent + return agent, nil } // ListenAndServe sets up and starts the background token refresh loop. diff --git a/internal/agent/agent_deviceid_test.go b/internal/agent/agent_deviceid_test.go new file mode 100644 index 0000000..5e5e532 --- /dev/null +++ b/internal/agent/agent_deviceid_test.go @@ -0,0 +1,191 @@ +package agent + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/go-jose/go-jose/v4" + "github.com/go-jose/go-jose/v4/jwt" + "github.com/stretchr/testify/assert" + "golang.org/x/oauth2" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + + "github.com/utilitywarehouse/wiresteward/internal/api" + "github.com/utilitywarehouse/wiresteward/internal/config" + "github.com/utilitywarehouse/wiresteward/internal/devicemanager" +) + +// fakeBackend is a device that does not touch the system. +type fakeBackend struct{ name string } + +func (b *fakeBackend) Name() string { return b.name } +func (b *fakeBackend) Run() error { return nil } +func (b *fakeBackend) Stop() {} +func (b *fakeBackend) PublicKey() (string, error) { return "backend-public-key", nil } +func (b *fakeBackend) Configure(_, _ *devicemanager.WirestewardPeerConfig) error { + return nil +} + +type leaseCall struct { + userAgent string + body string + request api.LeaseRequest +} + +// leaseServer answers lease requests and reports them. +func leaseServer(t *testing.T, calls chan<- leaseCall) *httptest.Server { + t.Helper() + key, err := wgtypes.GeneratePrivateKey() + assert.NoError(t, err) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + call := leaseCall{userAgent: r.UserAgent(), body: string(b)} + assert.NoError(t, json.Unmarshal(b, &call.request)) + calls <- call + assert.NoError(t, json.NewEncoder(w).Encode(api.LeaseResponse{ + IP: "10.90.0.3/32", + ServerWireguardIP: "10.90.0.1", + AllowedIPs: []string{"10.66.0.0/16"}, + PubKey: key.PublicKey().String(), + Endpoint: "127.0.0.1:51820", + })) + })) + t.Cleanup(srv.Close) + return srv +} + +// startAgent starts an agent with a valid token that leases from the server, +// and returns it once it has made a lease request. +func startAgent(t *testing.T, tokenFile, deviceIDFile, serverURL string) (stop func()) { + t.Helper() + signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.HS256, Key: []byte("0123456789abcdef0123456789abcdef")}, nil) + assert.NoError(t, err) + access, err := jwt.Signed(signer).Claims(jwt.Claims{ + Issuer: "test", + Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }).Serialize() + assert.NoError(t, err) + tok, err := json.Marshal(&oauth2.Token{AccessToken: access, RefreshToken: "r", Expiry: time.Now().Add(time.Hour)}) + assert.NoError(t, err) + assert.NoError(t, os.MkdirAll(filepath.Dir(tokenFile), 0750)) + assert.NoError(t, os.WriteFile(tokenFile, tok, 0600)) + + confFile := filepath.Join(t.TempDir(), "config.json") + assert.NoError(t, os.WriteFile(confFile, []byte(fmt.Sprintf(`{ + "oauth": {"clientID": "id", "authUrl": "https://idp.example.com/auth", "tokenUrl": "https://idp.example.com/token"}, + "devices": [{"name": "dev", "peers": [{"url": %q}]}] + }`, serverURL)), 0600)) + cfg, err := config.ReadAgentConfig(confFile) + assert.NoError(t, err) + + a, err := NewAgent(cfg, Options{ + NewDevice: func(name string, _ int) devicemanager.Backend { return &fakeBackend{name: name} }, + TokenFile: tokenFile, + DeviceIDFile: deviceIDFile, + ListenAddress: "127.0.0.1:0", + UserAgent: "wiresteward-agent/test (linux; amd64)", + }) + assert.NoError(t, err) + go a.ListenAndServe() + var once sync.Once + stop = func() { once.Do(a.Stop) } + t.Cleanup(stop) + return stop +} + +func waitForLease(t *testing.T, calls <-chan leaseCall) leaseCall { + t.Helper() + select { + case c := <-calls: + return c + case <-time.After(10 * time.Second): + t.Fatal("no lease request") + return leaseCall{} + } +} + +func TestAgent_SendsThePersistedDeviceID(t *testing.T) { + calls := make(chan leaseCall, 4) + srv := leaseServer(t, calls) + tokenFile := filepath.Join(t.TempDir(), "state", "token") + deviceIDFile := filepath.Join(t.TempDir(), "elsewhere", "device-id") + + stop := startAgent(t, tokenFile, deviceIDFile, srv.URL) + first := waitForLease(t, calls) + + b, err := os.ReadFile(deviceIDFile) + assert.NoError(t, err, "the device id is stored in the configured file") + id := string(b[:len(b)-1]) + assert.Regexp(t, deviceIDRegexp, id) + assert.Equal(t, id, first.request.DeviceID) + assert.Equal(t, "backend-public-key", first.request.PubKey) + assert.Equal(t, "wiresteward-agent/test (linux; amd64)", first.userAgent) + stop() + + // A restart sends the same id, so it does not take another lease. + startAgent(t, tokenFile, deviceIDFile, srv.URL) + second := waitForLease(t, calls) + assert.Equal(t, id, second.request.DeviceID) +} + +func TestNewAgent_FailsToStartWithoutADeviceID(t *testing.T) { + cfg := &config.AgentConfig{} + started := false + opts := func(deviceIDFile string) Options { + return Options{ + NewDevice: func(name string, _ int) devicemanager.Backend { + started = true + return &fakeBackend{name: name} + }, + TokenFile: filepath.Join(t.TempDir(), "token"), + DeviceIDFile: deviceIDFile, + } + } + + // No file configured. + a, err := NewAgent(cfg, opts("")) + assert.Error(t, err) + assert.Nil(t, a) + + // The file cannot be read. + dir := filepath.Join(t.TempDir(), "device-id") + assert.NoError(t, os.Mkdir(dir, 0700)) + a, err = NewAgent(cfg, opts(dir)) + assert.Error(t, err) + assert.Nil(t, a) + + // The file cannot be created. + blocker := filepath.Join(t.TempDir(), "blocker") + assert.NoError(t, os.WriteFile(blocker, nil, 0600)) + a, err = NewAgent(cfg, opts(filepath.Join(blocker, "device-id"))) + assert.Error(t, err) + assert.Nil(t, a) + + assert.False(t, started, "no device is set up before the id is known") +} + +func TestNewAgent_FailsToStartWithoutATokenDirectory(t *testing.T) { + // The token cannot be stored, so the agent could never authenticate. + blocker := filepath.Join(t.TempDir(), "blocker") + assert.NoError(t, os.WriteFile(blocker, nil, 0600)) + started := false + a, err := NewAgent(&config.AgentConfig{}, Options{ + NewDevice: func(name string, _ int) devicemanager.Backend { + started = true + return &fakeBackend{name: name} + }, + TokenFile: filepath.Join(blocker, "token"), + DeviceIDFile: filepath.Join(t.TempDir(), "device-id"), + }) + assert.ErrorContains(t, err, "unable to create directory") + assert.Nil(t, a) + assert.False(t, started) +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 75b7fe3..3e54f0a 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -9,10 +9,14 @@ import ( ) func TestAgent_Stop(t *testing.T) { - a := NewAgent(&config.AgentConfig{}, Options{ + a, err := NewAgent(&config.AgentConfig{}, Options{ ListenAddress: "127.0.0.1:0", TokenFile: filepath.Join(t.TempDir(), "token"), + DeviceIDFile: filepath.Join(t.TempDir(), "device-id"), }) + if err != nil { + t.Fatal(err) + } done := make(chan struct{}) go func() { a.ListenAndServe() @@ -27,9 +31,13 @@ func TestAgent_Stop(t *testing.T) { } func TestAgent_StatusWithoutToken(t *testing.T) { - a := NewAgent(&config.AgentConfig{}, Options{ - TokenFile: filepath.Join(t.TempDir(), "token"), + a, err := NewAgent(&config.AgentConfig{}, Options{ + TokenFile: filepath.Join(t.TempDir(), "token"), + DeviceIDFile: filepath.Join(t.TempDir(), "device-id"), }) + if err != nil { + t.Fatal(err) + } st := a.Status() if st.Token.Present { t.Fatal("expected no token") diff --git a/internal/agent/deviceid.go b/internal/agent/deviceid.go new file mode 100644 index 0000000..9c600df --- /dev/null +++ b/internal/agent/deviceid.go @@ -0,0 +1,154 @@ +package agent + +import ( + "crypto/rand" + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "syscall" +) + +var deviceIDRegexp = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +// writeFile stores the device id, it is replaced in tests. With exclusive set +// it only creates the file and reports false when it already exists. +var writeFile = writeFileAtomically + +// loadOrCreateDeviceID returns the device id kept in the file, which is a UUID +// that identifies this installation to the servers. The id is created and +// stored on first use, and it is the same after restarts. +// +// It fails when the id cannot be read or stored, and the agent does not run +// without one. An agent that started with a new id every time would take a new +// lease on every start. It also fails when the file has other content than an +// id, which it does not replace: an id that is written by hand can be a +// canonical UUID, with or without dashes, braces or a `urn:uuid:` prefix. Only +// an empty file, what a crash can leave behind, is replaced. +func loadOrCreateDeviceID(path string) (string, error) { + if path == "" { + return "", errors.New("no file to keep the device id in") + } + // Another start of the agent can create the file after we found it + // missing. It wins, and its id is read. + for attempt := 0; attempt < 3; attempt++ { + b, err := os.ReadFile(path) + exists := err == nil + switch { + case exists: + content := strings.TrimSpace(strings.TrimPrefix(string(b), "\ufeff")) + if content != "" { + id, ok := normalizeDeviceID(content) + if !ok { + return "", fmt.Errorf("invalid device id in %s, remove the file to create a new one", path) + } + return id, nil + } + logger.Errorf("Empty device id file %s, creating a new id", path) + case errors.Is(err, os.ErrNotExist): + default: + return "", fmt.Errorf("cannot read the device id from %s: %w", path, err) + } + + id, err := newDeviceID() + if err != nil { + return "", fmt.Errorf("cannot create a device id: %w", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0750); err != nil { + return "", fmt.Errorf("cannot store the device id in %s: %w", path, err) + } + created, err := writeFile(path, []byte(id+"\n"), !exists) + if err != nil { + return "", fmt.Errorf("cannot store the device id in %s: %w", path, err) + } + if created { + logger.Verbosef("Created device id %s in %s", id, path) + return id, nil + } + } + return "", fmt.Errorf("cannot settle on a device id in %s", path) +} + +// normalizeDeviceID returns the canonical form of a device id written as a +// UUID, in either case, with or without dashes, with braces or with a urn:uuid: +// prefix. +func normalizeDeviceID(s string) (string, bool) { + s = strings.ToLower(s) + s = strings.TrimPrefix(s, "urn:uuid:") + if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") { + s = s[1 : len(s)-1] + } + if len(s) == 32 && !strings.Contains(s, "-") { + s = s[0:8] + "-" + s[8:12] + "-" + s[12:16] + "-" + s[16:20] + "-" + s[20:] + } + return s, deviceIDRegexp.MatchString(s) +} + +// newDeviceID returns a random (version 4) UUID. +func newDeviceID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + b[6] = b[6]&0x0f | 0x40 + b[8] = b[8]&0x3f | 0x80 + return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil +} + +// writeFileAtomically stores the content in the file, so that a crash never +// leaves a partial file: it is written and synced under another name, which is +// then put in place, and the directory is synced. The file is only readable by +// its owner. With exclusive set an existing file is left alone and false is +// returned, which is what makes two processes that create it at once agree on +// one content. +func writeFileAtomically(path string, content []byte, exclusive bool) (bool, error) { + dir := filepath.Dir(path) + tmp, err := os.CreateTemp(dir, ".device-id-*") + if err != nil { + return false, err + } + defer os.Remove(tmp.Name()) + if _, err := tmp.Write(content); err != nil { + tmp.Close() + return false, err + } + if err := tmp.Sync(); err != nil { + tmp.Close() + return false, err + } + if err := tmp.Close(); err != nil { + return false, err + } + + if exclusive { + err := os.Link(tmp.Name(), path) + switch { + case err == nil: + syncDir(dir) + return true, nil + case errors.Is(err, os.ErrExist): + return false, nil + case !errors.Is(err, syscall.EPERM) && !errors.Is(err, syscall.ENOTSUP) && + !errors.Is(err, syscall.EXDEV) && !errors.Is(err, syscall.ENOSYS): + return false, err + } + // The file system has no hard links: fall back to a rename, which + // does not tell a file that was created in the meantime. + } + if err := os.Rename(tmp.Name(), path); err != nil { + return false, err + } + syncDir(dir) + return true, nil +} + +// syncDir makes the entries of a directory durable. Some file systems do not +// support it, which is not an error for the agent. +func syncDir(dir string) { + if d, err := os.Open(dir); err == nil { + d.Sync() + d.Close() + } +} diff --git a/internal/agent/deviceid_test.go b/internal/agent/deviceid_test.go new file mode 100644 index 0000000..f8f5759 --- /dev/null +++ b/internal/agent/deviceid_test.go @@ -0,0 +1,243 @@ +package agent + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" +) + +const testID = "0b9e1c7a-4d52-4f0e-9a64-8d1f3c2b5e70" + +func TestLoadOrCreateDeviceID_CreatesAndKeepsAnID(t *testing.T) { + path := filepath.Join(t.TempDir(), "device-id") + + id, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + assert.Regexp(t, deviceIDRegexp, id) + // A version 4 UUID. + assert.Equal(t, byte('4'), id[14]) + assert.Contains(t, "89ab", string(id[19])) + + b, err := os.ReadFile(path) + assert.NoError(t, err) + assert.Equal(t, id+"\n", string(b)) + info, err := os.Stat(path) + assert.NoError(t, err) + assert.Equal(t, os.FileMode(0600), info.Mode().Perm()) + + // Every start gets the same id. + for i := 0; i < 5; i++ { + again, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + assert.Equal(t, id, again) + } +} + +func TestLoadOrCreateDeviceID_IDsDiffer(t *testing.T) { + a, err := loadOrCreateDeviceID(filepath.Join(t.TempDir(), "device-id")) + assert.NoError(t, err) + b, err := loadOrCreateDeviceID(filepath.Join(t.TempDir(), "device-id")) + assert.NoError(t, err) + assert.NotEqual(t, a, b) +} + +func TestLoadOrCreateDeviceID_CreatesTheDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "state", "agent", "device-id") + id, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + assert.Regexp(t, deviceIDRegexp, id) +} + +// An id that is written by hand is used as it is, in its canonical form, and +// the file is left alone: the id must not rotate because of how it is spelled. +func TestLoadOrCreateDeviceID_AcceptsOtherSpellingsOfAUUID(t *testing.T) { + for name, content := range map[string]string{ + "canonical": testID + "\n", + "no newline": testID, + "windows": testID + "\r\n", + "spaces": " " + testID + " \n\n", + "uppercase": strings.ToUpper(testID) + "\n", + "braces": "{" + testID + "}\n", + "urn": "urn:uuid:" + testID + "\n", + "urn uppercase": "URN:UUID:" + strings.ToUpper(testID), + "without dashes": strings.ReplaceAll(testID, "-", "") + "\n", + "byte order": "\ufeff" + testID + "\n", + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "device-id") + assert.NoError(t, os.WriteFile(path, []byte(content), 0600)) + id, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + assert.Equal(t, testID, id) + b, _ := os.ReadFile(path) + assert.Equal(t, content, string(b), "the file is not rewritten") + }) + } +} + +// Content that is not an id is not replaced, the agent does not guess. +func TestLoadOrCreateDeviceID_FailsOnOtherContent(t *testing.T) { + for name, content := range map[string]string{ + "text": "not an id", + "short": testID[:35], + "long": testID + "0", + "two ids": testID + "\n" + testID + "\n", + "not hex": strings.Replace(testID, "0b9e", "zzzz", 1), + "half braces": "{" + testID, + } { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "device-id") + assert.NoError(t, os.WriteFile(path, []byte(content), 0600)) + id, err := loadOrCreateDeviceID(path) + assert.ErrorContains(t, err, "invalid device id") + assert.Equal(t, "", id) + b, _ := os.ReadFile(path) + assert.Equal(t, content, string(b), "the file is left alone") + }) + } +} + +// An empty file is what a crash can leave behind. +func TestLoadOrCreateDeviceID_ReplacesAnEmptyFile(t *testing.T) { + for name, content := range map[string]string{"empty": "", "blank": " \n\n", "byte order": "\ufeff"} { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "device-id") + assert.NoError(t, os.WriteFile(path, []byte(content), 0600)) + id, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + assert.Regexp(t, deviceIDRegexp, id) + again, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + assert.Equal(t, id, again, "the replacement is kept") + }) + } +} + +// Two starts of the agent that find no file at the same time end up with one id, +// the one that is stored. +func TestLoadOrCreateDeviceID_ConcurrentFirstStartsAgree(t *testing.T) { + for round := 0; round < 20; round++ { + dir := t.TempDir() + path := filepath.Join(dir, "device-id") + const starts = 16 + ids := make([]string, starts) + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < starts; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + id, err := loadOrCreateDeviceID(path) + assert.NoError(t, err) + ids[i] = id + }(i) + } + close(start) + wg.Wait() + + b, err := os.ReadFile(path) + assert.NoError(t, err) + stored := strings.TrimSpace(string(b)) + for _, id := range ids { + assert.Equal(t, stored, id) + } + entries, _ := os.ReadDir(dir) + assert.Len(t, entries, 1, "no temporary files are left") + } +} + +func TestWriteFileAtomically_ExclusiveLeavesAnExistingFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "device-id") + + created, err := writeFileAtomically(path, []byte("first\n"), true) + assert.NoError(t, err) + assert.True(t, created) + + created, err = writeFileAtomically(path, []byte("second\n"), true) + assert.NoError(t, err) + assert.False(t, created) + b, _ := os.ReadFile(path) + assert.Equal(t, "first\n", string(b)) + + // Without exclusive it replaces the file. + created, err = writeFileAtomically(path, []byte("third\n"), false) + assert.NoError(t, err) + assert.True(t, created) + b, _ = os.ReadFile(path) + assert.Equal(t, "third\n", string(b)) +} + +func TestLoadOrCreateDeviceID_FailsWhenTheFileCannotBeWritten(t *testing.T) { + path := filepath.Join(t.TempDir(), "device-id") + defer func(w func(string, []byte, bool) (bool, error)) { writeFile = w }(writeFile) + writeFile = func(string, []byte, bool) (bool, error) { return false, errors.New("disk full") } + + id, err := loadOrCreateDeviceID(path) + assert.ErrorContains(t, err, "disk full") + assert.Equal(t, "", id) + _, statErr := os.Stat(path) + assert.True(t, os.IsNotExist(statErr)) +} + +func TestLoadOrCreateDeviceID_FailsWhenItCannotAgree(t *testing.T) { + // The file keeps being created by someone else but is never there to read. + path := filepath.Join(t.TempDir(), "device-id") + defer func(w func(string, []byte, bool) (bool, error)) { writeFile = w }(writeFile) + writeFile = func(string, []byte, bool) (bool, error) { return false, nil } + + id, err := loadOrCreateDeviceID(path) + assert.Error(t, err) + assert.Equal(t, "", id) +} + +func TestLoadOrCreateDeviceID_FailsInAReadOnlyDirectory(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can write to a read-only directory") + } + dir := filepath.Join(t.TempDir(), "readonly") + assert.NoError(t, os.Mkdir(dir, 0500)) + for _, path := range []string{ + filepath.Join(dir, "device-id"), // the file cannot be created + filepath.Join(dir, "missing", "device-id"), // the directory cannot be created + } { + id, err := loadOrCreateDeviceID(path) + assert.Error(t, err, path) + assert.Equal(t, "", id, path) + } +} + +func TestLoadOrCreateDeviceID_FailsWhenItCannotBeRead(t *testing.T) { + // A directory in place of the file is an error that is not "not found". + path := filepath.Join(t.TempDir(), "device-id") + assert.NoError(t, os.Mkdir(path, 0700)) + id, err := loadOrCreateDeviceID(path) + assert.Error(t, err) + assert.Equal(t, "", id) + info, err := os.Stat(path) + assert.NoError(t, err) + assert.True(t, info.IsDir(), "the path is left alone") +} + +func TestLoadOrCreateDeviceID_FailsWithoutAFile(t *testing.T) { + _, err := loadOrCreateDeviceID("") + assert.Error(t, err) +} + +func TestLoadOrCreateDeviceID_LeavesNoTemporaryFiles(t *testing.T) { + dir := t.TempDir() + _, err := loadOrCreateDeviceID(filepath.Join(dir, "device-id")) + assert.NoError(t, err) + entries, err := os.ReadDir(dir) + assert.NoError(t, err) + names := []string{} + for _, e := range entries { + names = append(names, e.Name()) + } + assert.Equal(t, []string{"device-id"}, names) +} diff --git a/internal/devicemanager/backend_test.go b/internal/devicemanager/backend_test.go index 9a23ca5..9f6c750 100644 --- a/internal/devicemanager/backend_test.go +++ b/internal/devicemanager/backend_test.go @@ -18,6 +18,8 @@ import ( "github.com/utilitywarehouse/wiresteward/internal/healthcheck" ) +const testDeviceID = "0b9e1c7a-4d52-4f0e-9a64-8d1f3c2b5e70" + type fakeBackend struct { name string publicKey string @@ -85,14 +87,16 @@ func TestDeviceManager_renewLeaseWithBackend(t *testing.T) { defer srv.Close() b := &fakeBackend{name: "dev", publicKey: "backend-public-key"} - dm := NewWithBackend(b, []string{srv.URL}, config.Duration{Duration: time.Second}, config.AgentHealthCheckConfig{}, "test") + dm := NewWithBackend(b, []string{srv.URL}, config.Duration{Duration: time.Second}, config.AgentHealthCheckConfig{}, testDeviceID, "test") assert.NoError(t, dm.Run()) dm.SetCachedToken(testToken(t)) // The key comes from the backend and the lease is applied through it, // without touching the system (netlink or wgctrl). assert.NoError(t, dm.renewLease()) - assert.Equal(t, "backend-public-key", (<-requests).PubKey) + req := <-requests + assert.Equal(t, "backend-public-key", req.PubKey) + assert.Equal(t, testDeviceID, req.DeviceID) assert.Len(t, b.configured, 1) assert.Nil(t, b.olds[0]) assert.Equal(t, "10.90.0.3/32", b.configured[0].LocalAddress.String()) @@ -116,7 +120,7 @@ func TestDeviceManager_healthCheckUsesBackendExchanger(t *testing.T) { Threshold: 3, } // A second server enables health checking. - dm := NewWithBackend(b, []string{srv.URL, srv.URL}, config.Duration{Duration: time.Second}, hcc, "test") + dm := NewWithBackend(b, []string{srv.URL, srv.URL}, config.Duration{Duration: time.Second}, hcc, testDeviceID, "test") dm.SetCachedToken(testToken(t)) assert.NoError(t, dm.renewLease()) defer dm.healthCheck.Stop() diff --git a/internal/devicemanager/devicemanager.go b/internal/devicemanager/devicemanager.go index 468098d..dfa4dd8 100644 --- a/internal/devicemanager/devicemanager.go +++ b/internal/devicemanager/devicemanager.go @@ -44,6 +44,7 @@ type Backend interface { type DeviceManager struct { device.Device backend Backend // optional, replaces the default device setup + deviceID string // identifies this installation to the servers, can be empty userAgent string cachedToken string // cache the token on every renew lease request in case we need to use it on a renewal triggered by healthchecks tokenMutex sync.RWMutex @@ -62,7 +63,7 @@ type DeviceManager struct { httpClientTimeout config.Duration } -func New(deviceName string, mtu int, wirestewardURLs []string, httpClientTimeout config.Duration, hcc config.AgentHealthCheckConfig, deviceType, userAgent string) *DeviceManager { +func New(deviceName string, mtu int, wirestewardURLs []string, httpClientTimeout config.Duration, hcc config.AgentHealthCheckConfig, deviceType, deviceID, userAgent string) *DeviceManager { var dev device.Device if deviceType == "wireguard" { dev = device.NewWireguardDevice(deviceName, mtu) @@ -71,6 +72,7 @@ func New(deviceName string, mtu int, wirestewardURLs []string, httpClientTimeout } return &DeviceManager{ Device: dev, + deviceID: deviceID, userAgent: userAgent, serverURLs: wirestewardURLs, backoff: backoff.New(1*time.Second, 64*time.Second, 2), @@ -84,8 +86,8 @@ func New(deviceName string, mtu int, wirestewardURLs []string, httpClientTimeout } // NewWithBackend is like New but the device is set up through the backend. -func NewWithBackend(backend Backend, wirestewardURLs []string, httpClientTimeout config.Duration, hcc config.AgentHealthCheckConfig, userAgent string) *DeviceManager { - dm := New(backend.Name(), 0, wirestewardURLs, httpClientTimeout, hcc, "", userAgent) +func NewWithBackend(backend Backend, wirestewardURLs []string, httpClientTimeout config.Duration, hcc config.AgentHealthCheckConfig, deviceID, userAgent string) *DeviceManager { + dm := New(backend.Name(), 0, wirestewardURLs, httpClientTimeout, hcc, "", deviceID, userAgent) dm.Device = backend dm.backend = backend return dm @@ -259,7 +261,7 @@ func (dm *DeviceManager) renewLease() error { if serverURL == "" { return fmt.Errorf("No healthy servers found for device: %s", dm.Name()) } - config, wgServerAddr, err := requestWirestewardPeerConfig(serverURL, token, publicKey, dm.userAgent, dm.httpClientTimeout) + config, wgServerAddr, err := requestWirestewardPeerConfig(serverURL, token, publicKey, dm.deviceID, dm.userAgent, dm.httpClientTimeout) if err != nil { // Clear current server so the next retry picks a random one. dm.currentServerURL = "" @@ -396,9 +398,9 @@ func newWirestewardPeerConfigFromLeaseResponse(lr *api.LeaseResponse) (*Wirestew }, lr.ServerWireguardIP, nil } -func requestWirestewardPeerConfig(serverURL, token, publicKey, userAgent string, timeout config.Duration) (*WirestewardPeerConfig, string, error) { +func requestWirestewardPeerConfig(serverURL, token, publicKey, deviceID, userAgent string, timeout config.Duration) (*WirestewardPeerConfig, string, error) { // Marshal key into json - r, err := json.Marshal(&api.LeaseRequest{PubKey: publicKey}) + r, err := json.Marshal(&api.LeaseRequest{PubKey: publicKey, DeviceID: deviceID}) if err != nil { return nil, "", fmt.Errorf("requestWirestewardPeerConfig(%s): marshal request: %w", serverURL, err) }