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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions android/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ build/
local.properties
*.iml
.idea/
.kotlin/
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
77 changes: 61 additions & 16 deletions android/mobile/mobile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://<listenAddr>/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://<ListenAddr>/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 {
Expand All @@ -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)
}
Expand All @@ -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
Expand Down
51 changes: 51 additions & 0 deletions android/mobile/mobile_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 15 additions & 2 deletions cmd/wiresteward/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"os"
"os/signal"
"runtime"
"runtime/debug"
"strings"
"syscall"
Expand All @@ -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://<agent-listen-address>/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")
Expand All @@ -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()

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions cmd/wiresteward/main_test.go
Original file line number Diff line number Diff line change
@@ -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"))
}
28 changes: 18 additions & 10 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -50,35 +53,40 @@ 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 {
urls = append(urls, peer.URL)
}
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)
continue
}
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,
Expand All @@ -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.
Expand Down
Loading
Loading