Skip to content

Commit a04de46

Browse files
committed
tailscale: Add runtime exit node API
Expose the currently engaged exit node on TailscaleEndpointStatus and a StableID on each TailscalePeer, and add SetTailscaleExitNode through the daemon gRPC + libbox so clients can switch exit nodes at runtime by peer StableNodeID.
1 parent e7891c9 commit a04de46

9 files changed

Lines changed: 328 additions & 77 deletions

File tree

adapter/tailscale.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import "context"
55
type TailscaleEndpoint interface {
66
SubscribeTailscaleStatus(ctx context.Context, fn func(*TailscaleEndpointStatus)) error
77
StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error
8+
SetTailscaleExitNode(ctx context.Context, stableID string) error
89
}
910

1011
type TailscalePingResult struct {
@@ -22,6 +23,7 @@ type TailscaleEndpointStatus struct {
2223
NetworkName string
2324
MagicDNSSuffix string
2425
Self *TailscalePeer
26+
ExitNode *TailscalePeer
2527
UserGroups []*TailscaleUserGroup
2628
}
2729

@@ -34,6 +36,7 @@ type TailscaleUserGroup struct {
3436
}
3537

3638
type TailscalePeer struct {
39+
StableID string
3740
HostName string
3841
DNSName string
3942
OS string

daemon/started_service.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,11 +1384,15 @@ func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStat
13841384
if s.Self != nil {
13851385
result.Self = tailscalePeerToProto(s.Self)
13861386
}
1387+
if s.ExitNode != nil {
1388+
result.ExitNode = tailscalePeerToProto(s.ExitNode)
1389+
}
13871390
return result
13881391
}
13891392

13901393
func tailscalePeerToProto(peer *adapter.TailscalePeer) *TailscalePeer {
13911394
return &TailscalePeer{
1395+
StableID: peer.StableID,
13921396
HostName: peer.HostName,
13931397
DnsName: peer.DNSName,
13941398
Os: peer.OS,
@@ -1462,6 +1466,40 @@ func (s *StartedService) StartTailscalePing(
14621466
})
14631467
}
14641468

1469+
func (s *StartedService) SetTailscaleExitNode(ctx context.Context, request *SetTailscaleExitNodeRequest) (*emptypb.Empty, error) {
1470+
err := s.waitForStarted(ctx)
1471+
if err != nil {
1472+
return nil, err
1473+
}
1474+
s.serviceAccess.RLock()
1475+
boxService := s.instance
1476+
s.serviceAccess.RUnlock()
1477+
1478+
endpointManager := service.FromContext[adapter.EndpointManager](boxService.ctx)
1479+
if endpointManager == nil {
1480+
return nil, status.Error(codes.FailedPrecondition, "endpoint manager not available")
1481+
}
1482+
if request.EndpointTag == "" {
1483+
return nil, status.Error(codes.InvalidArgument, "endpoint tag is required")
1484+
}
1485+
endpoint, loaded := endpointManager.Get(request.EndpointTag)
1486+
if !loaded {
1487+
return nil, status.Error(codes.NotFound, "endpoint not found: "+request.EndpointTag)
1488+
}
1489+
if endpoint.Type() != C.TypeTailscale {
1490+
return nil, status.Error(codes.InvalidArgument, "endpoint is not Tailscale: "+request.EndpointTag)
1491+
}
1492+
tsEndpoint, loaded := endpoint.(adapter.TailscaleEndpoint)
1493+
if !loaded {
1494+
return nil, status.Error(codes.FailedPrecondition, "endpoint does not support tailscale")
1495+
}
1496+
err = tsEndpoint.SetTailscaleExitNode(ctx, request.StableID)
1497+
if err != nil {
1498+
return nil, err
1499+
}
1500+
return &emptypb.Empty{}, nil
1501+
}
1502+
14651503
func (s *StartedService) mustEmbedUnimplementedStartedServiceServer() {
14661504
}
14671505

daemon/started_service.pb.go

Lines changed: 155 additions & 77 deletions
Large diffs are not rendered by default.

daemon/started_service.proto

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ service StartedService {
4040
rpc StartSTUNTest(STUNTestRequest) returns (stream STUNTestProgress) {}
4141
rpc SubscribeTailscaleStatus(google.protobuf.Empty) returns (stream TailscaleStatusUpdate) {}
4242
rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {}
43+
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
4344
}
4445

4546
message ServiceStatus {
@@ -292,6 +293,7 @@ message TailscaleEndpointStatus {
292293
string magicDNSSuffix = 5;
293294
TailscalePeer self = 6;
294295
repeated TailscaleUserGroup userGroups = 7;
296+
TailscalePeer exitNode = 8;
295297
}
296298

297299
message TailscaleUserGroup {
@@ -314,6 +316,7 @@ message TailscalePeer {
314316
int64 rxBytes = 9;
315317
int64 txBytes = 10;
316318
int64 keyExpiry = 11;
319+
string stableID = 12;
317320
}
318321

319322
message TailscalePingRequest {
@@ -329,3 +332,8 @@ message TailscalePingResponse {
329332
string derpRegionCode = 5;
330333
string error = 6;
331334
}
335+
336+
message SetTailscaleExitNodeRequest {
337+
string endpointTag = 1;
338+
string stableID = 2;
339+
}

daemon/started_service_grpc.pb.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const (
4343
StartedService_StartSTUNTest_FullMethodName = "/daemon.StartedService/StartSTUNTest"
4444
StartedService_SubscribeTailscaleStatus_FullMethodName = "/daemon.StartedService/SubscribeTailscaleStatus"
4545
StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing"
46+
StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode"
4647
)
4748

4849
// StartedServiceClient is the client API for StartedService service.
@@ -77,6 +78,7 @@ type StartedServiceClient interface {
7778
StartSTUNTest(ctx context.Context, in *STUNTestRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[STUNTestProgress], error)
7879
SubscribeTailscaleStatus(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscaleStatusUpdate], error)
7980
StartTailscalePing(ctx context.Context, in *TailscalePingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscalePingResponse], error)
81+
SetTailscaleExitNode(ctx context.Context, in *SetTailscaleExitNodeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
8082
}
8183

8284
type startedServiceClient struct {
@@ -466,6 +468,16 @@ func (c *startedServiceClient) StartTailscalePing(ctx context.Context, in *Tails
466468
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
467469
type StartedService_StartTailscalePingClient = grpc.ServerStreamingClient[TailscalePingResponse]
468470

471+
func (c *startedServiceClient) SetTailscaleExitNode(ctx context.Context, in *SetTailscaleExitNodeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
472+
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
473+
out := new(emptypb.Empty)
474+
err := c.cc.Invoke(ctx, StartedService_SetTailscaleExitNode_FullMethodName, in, out, cOpts...)
475+
if err != nil {
476+
return nil, err
477+
}
478+
return out, nil
479+
}
480+
469481
// StartedServiceServer is the server API for StartedService service.
470482
// All implementations must embed UnimplementedStartedServiceServer
471483
// for forward compatibility.
@@ -498,6 +510,7 @@ type StartedServiceServer interface {
498510
StartSTUNTest(*STUNTestRequest, grpc.ServerStreamingServer[STUNTestProgress]) error
499511
SubscribeTailscaleStatus(*emptypb.Empty, grpc.ServerStreamingServer[TailscaleStatusUpdate]) error
500512
StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error
513+
SetTailscaleExitNode(context.Context, *SetTailscaleExitNodeRequest) (*emptypb.Empty, error)
501514
mustEmbedUnimplementedStartedServiceServer()
502515
}
503516

@@ -619,6 +632,10 @@ func (UnimplementedStartedServiceServer) SubscribeTailscaleStatus(*emptypb.Empty
619632
func (UnimplementedStartedServiceServer) StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error {
620633
return status.Error(codes.Unimplemented, "method StartTailscalePing not implemented")
621634
}
635+
636+
func (UnimplementedStartedServiceServer) SetTailscaleExitNode(context.Context, *SetTailscaleExitNodeRequest) (*emptypb.Empty, error) {
637+
return nil, status.Error(codes.Unimplemented, "method SetTailscaleExitNode not implemented")
638+
}
622639
func (UnimplementedStartedServiceServer) mustEmbedUnimplementedStartedServiceServer() {}
623640
func (UnimplementedStartedServiceServer) testEmbeddedByValue() {}
624641

@@ -1067,6 +1084,24 @@ func _StartedService_StartTailscalePing_Handler(srv interface{}, stream grpc.Ser
10671084
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
10681085
type StartedService_StartTailscalePingServer = grpc.ServerStreamingServer[TailscalePingResponse]
10691086

1087+
func _StartedService_SetTailscaleExitNode_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
1088+
in := new(SetTailscaleExitNodeRequest)
1089+
if err := dec(in); err != nil {
1090+
return nil, err
1091+
}
1092+
if interceptor == nil {
1093+
return srv.(StartedServiceServer).SetTailscaleExitNode(ctx, in)
1094+
}
1095+
info := &grpc.UnaryServerInfo{
1096+
Server: srv,
1097+
FullMethod: StartedService_SetTailscaleExitNode_FullMethodName,
1098+
}
1099+
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
1100+
return srv.(StartedServiceServer).SetTailscaleExitNode(ctx, req.(*SetTailscaleExitNodeRequest))
1101+
}
1102+
return interceptor(ctx, in, info, handler)
1103+
}
1104+
10701105
// StartedService_ServiceDesc is the grpc.ServiceDesc for StartedService service.
10711106
// It's only intended for direct use with grpc.RegisterService,
10721107
// and not to be introspected or modified (even as a copy)
@@ -1142,6 +1177,10 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{
11421177
MethodName: "GetStartedAt",
11431178
Handler: _StartedService_GetStartedAt_Handler,
11441179
},
1180+
{
1181+
MethodName: "SetTailscaleExitNode",
1182+
Handler: _StartedService_SetTailscaleExitNode_Handler,
1183+
},
11451184
},
11461185
Streams: []grpc.StreamDesc{
11471186
{

experimental/libbox/command_client.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -780,6 +780,19 @@ func (c *CommandClient) SubscribeTailscaleStatus(handler TailscaleStatusHandler)
780780
}
781781
}
782782

783+
func (c *CommandClient) SetTailscaleExitNode(endpointTag string, stableID string) error {
784+
_, err := callWithResult(c, func(client daemon.StartedServiceClient) (*emptypb.Empty, error) {
785+
return client.SetTailscaleExitNode(context.Background(), &daemon.SetTailscaleExitNodeRequest{
786+
EndpointTag: endpointTag,
787+
StableID: stableID,
788+
})
789+
})
790+
if err != nil {
791+
return E.Cause(err, "set tailscale exit node")
792+
}
793+
return nil
794+
}
795+
783796
func (c *CommandClient) StartTailscalePing(endpointTag string, peerIP string, handler TailscalePingHandler) error {
784797
client, err := c.getClientForCall()
785798
if err != nil {

experimental/libbox/command_types_tailscale.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type TailscaleEndpointStatus struct {
2222
NetworkName string
2323
MagicDNSSuffix string
2424
Self *TailscalePeer
25+
ExitNode *TailscalePeer
2526
userGroups []*TailscaleUserGroup
2627
}
2728

@@ -52,6 +53,7 @@ type TailscalePeerIterator interface {
5253
}
5354

5455
type TailscalePeer struct {
56+
StableID string
5557
HostName string
5658
DNSName string
5759
OS string
@@ -98,6 +100,9 @@ func tailscaleEndpointStatusFromGRPC(status *daemon.TailscaleEndpointStatus) *Ta
98100
if status.Self != nil {
99101
result.Self = tailscalePeerFromGRPC(status.Self)
100102
}
103+
if status.ExitNode != nil {
104+
result.ExitNode = tailscalePeerFromGRPC(status.ExitNode)
105+
}
101106
return result
102107
}
103108

@@ -117,6 +122,7 @@ func tailscaleUserGroupFromGRPC(group *daemon.TailscaleUserGroup) *TailscaleUser
117122

118123
func tailscalePeerFromGRPC(peer *daemon.TailscalePeer) *TailscalePeer {
119124
return &TailscalePeer{
125+
StableID: peer.StableID,
120126
HostName: peer.HostName,
121127
DNSName: peer.DnsName,
122128
OS: peer.Os,

protocol/tailscale/endpoint.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import (
5252
"github.com/sagernet/tailscale/net/netns"
5353
"github.com/sagernet/tailscale/net/tsaddr"
5454
tsTUN "github.com/sagernet/tailscale/net/tstun"
55+
"github.com/sagernet/tailscale/tailcfg"
5556
"github.com/sagernet/tailscale/tsnet"
5657
"github.com/sagernet/tailscale/types/ipproto"
5758
"github.com/sagernet/tailscale/types/nettype"
@@ -484,6 +485,49 @@ func (t *Endpoint) watchState() {
484485
}
485486
}
486487

488+
func (t *Endpoint) SetTailscaleExitNode(ctx context.Context, stableID string) error {
489+
if !t.started.Load() {
490+
return E.New("Tailscale is not ready yet")
491+
}
492+
if t.advertiseExitNode && stableID != "" {
493+
return E.New("cannot advertise an exit node and use an exit node at the same time")
494+
}
495+
perfs := &ipn.MaskedPrefs{
496+
Prefs: ipn.Prefs{
497+
ExitNodeID: tailcfg.StableNodeID(stableID),
498+
ExitNodeAllowLANAccess: t.exitNodeAllowLANAccess,
499+
},
500+
ExitNodeIDSet: true,
501+
ExitNodeIPSet: true,
502+
ExitNodeAllowLANAccessSet: true,
503+
}
504+
if stableID != "" {
505+
status, err := common.Must1(t.server.LocalClient()).Status(ctx)
506+
if err != nil {
507+
return E.Cause(err, "get tailscale status")
508+
}
509+
found := false
510+
for _, peer := range status.Peer {
511+
if peer.ID != tailcfg.StableNodeID(stableID) {
512+
continue
513+
}
514+
if !peer.ExitNodeOption {
515+
return E.New("peer does not offer exit node: ", stableID)
516+
}
517+
found = true
518+
break
519+
}
520+
if !found {
521+
return E.New("peer not found: ", stableID)
522+
}
523+
}
524+
_, err := t.server.ExportLocalBackend().EditPrefs(perfs)
525+
if err != nil {
526+
return E.Cause(err, "update prefs")
527+
}
528+
return nil
529+
}
530+
487531
func (t *Endpoint) Close() error {
488532
var err error
489533
t.started.Store(false)

protocol/tailscale/status.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,27 @@ func convertTailscaleStatus(status *ipnstate.Status) *adapter.TailscaleEndpointS
7676
return 0
7777
})
7878
}
79+
if status.ExitNodeStatus != nil {
80+
for _, peerKey := range status.Peers() {
81+
peer := status.Peer[peerKey]
82+
if peer.ID == status.ExitNodeStatus.ID {
83+
result.ExitNode = convertTailscalePeer(peer)
84+
break
85+
}
86+
}
87+
if result.ExitNode == nil {
88+
ips := make([]string, 0, len(status.ExitNodeStatus.TailscaleIPs))
89+
for _, prefix := range status.ExitNodeStatus.TailscaleIPs {
90+
ips = append(ips, prefix.Addr().String())
91+
}
92+
result.ExitNode = &adapter.TailscalePeer{
93+
StableID: string(status.ExitNodeStatus.ID),
94+
TailscaleIPs: ips,
95+
Online: status.ExitNodeStatus.Online,
96+
ExitNode: true,
97+
}
98+
}
99+
}
79100
return result
80101
}
81102

@@ -89,6 +110,7 @@ func convertTailscalePeer(peer *ipnstate.PeerStatus) *adapter.TailscalePeer {
89110
keyExpiry = peer.KeyExpiry.Unix()
90111
}
91112
return &adapter.TailscalePeer{
113+
StableID: string(peer.ID),
92114
HostName: peer.HostName,
93115
DNSName: peer.DNSName,
94116
OS: peer.OS,

0 commit comments

Comments
 (0)