初版功能完成
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//go:build windows
|
||||
|
||||
// Package windowsplatform centralizes RemLink-owned Windows networking changes.
|
||||
package windowsplatform
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.zx2c4.com/wireguard/tun"
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
|
||||
"remlink/internal/platform/windows/wintunruntime"
|
||||
)
|
||||
|
||||
const (
|
||||
AdapterName = "RemLink"
|
||||
AdapterTunnelType = "RemLink"
|
||||
DefaultMTU = 1280
|
||||
)
|
||||
|
||||
var ErrAdministratorRequired = errors.New("administrator privileges are required to manage the RemLink Wintun adapter")
|
||||
|
||||
// AdapterConfig defines the single IPv4 Overlay address owned by RemLink.
|
||||
type AdapterConfig struct {
|
||||
Address netip.Prefix
|
||||
MTU int
|
||||
}
|
||||
|
||||
// Adapter owns one live Wintun session. Closing it leaves the persistent
|
||||
// Windows adapter installed so a later RemLink process can reuse it.
|
||||
type Adapter struct {
|
||||
device tun.Device
|
||||
luid winipcfg.LUID
|
||||
interfaceIndex uint32
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
// OpenRemLink creates or reuses the one fixed-name RemLink Wintun, applies its
|
||||
// IPv4 address and MTU through Windows APIs, and returns the live TUN device.
|
||||
func OpenRemLink(config AdapterConfig) (*Adapter, error) {
|
||||
if err := validateAdapterConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !windows.GetCurrentProcessToken().IsElevated() {
|
||||
return nil, ErrAdministratorRequired
|
||||
}
|
||||
if _, err := wintunruntime.PreloadDefault(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tun.WintunTunnelType = AdapterTunnelType
|
||||
base, err := tun.CreateTUN(AdapterName, config.MTU)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create or reuse %s Wintun: %w", AdapterName, err)
|
||||
}
|
||||
succeeded := false
|
||||
defer func() {
|
||||
if !succeeded {
|
||||
_ = base.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
luidSource, ok := base.(interface{ LUID() uint64 })
|
||||
if !ok {
|
||||
return nil, errors.New("wireguard-go Windows TUN does not expose its interface LUID")
|
||||
}
|
||||
luid := winipcfg.LUID(luidSource.LUID())
|
||||
if luid == 0 {
|
||||
return nil, errors.New("RemLink Wintun returned an invalid interface LUID")
|
||||
}
|
||||
if err := luid.SetIPAddressesForFamily(
|
||||
winipcfg.AddressFamily(windows.AF_INET),
|
||||
[]netip.Prefix{config.Address},
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("set RemLink IPv4 address %s: %w", config.Address, err)
|
||||
}
|
||||
|
||||
ipInterface, err := luid.IPInterface(winipcfg.AddressFamily(windows.AF_INET))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read RemLink IPv4 interface: %w", err)
|
||||
}
|
||||
ipInterface.NLMTU = uint32(config.MTU)
|
||||
if err := ipInterface.Set(); err != nil {
|
||||
return nil, fmt.Errorf("set RemLink MTU %d: %w", config.MTU, err)
|
||||
}
|
||||
interfaceRow, err := luid.Interface()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read RemLink interface index: %w", err)
|
||||
}
|
||||
|
||||
succeeded = true
|
||||
return &Adapter{
|
||||
device: base,
|
||||
luid: luid,
|
||||
interfaceIndex: interfaceRow.InterfaceIndex,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Device returns the TUN device. Ownership transfers to wireguard-go when it is
|
||||
// passed to clientwg.NewDevice; callers must then close the wireguard-go owner.
|
||||
func (a *Adapter) Device() tun.Device {
|
||||
return a.device
|
||||
}
|
||||
|
||||
// LUID returns the Windows interface locally unique identifier.
|
||||
func (a *Adapter) LUID() uint64 {
|
||||
return uint64(a.luid)
|
||||
}
|
||||
|
||||
// InterfaceIndex returns the Windows interface index used by later RouteManager work.
|
||||
func (a *Adapter) InterfaceIndex() uint32 {
|
||||
return a.interfaceIndex
|
||||
}
|
||||
|
||||
// Close stops the Wintun session without deleting the persistent adapter.
|
||||
func (a *Adapter) Close() error {
|
||||
if a == nil || a.device == nil {
|
||||
return nil
|
||||
}
|
||||
a.closeOnce.Do(func() {
|
||||
a.closeErr = a.device.Close()
|
||||
})
|
||||
return a.closeErr
|
||||
}
|
||||
|
||||
func validateAdapterConfig(config AdapterConfig) error {
|
||||
if !config.Address.IsValid() || !config.Address.Addr().Is4() {
|
||||
return errors.New("RemLink adapter address must be a valid IPv4 prefix")
|
||||
}
|
||||
if config.Address.Bits() > 30 {
|
||||
return errors.New("RemLink adapter prefix must leave usable host addresses")
|
||||
}
|
||||
if config.Address.Addr() == config.Address.Masked().Addr() || config.Address.Addr() == lastAddress(config.Address) {
|
||||
return errors.New("RemLink adapter address must not be the network or broadcast address")
|
||||
}
|
||||
if config.MTU < 576 || config.MTU > 65535 {
|
||||
return errors.New("RemLink adapter MTU must be between 576 and 65535")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lastAddress(prefix netip.Prefix) netip.Addr {
|
||||
bytes := prefix.Masked().Addr().As4()
|
||||
value := uint32(bytes[0])<<24 | uint32(bytes[1])<<16 | uint32(bytes[2])<<8 | uint32(bytes[3])
|
||||
value |= ^uint32(0) >> prefix.Bits()
|
||||
return netip.AddrFrom4([4]byte{byte(value >> 24), byte(value >> 16), byte(value >> 8), byte(value)})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package windowsplatform
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateAdapterConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
address string
|
||||
mtu int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", address: "10.88.0.2/16", mtu: 1280},
|
||||
{name: "IPv6", address: "fd00::2/64", mtu: 1280, wantErr: true},
|
||||
{name: "network", address: "10.88.0.0/16", mtu: 1280, wantErr: true},
|
||||
{name: "broadcast", address: "10.88.255.255/16", mtu: 1280, wantErr: true},
|
||||
{name: "small MTU", address: "10.88.0.2/16", mtu: 575, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
prefix := netip.MustParsePrefix(test.address)
|
||||
err := validateAdapterConfig(AdapterConfig{Address: prefix, MTU: test.mtu})
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("validateAdapterConfig() error = %v, wantErr %v", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build !windows
|
||||
|
||||
package dpapi
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrWindowsRequired = errors.New("DPAPI requires Windows")
|
||||
|
||||
type Protector struct{}
|
||||
|
||||
func (Protector) Protect([]byte) ([]byte, error) { return nil, ErrWindowsRequired }
|
||||
func (Protector) Unprotect([]byte) ([]byte, error) { return nil, ErrWindowsRequired }
|
||||
@@ -0,0 +1,64 @@
|
||||
//go:build windows
|
||||
|
||||
// Package dpapi protects Windows Node secrets using the operating system DPAPI.
|
||||
package dpapi
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var entropy = []byte("RemLink-v1-NodeIdentity")
|
||||
|
||||
// Protector uses machine-scoped DPAPI so Console/service identities can read
|
||||
// the package-local identity after Windows restart. Directory ACLs remain required.
|
||||
type Protector struct{}
|
||||
|
||||
func (Protector) Protect(plain []byte) ([]byte, error) {
|
||||
if len(plain) == 0 {
|
||||
return nil, errors.New("DPAPI plaintext must not be empty")
|
||||
}
|
||||
input := blob(plain)
|
||||
extra := blob(entropy)
|
||||
var output windows.DataBlob
|
||||
name, err := windows.UTF16PtrFromString("RemLink Node Identity")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
flags := uint32(windows.CRYPTPROTECT_LOCAL_MACHINE | windows.CRYPTPROTECT_UI_FORBIDDEN)
|
||||
if err := windows.CryptProtectData(&input, name, &extra, 0, nil, flags, &output); err != nil {
|
||||
return nil, fmt.Errorf("protect secret with DPAPI: %w", err)
|
||||
}
|
||||
return copyAndFree(output)
|
||||
}
|
||||
|
||||
func (Protector) Unprotect(ciphertext []byte) ([]byte, error) {
|
||||
if len(ciphertext) == 0 {
|
||||
return nil, errors.New("DPAPI ciphertext must not be empty")
|
||||
}
|
||||
input := blob(ciphertext)
|
||||
extra := blob(entropy)
|
||||
var output windows.DataBlob
|
||||
flags := uint32(windows.CRYPTPROTECT_UI_FORBIDDEN)
|
||||
if err := windows.CryptUnprotectData(&input, nil, &extra, 0, nil, flags, &output); err != nil {
|
||||
return nil, fmt.Errorf("unprotect secret with DPAPI: %w", err)
|
||||
}
|
||||
return copyAndFree(output)
|
||||
}
|
||||
|
||||
func blob(value []byte) windows.DataBlob {
|
||||
return windows.DataBlob{Size: uint32(len(value)), Data: &value[0]}
|
||||
}
|
||||
|
||||
func copyAndFree(value windows.DataBlob) ([]byte, error) {
|
||||
if value.Data == nil || value.Size == 0 {
|
||||
return nil, errors.New("DPAPI returned empty output")
|
||||
}
|
||||
defer windows.LocalFree(windows.Handle(uintptr(unsafe.Pointer(value.Data))))
|
||||
result := make([]byte, int(value.Size))
|
||||
copy(result, unsafe.Slice(value.Data, int(value.Size)))
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
//go:build windows
|
||||
|
||||
package dpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProtectUnprotect(t *testing.T) {
|
||||
protector := Protector{}
|
||||
plain := []byte("RemLink test secret")
|
||||
protected, err := protector.Protect(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Equal(protected, plain) {
|
||||
t.Fatal("DPAPI returned plaintext")
|
||||
}
|
||||
unprotected, err := protector.Unprotect(protected)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(unprotected, plain) {
|
||||
t.Fatalf("unprotected = %q, want %q", unprotected, plain)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package netinfo inspects local IPv4 networks without mutating Windows state.
|
||||
package netinfo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
// DirectIPv4Prefixes returns assigned non-loopback IPv4 interface prefixes,
|
||||
// excluding the named RemLink adapter.
|
||||
func DirectIPv4Prefixes(excludedInterface string) ([]netip.Prefix, error) {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list network interfaces: %w", err)
|
||||
}
|
||||
var prefixes []netip.Prefix
|
||||
for _, networkInterface := range interfaces {
|
||||
if networkInterface.Name == excludedInterface || networkInterface.Flags&net.FlagLoopback != 0 || networkInterface.Flags&net.FlagUp == 0 {
|
||||
continue
|
||||
}
|
||||
addresses, err := networkInterface.Addrs()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list addresses for %s: %w", networkInterface.Name, err)
|
||||
}
|
||||
for _, address := range addresses {
|
||||
prefix, err := netip.ParsePrefix(address.String())
|
||||
if err != nil || !prefix.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
prefixes = append(prefixes, prefix.Masked())
|
||||
}
|
||||
}
|
||||
return prefixes, nil
|
||||
}
|
||||
|
||||
// PrefixesOverlap performs true containment-based IPv4 prefix overlap.
|
||||
func PrefixesOverlap(left, right netip.Prefix) bool {
|
||||
if !left.Addr().Is4() || !right.Addr().Is4() {
|
||||
return false
|
||||
}
|
||||
left = left.Masked()
|
||||
right = right.Masked()
|
||||
return left.Contains(right.Addr()) || right.Contains(left.Addr())
|
||||
}
|
||||
|
||||
// FindConflict returns the first local network overlapping desired.
|
||||
func FindConflict(desired netip.Prefix, existing []netip.Prefix) (netip.Prefix, bool) {
|
||||
for _, candidate := range existing {
|
||||
if PrefixesOverlap(desired, candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return netip.Prefix{}, false
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package netinfo
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrefixesOverlap(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
left, right string
|
||||
want bool
|
||||
}{
|
||||
{"192.168.0.0/16", "192.168.13.0/24", true},
|
||||
{"192.168.13.0/24", "192.168.13.10/32", true},
|
||||
{"10.88.0.0/16", "10.89.0.0/16", false},
|
||||
{"0.0.0.0/0", "10.88.0.0/16", true},
|
||||
} {
|
||||
got := PrefixesOverlap(netip.MustParsePrefix(test.left), netip.MustParsePrefix(test.right))
|
||||
if got != test.want {
|
||||
t.Errorf("PrefixesOverlap(%s, %s) = %v, want %v", test.left, test.right, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package route owns RemLink Remote routes and performs conflict/lookup checks.
|
||||
package route
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
|
||||
"remlink/internal/platform/windows/netinfo"
|
||||
)
|
||||
|
||||
type LookupResult string
|
||||
|
||||
const (
|
||||
LookupDirect LookupResult = "DIRECT"
|
||||
LookupRouted LookupResult = "ROUTED"
|
||||
LookupDefaultOnly LookupResult = "DEFAULT_ONLY"
|
||||
LookupNoRoute LookupResult = "NO_ROUTE"
|
||||
LookupOverlayConflict LookupResult = "OVERLAY_CONFLICT"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
InterfaceLUID uint64
|
||||
Destination netip.Prefix
|
||||
NextHop netip.Addr
|
||||
}
|
||||
|
||||
// ConflictsFrom applies the Engineer rule: ignore default and RemLink-owned
|
||||
// routes, reject any other prefix overlap.
|
||||
func ConflictsFrom(entries []Entry, desired netip.Prefix, remLinkLUID uint64) []Entry {
|
||||
var conflicts []Entry
|
||||
for _, entry := range entries {
|
||||
if entry.InterfaceLUID == remLinkLUID || !entry.Destination.Addr().Is4() || entry.Destination.Bits() == 0 {
|
||||
continue
|
||||
}
|
||||
if netinfo.PrefixesOverlap(desired, entry.Destination) {
|
||||
conflicts = append(conflicts, entry)
|
||||
}
|
||||
}
|
||||
return conflicts
|
||||
}
|
||||
|
||||
// LookupFrom returns the most-specific Windows route classification used by Site PREPARE.
|
||||
func LookupFrom(entries []Entry, target netip.Addr, overlay netip.Prefix, remLinkLUID uint64) LookupResult {
|
||||
if !target.Is4() {
|
||||
return LookupNoRoute
|
||||
}
|
||||
if overlay.Contains(target) {
|
||||
return LookupOverlayConflict
|
||||
}
|
||||
var best *Entry
|
||||
for index := range entries {
|
||||
entry := &entries[index]
|
||||
if !entry.Destination.Contains(target) || entry.InterfaceLUID == remLinkLUID {
|
||||
continue
|
||||
}
|
||||
if best == nil || entry.Destination.Bits() > best.Destination.Bits() {
|
||||
best = entry
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return LookupNoRoute
|
||||
}
|
||||
if best.Destination.Bits() == 0 {
|
||||
return LookupDefaultOnly
|
||||
}
|
||||
if !best.NextHop.IsValid() || best.NextHop.IsUnspecified() {
|
||||
return LookupDirect
|
||||
}
|
||||
return LookupRouted
|
||||
}
|
||||
|
||||
func validateRemote(prefix, overlay netip.Prefix) error {
|
||||
if !prefix.Addr().Is4() || prefix != prefix.Masked() || prefix.Bits() == 0 {
|
||||
return errors.New("Remote CIDR must be canonical IPv4 and must not be 0.0.0.0/0")
|
||||
}
|
||||
if netinfo.PrefixesOverlap(prefix, overlay) {
|
||||
return errors.New("Remote CIDR overlaps Overlay CIDR")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package route
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConflictsIgnoresDefaultAndRemLink(t *testing.T) {
|
||||
entries := []Entry{
|
||||
{InterfaceLUID: 1, Destination: netip.MustParsePrefix("0.0.0.0/0"), NextHop: netip.MustParseAddr("192.0.2.1")},
|
||||
{InterfaceLUID: 2, Destination: netip.MustParsePrefix("192.168.0.0/16")},
|
||||
{InterfaceLUID: 99, Destination: netip.MustParsePrefix("192.168.13.0/24")},
|
||||
}
|
||||
conflicts := ConflictsFrom(entries, netip.MustParsePrefix("192.168.13.0/24"), 99)
|
||||
if len(conflicts) != 1 || conflicts[0].Destination.String() != "192.168.0.0/16" {
|
||||
t.Fatalf("conflicts = %+v", conflicts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupClassifications(t *testing.T) {
|
||||
overlay := netip.MustParsePrefix("10.88.0.0/16")
|
||||
entries := []Entry{
|
||||
{InterfaceLUID: 1, Destination: netip.MustParsePrefix("0.0.0.0/0"), NextHop: netip.MustParseAddr("192.0.2.1")},
|
||||
{InterfaceLUID: 2, Destination: netip.MustParsePrefix("192.168.13.0/24"), NextHop: netip.IPv4Unspecified()},
|
||||
{InterfaceLUID: 3, Destination: netip.MustParsePrefix("172.16.0.0/16"), NextHop: netip.MustParseAddr("192.0.2.254")},
|
||||
}
|
||||
for _, test := range []struct {
|
||||
target string
|
||||
want LookupResult
|
||||
}{
|
||||
{"10.88.0.5", LookupOverlayConflict},
|
||||
{"192.168.13.10", LookupDirect},
|
||||
{"172.16.4.2", LookupRouted},
|
||||
{"8.8.8.8", LookupDefaultOnly},
|
||||
} {
|
||||
if got := LookupFrom(entries, netip.MustParseAddr(test.target), overlay, 99); got != test.want {
|
||||
t.Errorf("LookupFrom(%s) = %s, want %s", test.target, got, test.want)
|
||||
}
|
||||
}
|
||||
if got := LookupFrom(nil, netip.MustParseAddr("192.168.1.2"), overlay, 99); got != LookupNoRoute {
|
||||
t.Fatalf("empty route lookup = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRemote(t *testing.T) {
|
||||
overlay := netip.MustParsePrefix("10.88.0.0/16")
|
||||
for _, invalid := range []string{"0.0.0.0/0", "10.88.5.0/24"} {
|
||||
if err := validateRemote(netip.MustParsePrefix(invalid), overlay); err == nil {
|
||||
t.Errorf("validateRemote accepted %s", invalid)
|
||||
}
|
||||
}
|
||||
if err := validateRemote(netip.MustParsePrefix("192.168.13.0/24"), overlay); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//go:build !windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/netip"
|
||||
)
|
||||
|
||||
var ErrWindowsRequired = errors.New("Windows RouteManager requires Windows")
|
||||
|
||||
type OwnershipStore interface {
|
||||
LoadOwnedRoutes() ([]netip.Prefix, error)
|
||||
SaveOwnedRoutes([]netip.Prefix) error
|
||||
}
|
||||
|
||||
type Manager struct{}
|
||||
|
||||
func NewManager(uint64, netip.Prefix, OwnershipStore) (*Manager, error) {
|
||||
return nil, ErrWindowsRequired
|
||||
}
|
||||
func (*Manager) AddRemote(netip.Prefix) error { return ErrWindowsRequired }
|
||||
func (*Manager) RemoveRemote(netip.Prefix) error { return ErrWindowsRequired }
|
||||
func (*Manager) Conflicts(netip.Prefix) ([]Entry, error) { return nil, ErrWindowsRequired }
|
||||
func (*Manager) Lookup(netip.Addr) (LookupResult, error) { return LookupNoRoute, ErrWindowsRequired }
|
||||
func (*Manager) Reconcile() error { return ErrWindowsRequired }
|
||||
@@ -0,0 +1,151 @@
|
||||
//go:build windows
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.zx2c4.com/wireguard/windows/tunnel/winipcfg"
|
||||
)
|
||||
|
||||
const remoteRouteMetric = 0
|
||||
|
||||
type OwnershipStore interface {
|
||||
LoadOwnedRoutes() ([]netip.Prefix, error)
|
||||
SaveOwnedRoutes([]netip.Prefix) error
|
||||
}
|
||||
|
||||
// Manager is the sole writer for Engineer Remote CIDR routes.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
luid winipcfg.LUID
|
||||
overlay netip.Prefix
|
||||
store OwnershipStore
|
||||
owned map[netip.Prefix]struct{}
|
||||
}
|
||||
|
||||
func NewManager(luid uint64, overlay netip.Prefix, store OwnershipStore) (*Manager, error) {
|
||||
if luid == 0 || !overlay.Addr().Is4() || store == nil {
|
||||
return nil, errors.New("RouteManager requires RemLink LUID, IPv4 Overlay, and ownership store")
|
||||
}
|
||||
ownedRoutes, err := store.LoadOwnedRoutes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
owned := make(map[netip.Prefix]struct{}, len(ownedRoutes))
|
||||
for _, prefix := range ownedRoutes {
|
||||
owned[prefix.Masked()] = struct{}{}
|
||||
}
|
||||
return &Manager{luid: winipcfg.LUID(luid), overlay: overlay.Masked(), store: store, owned: owned}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) AddRemote(prefix netip.Prefix) error {
|
||||
prefix = prefix.Masked()
|
||||
if err := validateRemote(prefix, m.overlay); err != nil {
|
||||
return err
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.owned[prefix]; exists {
|
||||
return nil
|
||||
}
|
||||
conflicts, err := m.conflictsLocked(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(conflicts) != 0 {
|
||||
return fmt.Errorf("Remote CIDR %s conflicts with existing route %s", prefix, conflicts[0].Destination)
|
||||
}
|
||||
if err := m.luid.AddRoute(prefix, netip.IPv4Unspecified(), remoteRouteMetric); err != nil {
|
||||
return fmt.Errorf("add RemLink Remote route %s: %w", prefix, err)
|
||||
}
|
||||
m.owned[prefix] = struct{}{}
|
||||
if err := m.persistLocked(); err != nil {
|
||||
delete(m.owned, prefix)
|
||||
_ = m.luid.DeleteRoute(prefix, netip.IPv4Unspecified())
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) RemoveRemote(prefix netip.Prefix) error {
|
||||
prefix = prefix.Masked()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, exists := m.owned[prefix]; !exists {
|
||||
return nil
|
||||
}
|
||||
if err := m.luid.DeleteRoute(prefix, netip.IPv4Unspecified()); err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) {
|
||||
return fmt.Errorf("remove RemLink Remote route %s: %w", prefix, err)
|
||||
}
|
||||
delete(m.owned, prefix)
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) Conflicts(prefix netip.Prefix) ([]Entry, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.conflictsLocked(prefix.Masked())
|
||||
}
|
||||
|
||||
func (m *Manager) Lookup(target netip.Addr) (LookupResult, error) {
|
||||
entries, err := windowsEntries()
|
||||
if err != nil {
|
||||
return LookupNoRoute, err
|
||||
}
|
||||
return LookupFrom(entries, target, m.overlay, uint64(m.luid)), nil
|
||||
}
|
||||
|
||||
// Reconcile removes every route recorded by a previous non-Active Session.
|
||||
func (m *Manager) Reconcile() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for prefix := range m.owned {
|
||||
if err := m.luid.DeleteRoute(prefix, netip.IPv4Unspecified()); err != nil && !errors.Is(err, windows.ERROR_NOT_FOUND) {
|
||||
return fmt.Errorf("reconcile stale Remote route %s: %w", prefix, err)
|
||||
}
|
||||
delete(m.owned, prefix)
|
||||
}
|
||||
return m.persistLocked()
|
||||
}
|
||||
|
||||
func (m *Manager) conflictsLocked(prefix netip.Prefix) ([]Entry, error) {
|
||||
entries, err := windowsEntries()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ConflictsFrom(entries, prefix, uint64(m.luid)), nil
|
||||
}
|
||||
|
||||
func (m *Manager) persistLocked() error {
|
||||
prefixes := make([]netip.Prefix, 0, len(m.owned))
|
||||
for prefix := range m.owned {
|
||||
prefixes = append(prefixes, prefix)
|
||||
}
|
||||
if err := m.store.SaveOwnedRoutes(prefixes); err != nil {
|
||||
return fmt.Errorf("persist RemLink route ownership: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func windowsEntries() ([]Entry, error) {
|
||||
rows, err := winipcfg.GetIPForwardTable2(winipcfg.AddressFamily(windows.AF_INET))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read Windows IPv4 route table: %w", err)
|
||||
}
|
||||
entries := make([]Entry, 0, len(rows))
|
||||
for index := range rows {
|
||||
prefix := rows[index].DestinationPrefix.Prefix()
|
||||
if !prefix.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, Entry{
|
||||
InterfaceLUID: uint64(rows[index].InterfaceLUID), Destination: prefix.Masked(), NextHop: rows[index].NextHop.Addr(),
|
||||
})
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build windows && amd64
|
||||
|
||||
package wintunruntime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
_ "embed"
|
||||
"encoding/hex"
|
||||
)
|
||||
|
||||
//go:embed assets/amd64/wintun.dll
|
||||
var embeddedDLL []byte
|
||||
|
||||
func assetBytes() ([]byte, [sha256.Size]byte, error) {
|
||||
var expected [sha256.Size]byte
|
||||
decoded, err := hex.DecodeString(DLLSHA256AMD64)
|
||||
if err != nil {
|
||||
return nil, expected, err
|
||||
}
|
||||
copy(expected[:], decoded)
|
||||
return embeddedDLL, expected, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build windows && !amd64
|
||||
|
||||
package wintunruntime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func assetBytes() ([]byte, [sha256.Size]byte, error) {
|
||||
return nil, [sha256.Size]byte{}, fmt.Errorf("Phase 1 Wintun asset is not bundled for windows/%s", runtime.GOARCH)
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,193 @@
|
||||
//go:build windows
|
||||
|
||||
// Package wintunruntime installs and preloads the pinned embedded Wintun DLL.
|
||||
package wintunruntime
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.zx2c4.com/wintun"
|
||||
|
||||
"remlink/internal/appdir"
|
||||
)
|
||||
|
||||
const (
|
||||
Version = "0.14.1"
|
||||
DLLName = "wintun.dll"
|
||||
DLLSHA256AMD64 = "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce"
|
||||
)
|
||||
|
||||
var (
|
||||
preloadOnce sync.Once
|
||||
preloadPath string
|
||||
preloadErr error
|
||||
// Keep the module referenced for the process lifetime. The upstream Go
|
||||
// binding subsequently resolves the already-loaded DLL by base name.
|
||||
preloadHandle windows.Handle
|
||||
)
|
||||
|
||||
// Install writes the embedded signed DLL into one portable package directory.
|
||||
// An existing verified file is reused; an unexpected file is replaced.
|
||||
func Install(packageDirectory string) (string, error) {
|
||||
if packageDirectory == "" {
|
||||
return "", errors.New("package directory must not be empty")
|
||||
}
|
||||
data, expectedHash, err := assetBytes()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(packageDirectory, 0o750); err != nil {
|
||||
return "", fmt.Errorf("create Wintun runtime directory: %w", err)
|
||||
}
|
||||
target := filepath.Join(packageDirectory, DLLName)
|
||||
|
||||
verified, err := fileHasSHA256Stable(target, expectedHash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if verified {
|
||||
return target, nil
|
||||
}
|
||||
if err := writeAtomically(target, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
verified, err = fileHasSHA256Stable(target, expectedHash)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !verified {
|
||||
return "", errors.New("installed Wintun DLL failed SHA-256 verification")
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
// PreloadDefault installs and loads Wintun beside the running executable before
|
||||
// the upstream lazy binding attempts to resolve wintun.dll by its base name.
|
||||
func PreloadDefault() (string, error) {
|
||||
root, err := appdir.Executable()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return Preload(root)
|
||||
}
|
||||
|
||||
// Preload installs and loads Wintun exactly once for the process.
|
||||
func Preload(packageDirectory string) (string, error) {
|
||||
preloadOnce.Do(func() {
|
||||
preloadPath, preloadErr = Install(packageDirectory)
|
||||
if preloadErr != nil {
|
||||
return
|
||||
}
|
||||
preloadHandle, preloadErr = windows.LoadLibraryEx(
|
||||
preloadPath,
|
||||
0,
|
||||
windows.LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR|windows.LOAD_LIBRARY_SEARCH_SYSTEM32,
|
||||
)
|
||||
if preloadErr != nil {
|
||||
preloadErr = fmt.Errorf("preload Wintun DLL %q: %w", preloadPath, preloadErr)
|
||||
}
|
||||
})
|
||||
return preloadPath, preloadErr
|
||||
}
|
||||
|
||||
// Probe installs/preloads the DLL and asks the official binding for its version.
|
||||
func Probe() (path string, version string, err error) {
|
||||
path, err = PreloadDefault()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
version = wintun.Version()
|
||||
if version == "unknown" {
|
||||
return "", "", errors.New("official Wintun binding could not resolve the preloaded DLL")
|
||||
}
|
||||
return path, version, nil
|
||||
}
|
||||
|
||||
func fileHasSHA256(path string, expected [sha256.Size]byte) (bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read existing Wintun DLL: %w", err)
|
||||
}
|
||||
return sha256.Sum256(data) == expected, nil
|
||||
}
|
||||
|
||||
func fileHasSHA256Stable(path string, expected [sha256.Size]byte) (bool, error) {
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
verified, err := fileHasSHA256(path, expected)
|
||||
if err == nil {
|
||||
return verified, nil
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_ACCESS_DENIED) && !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
|
||||
return false, err
|
||||
}
|
||||
lastErr = err
|
||||
time.Sleep(time.Duration(attempt+1) * time.Millisecond)
|
||||
}
|
||||
return false, lastErr
|
||||
}
|
||||
|
||||
func writeAtomically(target string, data []byte) error {
|
||||
temporary, err := os.CreateTemp(filepath.Dir(target), ".wintun-*.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("create temporary Wintun DLL: %w", err)
|
||||
}
|
||||
temporaryPath := temporary.Name()
|
||||
defer os.Remove(temporaryPath)
|
||||
|
||||
if _, err := temporary.Write(data); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("write temporary Wintun DLL: %w", err)
|
||||
}
|
||||
if err := temporary.Sync(); err != nil {
|
||||
temporary.Close()
|
||||
return fmt.Errorf("sync temporary Wintun DLL: %w", err)
|
||||
}
|
||||
if err := temporary.Close(); err != nil {
|
||||
return fmt.Errorf("close temporary Wintun DLL: %w", err)
|
||||
}
|
||||
if err := os.Chmod(temporaryPath, 0o644); err != nil {
|
||||
return fmt.Errorf("set Wintun DLL permissions: %w", err)
|
||||
}
|
||||
|
||||
from, err := windows.UTF16PtrFromString(temporaryPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode temporary Wintun DLL path: %w", err)
|
||||
}
|
||||
to, err := windows.UTF16PtrFromString(target)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode Wintun DLL target path: %w", err)
|
||||
}
|
||||
// MoveFileExW publishes the verified bytes in one replace operation. This
|
||||
// avoids a remove/rename gap where another launch from the same package could
|
||||
// observe the DLL path as missing. Concurrent publishers
|
||||
// may briefly hold a Windows file handle; accept their verified result or
|
||||
// retry only the documented sharing/access failures.
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
if verified, verifyErr := fileHasSHA256(target, sha256.Sum256(data)); verifyErr == nil && verified {
|
||||
return nil
|
||||
}
|
||||
err = windows.MoveFileEx(from, to, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, windows.ERROR_ACCESS_DENIED) && !errors.Is(err, windows.ERROR_SHARING_VIOLATION) {
|
||||
return fmt.Errorf("install Wintun DLL: %w", err)
|
||||
}
|
||||
time.Sleep(time.Duration(attempt+1) * time.Millisecond)
|
||||
}
|
||||
if verified, verifyErr := fileHasSHA256(target, sha256.Sum256(data)); verifyErr == nil && verified {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("install Wintun DLL after concurrent publish retries: %w", err)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build windows && amd64
|
||||
|
||||
package wintunruntime
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallIsVerifiedIdempotentAndRepairsUnexpectedFile(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
path, err := Install(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Install() error = %v", err)
|
||||
}
|
||||
if got, want := path, filepath.Join(root, DLLName); got != want {
|
||||
t.Fatalf("Install() path = %q, want %q", got, want)
|
||||
}
|
||||
first, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error = %v", err)
|
||||
}
|
||||
|
||||
secondPath, err := Install(root)
|
||||
if err != nil {
|
||||
t.Fatalf("second Install() error = %v", err)
|
||||
}
|
||||
if secondPath != path {
|
||||
t.Fatalf("second Install() path = %q, want %q", secondPath, path)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, []byte("tampered"), 0o644); err != nil {
|
||||
t.Fatalf("tamper DLL: %v", err)
|
||||
}
|
||||
if _, err := Install(root); err != nil {
|
||||
t.Fatalf("repair Install() error = %v", err)
|
||||
}
|
||||
repaired, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read repaired DLL: %v", err)
|
||||
}
|
||||
if string(repaired) != string(first) {
|
||||
t.Fatal("Install() did not repair the unexpected DLL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPublishesAtomicallyAcrossConcurrentProcesses(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, DLLName)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("corrupt"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const workers = 16
|
||||
errorsByWorker := make([]error, workers)
|
||||
paths := make([]string, workers)
|
||||
var wait sync.WaitGroup
|
||||
for index := 0; index < workers; index++ {
|
||||
wait.Add(1)
|
||||
go func(index int) {
|
||||
defer wait.Done()
|
||||
paths[index], errorsByWorker[index] = Install(root)
|
||||
}(index)
|
||||
}
|
||||
wait.Wait()
|
||||
for index := range errorsByWorker {
|
||||
if errorsByWorker[index] != nil || paths[index] != target {
|
||||
t.Fatalf("worker %d path=%q error=%v", index, paths[index], errorsByWorker[index])
|
||||
}
|
||||
}
|
||||
data, expected, err := assetBytes()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installed, err := os.ReadFile(target)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(installed) != string(data) {
|
||||
t.Fatalf("concurrent install produced %d bytes, want %d (expected hash %x)", len(installed), len(data), expected)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user