Skip to content

Commit 7d1cb7a

Browse files
committed
tailmix: add raw WireGuard profiles
Add versioned YAML profiles backed by one long-lived userspace WireGuard engine per profile. Apply peer, key, endpoint, route, and exit-node changes through UAPI without replacing the device or TUN.\n\nIntegrate raw peers with effective addressing, DNS, subnet-route policy, and exit-node selection. Persist managed keys separately with private permissions, and expose apply and show through the control API and CLI.\n\nTest real encrypted multi-peer traffic, live removal and rekeying, deterministic churn, secret persistence, and public API contracts.\n\nRELNOTE: Add declarative raw WireGuard profiles.\n\nChange-Id: I7b1dc3cfdade465b95dc237463446bab7de0db85
1 parent b46f75f commit 7d1cb7a

27 files changed

Lines changed: 3151 additions & 61 deletions

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ configured when the exit node is changed or cleared.
189189

190190
- [Linux service setup and troubleshooting](docs/linux-install.md)
191191
- [Profile, route, exit-node, and DNS command reference](docs/profile-management.md)
192+
- [Raw WireGuard profiles](docs/wireguard.md)
192193
- [Architecture](docs/architecture.md)
193194
- [Development workflows](DEVELOPMENT.md)
194195

‎cmd/tailmix/completion.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,13 @@ func completionCommandTree() *completionCommand {
402402
name: "dns", description: "Manage DNS routing and search domains", help: true,
403403
subcommands: []*completionCommand{dnsRoutes, dnsSearch},
404404
}
405+
wireguard := &completionCommand{
406+
name: "wireguard", description: "Apply and inspect raw WireGuard profiles", help: true,
407+
subcommands: []*completionCommand{
408+
leaf("apply", "Apply a declarative WireGuard profile", []completionFlag{flag("Read the YAML manifest", completionFile, "--file"), jsonFlag()}),
409+
leaf("show", "Show one WireGuard profile", []completionFlag{jsonFlag()}, completionProfile),
410+
},
411+
}
405412

