@@ -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+
142238func 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
10931189func writeProfiles (w io.Writer , profiles []controlapi.Profile ) {
10941190 table := tabwriter .NewWriter (w , 0 , 4 , 2 , ' ' , 0 )
1095- fmt .Fprintln (table , "PROFILE\t ENABLED\t RUNTIME\t TAILNET\t PEERS\t ERROR" )
1191+ fmt .Fprintln (table , "PROFILE\t KIND \ t ENABLED\t RUNTIME\t TAILNET\t PEERS\t ERROR" )
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\n KIND:\t %s\n PUBLIC KEY:\t %s\n LISTEN PORT:\t %d\n ADDRESSES:\t %s\n DNS 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 , "\n PEER\t PUBLIC KEY\t ONLINE\t ENDPOINT\t CANONICAL\t EFFECTIVE\t ROUTES\t EXIT\t HANDSHAKE\t RX\t TX" )
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+
11681298func 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+
13961537const versionHelp = `Usage:
13971538 tailmix version
13981539
0 commit comments