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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,29 @@ Note that Machine B must be able to send UDP packets to port 50227 on Machine A,

All outbound network traffic seen by `vprox0` will automatically be forwarded through the WireGuard tunnel. The VPN server masquerades the source IP address.

### Multiple passwords

Both `vprox server` and `vprox relay` accept a comma-separated `VPROX_PASSWORDS`
list, so clients can use different passwords or migrate during a password rotation:

```bash
VPROX_PASSWORDS='old-password,new-password' vprox server \
--ip 172.31.64.125 --wg-block 240.1.0.0/16 --region us-west

# Each client still uses one accepted password.
VPROX_PASSWORD=new-password vprox connect 1.2.3.4 --interface vprox0
```

`VPROX_PASSWORD` continues to work as a literal single password, including any
commas or whitespace. If both variables are set on a server, it accepts the
passwords from both. In `VPROX_PASSWORDS`, surrounding whitespace is trimmed
from each entry and empty entries are rejected. Passwords containing commas
or leading/trailing whitespace should use `VPROX_PASSWORD`.

Restart the server or relay after changing its passwords. To retire a password,
remove it from both variables. Existing WireGuard tunnels remain active;
relay restarts drain connections as described in the [relay documentation](docs/RELAY.md).

### Building

To build `vprox`, run the following command with Go 1.22+ installed:
Expand Down
8 changes: 5 additions & 3 deletions cmd/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ var RelayCmd = &cobra.Command{
Long: `Start the egress relay. A macOS host's egress proxy opens one TLS 1.3
session per VM, authenticates with VPROX_PASSWORD, and multiplexes that VM's
TCP and UDP flows over it. The relay dials each destination from the static
IP the session was granted, after checking it against the destination policy.`,
IP the session was granted, after checking it against the destination policy.
Set VPROX_PASSWORD for a single password, or VPROX_PASSWORDS for a comma-separated
list. If both are set, all passwords are accepted.`,
RunE: runRelay,
}

Expand Down Expand Up @@ -110,7 +112,7 @@ func runRelay(cmd *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("--deny-cidr: %v", err)
}
password, err := lib.GetVproxPassword()
passwords, err := lib.GetVproxPasswords()
if err != nil {
return err
}
Expand All @@ -130,7 +132,7 @@ func runRelay(cmd *cobra.Command, args []string) error {
})
srv, err := relay.NewServer(relay.Config{
TLS: &tls.Config{Certificates: []tls.Certificate{cert}},
Password: password,
Passwords: passwords,
AllowSources: allow,
Policy: policy,
Metrics: relay.NewMetrics(reg),
Expand Down
9 changes: 6 additions & 3 deletions cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ const awsPollDuration = 5 * time.Second // AWS is polled this frequently for new
var ServerCmd = &cobra.Command{
Use: "server",
Short: "Start a VPN server, listening for new WireGuard peers",
RunE: runServer,
Long: `Start a VPN server, listening for new WireGuard peers.
Set VPROX_PASSWORD for a single password, or VPROX_PASSWORDS for a comma-separated
list. If both are set, all passwords are accepted.`,
RunE: runServer,
}

var serverCmdArgs struct {
Expand Down Expand Up @@ -101,14 +104,14 @@ func runServer(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to load server key: %v", err)
}

password, err := lib.GetVproxPassword()
passwords, err := lib.GetVproxPasswords()
if err != nil {
return err
}

ctx, done := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)

sm, err := lib.NewServerManager(wgBlock, wgBlockPerIp, ctx, key, password, serverCmdArgs.region, serverCmdArgs.internalNetworkCidr)
sm, err := lib.NewServerManager(wgBlock, wgBlockPerIp, ctx, key, passwords[0], serverCmdArgs.region, serverCmdArgs.internalNetworkCidr, passwords[1:]...)
if err != nil {
done()
return err
Expand Down
7 changes: 7 additions & 0 deletions docs/RELAY.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ VPROX_PASSWORD=... vprox relay \
--metrics-addr 127.0.0.1:9444
```

To accept multiple passwords, use `VPROX_PASSWORDS='old-password,new-password'`.
If `VPROX_PASSWORD` is also set, that password is accepted too. Each host proxy
continues to use one password; the relay replies with a proof of the password
that authenticated that connection. The wire protocol is unchanged.
See [multiple passwords](../README.md#multiple-passwords) for parsing and
rotation details.

* `--allow-source` is required; connections from other sources are dropped
before the TLS handshake. Use the same CIDRs as the `/connect-ipip` UFW rule.
* The TLS certificate defaults to the embedded vprox certificate. The host
Expand Down
24 changes: 24 additions & 0 deletions lib/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package lib
import (
"errors"
"os"
"strings"
)

func GetVproxPassword() (string, error) {
Expand All @@ -12,3 +13,26 @@ func GetVproxPassword() (string, error) {
}
return password, nil
}

// GetVproxPasswords returns the passwords accepted by a server or relay.
// VPROX_PASSWORD remains a literal single password; VPROX_PASSWORDS adds
// comma-separated passwords with surrounding whitespace removed.
func GetVproxPasswords() ([]string, error) {
var passwords []string
if password := os.Getenv("VPROX_PASSWORD"); password != "" {
passwords = append(passwords, password)
}
if list := os.Getenv("VPROX_PASSWORDS"); list != "" {
for _, password := range strings.Split(list, ",") {
password = strings.TrimSpace(password)
if password == "" {
return nil, errors.New("VPROX_PASSWORDS must not contain empty passwords")
}
passwords = append(passwords, password)
}
}
if len(passwords) == 0 {
return nil, errors.New("VPROX_PASSWORD or VPROX_PASSWORDS environment variable must be set")
}
return passwords, nil
}
58 changes: 58 additions & 0 deletions lib/env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package lib

import (
"testing"

"github.com/stretchr/testify/assert"
)

func TestGetVproxPasswords(t *testing.T) {
tests := []struct {
name string
password string
passwords string
want []string
wantErr string
}{
{name: "missing", wantErr: "VPROX_PASSWORD or VPROX_PASSWORDS environment variable must be set"},
{name: "legacy", password: "old", want: []string{"old"}},
{name: "literal legacy", password: " old,password ", want: []string{" old,password "}},
{name: "single entry", passwords: "new", want: []string{"new"}},
{name: "multiple", passwords: "first,second,third", want: []string{"first", "second", "third"}},
{name: "both", password: "old", passwords: "new,next", want: []string{"old", "new", "next"}},
{name: "trim entries", passwords: " first ,\tsecond\n,third with spaces ", want: []string{"first", "second", "third with spaces"}},
{name: "duplicates", password: "same", passwords: "same,same", want: []string{"same", "same", "same"}},
{name: "blank", passwords: " \t\n", wantErr: "VPROX_PASSWORDS must not contain empty passwords"},
{name: "leading comma", passwords: ",new", wantErr: "VPROX_PASSWORDS must not contain empty passwords"},
{name: "trailing comma", passwords: "new,", wantErr: "VPROX_PASSWORDS must not contain empty passwords"},
{name: "empty entry", passwords: "first,,second", wantErr: "VPROX_PASSWORDS must not contain empty passwords"},
{name: "blank entry", passwords: "first, ,second", wantErr: "VPROX_PASSWORDS must not contain empty passwords"},
{name: "invalid list with legacy", password: "old", passwords: "new,", wantErr: "VPROX_PASSWORDS must not contain empty passwords"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("VPROX_PASSWORD", tt.password)
t.Setenv("VPROX_PASSWORDS", tt.passwords)
got, err := GetVproxPasswords()
if tt.wantErr != "" {
assert.EqualError(t, err, tt.wantErr)
assert.Nil(t, got)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}

func TestGetVproxPasswordRemainsSingle(t *testing.T) {
t.Setenv("VPROX_PASSWORDS", "first,second")
t.Setenv("VPROX_PASSWORD", " literal,password ")
got, err := GetVproxPassword()
assert.NoError(t, err)
assert.Equal(t, " literal,password ", got)

t.Setenv("VPROX_PASSWORD", "")
_, err = GetVproxPassword()
assert.EqualError(t, err, "VPROX_PASSWORD environment variable is not set")
}
34 changes: 29 additions & 5 deletions lib/relay/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ func DefaultLimits() Limits {
// Config configures a Server.
type Config struct {
TLS *tls.Config
// Password is the shared secret (VPROX_PASSWORD) both ends prove over
// the TLS channel in the hello; it is never sent on the wire.
// Password is a single shared secret accepted in addition to Passwords.
Password string
// Passwords are the shared secrets accepted in the hello. Both ends prove
// the matching password over the TLS channel; it is never sent on the wire.
// Empty passwords are invalid.
Passwords []string
// AllowSources restricts which peers may connect; empty allows none.
AllowSources []netip.Prefix
Policy *Policy
Expand Down Expand Up @@ -93,9 +96,21 @@ func NewServer(cfg Config) (*Server, error) {
if cfg.TLS == nil {
return nil, errors.New("relay: TLS config required")
}
if cfg.Password == "" {
// Copy the list so callers cannot change the accepted passwords after
// construction by modifying their config slice.
passwords := append([]string(nil), cfg.Passwords...)
if cfg.Password != "" {
passwords = append(passwords, cfg.Password)
}
if len(passwords) == 0 {
return nil, errors.New("relay: password required")
}
for _, password := range passwords {
if password == "" {
return nil, errors.New("relay: empty password")
}
}
cfg.Passwords = passwords
if cfg.Policy == nil {
cfg.Policy = NewPolicy(PolicyConfig{})
}
Expand Down Expand Up @@ -288,7 +303,16 @@ func (s *Server) handleConn(raw net.Conn, src netip.Addr) {
s.refuseHello(tc, src, relayproto.HelloProtocolError, "channel binding unavailable")
return
}
if !relayproto.VerifyProof(hello.Auth, relayproto.ClientProof(s.cfg.Password, binding)) {
// Find a matching password and use it for the server proof.
// This keeps clients that know only one accepted password compatible.
var password string
for _, candidate := range s.cfg.Passwords {
if relayproto.VerifyProof(hello.Auth, relayproto.ClientProof(candidate, binding)) {
password = candidate
break
}
}
if password == "" {
s.refuseHello(tc, src, relayproto.HelloUnauthorized, "bad credential")
return
}
Expand All @@ -313,7 +337,7 @@ func (s *Server) handleConn(raw net.Conn, src netip.Addr) {

if err := relayproto.WriteHelloReply(tc, relayproto.HelloOK, relayproto.HelloReplyBody{
EgressIP: staticIP.String(),
Proof: relayproto.ServerProof(s.cfg.Password, binding),
Proof: relayproto.ServerProof(password, binding),
}); err != nil {
return
}
Expand Down
75 changes: 75 additions & 0 deletions lib/relay/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,81 @@ func udpEcho(t *testing.T) netip.AddrPort {
return netip.MustParseAddrPort(uc.LocalAddr().String())
}

func TestHelloMultiplePasswords(t *testing.T) {
t.Parallel()
for _, legacy := range []string{"", "legacy"} {
name := "list only"
if legacy != "" {
name = "list and legacy"
}
t.Run(name, func(t *testing.T) {
r := startRelay(t, func(c *Config) {
c.Password = legacy
c.Passwords = []string{"first", "second", "third"}
})
passwords := []string{"first", "second", "third"}
if legacy != "" {
passwords = append(passwords, legacy)
}
for _, password := range passwords {
// dialHello also verifies that the server proves knowledge of
// this password, including when it is not first in the list.
tc, status, reply := dialHello(t, r.addr, password, relayproto.HelloBody{StaticIP: "127.0.0.1"})
_ = tc.Close()
if status != relayproto.HelloOK || reply.EgressIP != "127.0.0.1" {
t.Fatalf("accepted password %q: status %d, reply %+v", password, status, reply)
}
}
for _, password := range []string{"", "wrong", "first,second,third"} {
tc, status, _ := dialHello(t, r.addr, password, relayproto.HelloBody{StaticIP: "127.0.0.1"})
_ = tc.Close()
if status != relayproto.HelloUnauthorized {
t.Fatalf("invalid password %q: status %d", password, status)
}
}

tc, binding := dialTLS(t, r.addr)
_ = tc.Close()
tc, status, _ := dialHello(t, r.addr, "second", relayproto.HelloBody{
Auth: relayproto.ClientProof("second", binding),
StaticIP: "127.0.0.1",
})
_ = tc.Close()
if status != relayproto.HelloUnauthorized {
t.Fatalf("replayed proof: status %d", status)
}
})
}
}

func TestNewServerPasswords(t *testing.T) {
t.Parallel()
for _, tt := range []struct {
name string
password string
passwords []string
wantErr string
}{
{name: "missing", wantErr: "relay: password required"},
{name: "empty list", passwords: []string{}, wantErr: "relay: password required"},
{name: "empty password", passwords: []string{""}, wantErr: "relay: empty password"},
{name: "empty with valid", passwords: []string{"valid", ""}, wantErr: "relay: empty password"},
{name: "empty with legacy", password: "legacy", passwords: []string{""}, wantErr: "relay: empty password"},
} {
t.Run(tt.name, func(t *testing.T) {
_, err := NewServer(Config{
TLS: testTLS(t),
Password: tt.password,
Passwords: tt.passwords,
Metrics: NewMetrics(prometheus.NewRegistry()),
})
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("NewServer error = %v, want %q", err, tt.wantErr)
}
})
}
}

func TestHelloRefusals(t *testing.T) {
t.Parallel()
r := startRelay(t, nil)
Expand Down
21 changes: 18 additions & 3 deletions lib/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,13 @@ type Server struct {
// InternalNetworkCidr is the CIDR block of the internal network.
InternalNetworkCidr string

// Password is needed to authenticate connection requests.
// Password is a single password accepted in addition to Passwords.
Password string

// Passwords are the passwords accepted for connection requests.
// Empty passwords are never accepted.
Passwords []string

// Index is a unique server index for firewall marks and other uses. It starts at 0.
Index uint16

Expand All @@ -93,6 +97,9 @@ type Server struct {

ipAllocator *IpAllocator

// authTokens is built by InitState and read-only while serving requests.
authTokens map[string]struct{}

mu sync.Mutex // Protects newPeers and peerIPs.
// newPeers records the time of each peer's most recent /connect, granting
// a grace period during which the idle reaper won't remove the peer. This
Expand Down Expand Up @@ -137,6 +144,15 @@ func (srv *Server) InitState() error {

srv.newPeers = make(map[wgtypes.Key]time.Time)
srv.peerIPs = make(map[wgtypes.Key]netip.Addr)
srv.authTokens = make(map[string]struct{}, len(srv.Passwords)+1)
if srv.Password != "" {
srv.authTokens["Bearer "+srv.Password] = struct{}{}
}
for _, password := range srv.Passwords {
if password != "" {
srv.authTokens["Bearer "+password] = struct{}{}
}
}
srv.initReadiness()
return nil
}
Expand Down Expand Up @@ -165,8 +181,7 @@ func (srv *Server) connectHandler(w http.ResponseWriter, r *http.Request) {
return
}

auth := r.Header.Get("Authorization")
if auth != "Bearer "+srv.Password {
if _, ok := srv.authTokens[r.Header.Get("Authorization")]; !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
Expand Down
Loading
Loading