406413
completion := &completionCommand{
407414
name: "completion", description: "Generate shell completion scripts", help: true,
@@ -434,6 +441,7 @@ func completionCommandTree() *completionCommand {
434441
routes,
435442
exitNode,
436443
dns,
444+
wireguard,
437445
tailscaleCompletionCommand("tailscale", "Run an upstream Tailscale command for one profile", profileFlag()),
438446
tailscaleCompletionCommand("ts", "Shortcut for tailscale", profileFlag()),
439447
completion,

‎cmd/tailmix/main.go‎

Lines changed: 144 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/netip"
1111
"os"
1212
"os/signal"
13+
"path/filepath"
1314
"strconv"
1415
"strings"
1516
"syscall"
@@ -19,6 +20,7 @@ import (
1920
"github.com/maisem/tailmix/exitnodeview"
2021
"github.com/maisem/tailmix/profilesocket"
2122
tailmixversion "github.com/maisem/tailmix/version"
23+
"github.com/maisem/tailmix/wireguardcfg"
2224
"tailscale.com/cmd/tailscale/cli"
2325
"tailscale.com/util/dnsname"
2426
)
@@ -36,6 +38,8 @@ type managementClient interface {
3638
PatchProfile(context.Context, string, controlapi.PatchProfileRequest) (controlapi.Profile, error)
3739
ProfileAction(context.Context, string, string) (controlapi.Profile, error)
3840
RemoveProfile(context.Context, string, bool) (controlapi.Profile, error)
41+
ApplyWireGuard(context.Context, wireguardcfg.Config, wireguardcfg.Secrets) (controlapi.WireGuardProfile, error)
42+
WireGuardProfile(context.Context, string) (controlapi.WireGuardProfile, error)
3943
IPRoutes(context.Context, bool) (controlapi.IPRoutes, error)
4044
PatchIPRoutes(context.Context, controlapi.PatchIPRoutesRequest) (controlapi.IPRoutes, error)
4145
ExitNodes(context.Context) (controlapi.ExitNodes, error)
@@ -113,6 +117,8 @@ func runWithDependencies(ctx context.Context, args []string, deps dependencies)
113117
err = runExitNode(ctx, client, args[1:], deps)
114118
case "dns":
115119
err = runDNS(ctx, client, args[1:], deps)
120+
case "wireguard":
121+
err = runWireGuard(ctx, client, args[1:], deps)
116122
case "tailscale", "ts":
117123
err = runTailscale(ctx, client, socketDir, args[1:], deps)
118124
case "completion":
@@ -139,6 +145,96 @@ func runWithDependencies(ctx context.Context, args []string, deps dependencies)
139145
return 1
140146
}
141147

148+
func runWireGuard(ctx context.Context, client managementClient, args []string, deps dependencies) error {
149+
if len(args) == 0 || isHelp(args[0]) {
150+
fmt.Fprint(deps.stdout, wireGuardHelp)
151+
return nil
152+
}
153+
switch args[0] {
154+
case "apply":
155+
rest := args[1:]
156+
manifestPath, found, err := takeString(&rest, "--file")
157+
if err != nil {
158+
return err
159+
}
160+
if !found || manifestPath == "" {
161+
return usageError{"wireguard apply requires --file <path|->"}
162+
}
163+
jsonOutput, err := takeBool(&rest, "--json")
164+
if err != nil {
165+
return err
166+
}
167+
if err := noOperands(rest); err != nil {
168+
return err
169+
}
170+
config, secrets, err := parseWireGuardManifest(manifestPath, deps.stdin)
171+
if err != nil {
172+
return err
173+
}
174+
result, err := client.ApplyWireGuard(ctx, config, secrets)
175+
if err != nil {
176+
return err
177+
}
178+
if jsonOutput {
179+
return writeJSON(deps.stdout, result)
180+
}
181+
writeWireGuardProfile(deps.stdout, result)
182+
return nil
183+
184+
case "show":
185+
rest := args[1:]
186+
jsonOutput, err := takeBool(&rest, "--json")
187+
if err != nil {
188+
return err
189+
}
190+
name, err := oneOperand(rest, "WireGuard profile name")
191+
if err != nil {
192+
return err
193+
}
194+
result, err := client.WireGuardProfile(ctx, name)
195+
if err != nil {
196+
return err
197+
}
198+
if jsonOutput {
199+
return writeJSON(deps.stdout, result)
200+
}
201+
writeWireGuardProfile(deps.stdout, result)
202+
return nil
203+
default:
204+
return usageError{fmt.Sprintf("unknown wireguard command %q", args[0])}
205+
}
206+
}
207+
208+
func parseWireGuardManifest(manifestPath string, stdin io.Reader) (wireguardcfg.Config, wireguardcfg.Secrets, error) {
209+
var (
210+
data []byte
211+
err error
212+
base string
213+
)
214+
if manifestPath == "-" {
215+
data, err = io.ReadAll(stdin)
216+
if err == nil {
217+
base, err = os.Getwd()
218+
}
219+
} else {
220+
data, err = os.ReadFile(manifestPath)
221+
base = filepath.Dir(manifestPath)
222+
}
223+
if err != nil {
224+
return wireguardcfg.Config{}, wireguardcfg.Secrets{}, fmt.Errorf("read WireGuard manifest %q: %w", manifestPath, err)
225+
}
226+
config, secrets, err := wireguardcfg.Parse(data, func(path string) ([]byte, error) {
227+
if !filepath.IsAbs(path) {
228+
path = filepath.Join(base, path)
229+
}
230+
return os.ReadFile(path)
231+
})
232+
if err != nil {
233+
return wireguardcfg.Config{}, wireguardcfg.Secrets{}, fmt.Errorf("parse WireGuard manifest %q: %w", manifestPath, err)
234+
}
235+
return config, secrets, nil
236+
}
237+
142238
func runUpdate(ctx context.Context, client managementClient, args []string, deps dependencies) error {
143239
if len(args) == 0 || isHelp(args[0]) {
144240
fmt.Fprint(deps.stdout, updateHelp)
@@ -1092,10 +1188,10 @@ func writeJSON(w io.Writer, value any) error {
10921188

10931189
func writeProfiles(w io.Writer, profiles []controlapi.Profile) {
10941190
table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
1095-
fmt.Fprintln(table, "PROFILE\tENABLED\tRUNTIME\tTAILNET\tPEERS\tERROR")
1191+
fmt.Fprintln(table, "PROFILE\tKIND\tENABLED\tRUNTIME\tTAILNET\tPEERS\tERROR")
10961192
for _, profile := range profiles {
1097-
fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%d\t%s\n",
1098-
profile.Name, yesNo(profile.Enabled), profile.RuntimeState,
1193+
fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%d\t%s\n",
1194+
profile.Name, profile.Kind, yesNo(profile.Enabled), profile.RuntimeState,
10991195
profile.MagicDNSSuffix, profile.PeerCount, profile.LastError)
11001196
}
11011197
_ = table.Flush()
@@ -1145,6 +1241,7 @@ func writeProfile(w io.Writer, profile controlapi.Profile) {
11451241
table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
11461242
rows := [][2]string{
11471243
{"Profile", profile.Name},
1244+
{"Kind", profile.Kind},
11481245
{"ID", profile.ID},
11491246
{"Enabled", yesNo(profile.Enabled)},
11501247
{"Removed", yesNo(profile.Removed)},
@@ -1165,6 +1262,39 @@ func writeProfile(w io.Writer, profile controlapi.Profile) {
11651262
_ = table.Flush()
11661263
}
11671264

1265+
func writeWireGuardProfile(w io.Writer, profile controlapi.WireGuardProfile) {
1266+
table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
1267+
fmt.Fprintf(table, "PROFILE:\t%s\nKIND:\t%s\nPUBLIC KEY:\t%s\nLISTEN PORT:\t%d\nADDRESSES:\t%s\nDNS SUFFIX:\t%s\n",
1268+
profile.Name, profile.Kind, profile.PublicKey, profile.ListenPort,
1269+
joinStrings(profile.Addresses), profile.DNSSuffix)
1270+
if len(profile.Peers) > 0 {
1271+
fmt.Fprintln(table, "\nPEER\tPUBLIC KEY\tONLINE\tENDPOINT\tCANONICAL\tEFFECTIVE\tROUTES\tEXIT\tHANDSHAKE\tRX\tTX")
1272+
}
1273+
for _, peer := range profile.Peers {
1274+
handshake := ""
1275+
if !peer.LastHandshake.IsZero() {
1276+
handshake = peer.LastHandshake.Format("2006-01-02T15:04:05Z07:00")
1277+
}
1278+
exit := yesNo(peer.ExitNode)
1279+
if peer.ExitNodeSelected {
1280+
exit = "selected"
1281+
}
1282+
fmt.Fprintf(table, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%d\t%d\n",
1283+
peer.Name, peer.PublicKey, yesNo(peer.Online), peer.Endpoint, joinStrings(peer.Addresses),
1284+
joinStrings(peer.EffectiveAddresses), joinStrings(peer.Routes), exit,
1285+
handshake, peer.ReceiveBytes, peer.TransmitBytes)
1286+
}
1287+
_ = table.Flush()
1288+
}
1289+
1290+
func joinStrings[T fmt.Stringer](values []T) string {
1291+
items := make([]string, len(values))
1292+
for i, value := range values {
1293+
items[i] = value.String()
1294+
}
1295+
return strings.Join(items, ",")
1296+
}
1297+
11681298
func writeIPRoutes(w io.Writer, routes controlapi.IPRoutes, available bool) {
11691299
table := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
11701300
if available {
@@ -1381,6 +1511,7 @@ Commands:
13811511
exit-node Select one profile's exit node for default traffic
13821512
dns routes Route DNS suffixes through selected profiles
13831513
dns search Manage the ordered OS search-domain list
1514+
wireguard Apply and inspect raw WireGuard profiles
13841515
tailscale Run an upstream Tailscale command for one profile
13851516
ts Shortcut for tailscale
13861517
completion Generate shell completion scripts
@@ -1393,6 +1524,16 @@ Environment:
13931524
TAILMIX_SOCKET_DIR Default directory for daemon and profile sockets
13941525
`
13951526

1527+
const wireGuardHelp = `Usage:
1528+
tailmix wireguard apply --file <path|-> [--json]
1529+
tailmix wireguard show <profile> [--json]
1530+
1531+
Apply validates a declarative YAML profile and reconciles it live. Relative key
1532+
paths are resolved from the manifest directory, or from the current directory
1533+
when the manifest is read from standard input. Show never includes private or
1534+
preshared key material.
1535+
`
1536+
13961537
const versionHelp = `Usage:
13971538
tailmix version
13981539

0 commit comments

Comments
 (0)