Add Tailscale certificate API commands

This commit is contained in:
世界
2026-08-30 17:41:47 +08:00
parent 4658dfc6c6
commit 2a38f30125
10 changed files with 797 additions and 396 deletions
+3
View File
@@ -3,6 +3,7 @@ package adapter
import (
"context"
"io"
"time"
)
type TailscaleEndpoint interface {
@@ -10,6 +11,7 @@ type TailscaleEndpoint interface {
StartTailscalePing(ctx context.Context, peerIP string, fn func(*TailscalePingResult)) error
SetTailscaleExitNode(ctx context.Context, stableID string) error
Logout(ctx context.Context) error
GetTailscaleCertificate(ctx context.Context, domain string, minValidity time.Duration) (certificatePEM []byte, privateKeyPEM []byte, err error)
SubscribeTaildropInbox(ctx context.Context, fn func(*TaildropInbox)) error
MarkTaildropInboxRead() error
SendTaildropFile(ctx context.Context, peerStableID string, fileName string, size int64, content io.Reader, progress func(sentBytes int64)) error
@@ -61,6 +63,7 @@ type TailscaleEndpointStatus struct {
WaitingFileCount int32
ReceivingFileCount int32
UnreadFileCount int32
CertDomains []string
}
type TailscaleUserGroup struct {
@@ -0,0 +1,15 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscaleCertificate = &cobra.Command{
Use: "certificate",
Short: "Manage Tailscale HTTPS certificates",
}
func init() {
commandAPITailscaleCertificate.PersistentFlags().StringVar(&commandAPITailscaleFlagEndpoint, "endpoint", "", commandAPITailscaleEndpointUsage)
commandAPITailscale.AddCommand(commandAPITailscaleCertificate)
}
@@ -0,0 +1,108 @@
package main
import (
"os"
"slices"
"strings"
"time"
"github.com/sagernet/sing-box/daemon"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"github.com/spf13/cobra"
)
var (
commandAPITailscaleCertificateExportFlagCertificateFile string
commandAPITailscaleCertificateExportFlagKeyFile string
commandAPITailscaleCertificateExportFlagMinValidity time.Duration
)
var commandAPITailscaleCertificateExport = &cobra.Command{
Use: "export <domain>",
Short: "Export the HTTPS certificate and private key for a domain",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleCertificateExport(args[0])
},
}
func init() {
commandAPITailscaleCertificateExport.Flags().StringVar(&commandAPITailscaleCertificateExportFlagCertificateFile, "cert-file", "", "Output certificate file (default: <domain>.crt, \"-\" for stdout)")
commandAPITailscaleCertificateExport.Flags().StringVar(&commandAPITailscaleCertificateExportFlagKeyFile, "key-file", "", "Output private key file (default: <domain>.key, \"-\" for stdout)")
commandAPITailscaleCertificateExport.Flags().DurationVar(&commandAPITailscaleCertificateExportFlagMinValidity, "min-validity", 0, "Renew the certificate if it expires within this duration")
commandAPITailscaleCertificate.AddCommand(commandAPITailscaleCertificateExport)
}
func runAPITailscaleCertificateExport(domain string) error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
certDomains := endpoint.GetCertDomains()
if len(certDomains) == 0 {
return E.New("no certificate domains, enable HTTPS in the Tailscale admin console")
}
if !slices.Contains(certDomains, domain) {
return E.New("unknown certificate domain: ", domain, "\nknown domains:\n", formatTailscaleCertificateDomains(certDomains))
}
certificateFile := commandAPITailscaleCertificateExportFlagCertificateFile
if certificateFile == "" {
certificateFile = domain + ".crt"
}
keyFile := commandAPITailscaleCertificateExportFlagKeyFile
if keyFile == "" {
keyFile = domain + ".key"
}
writeStderrLine("fetching certificate for " + domain)
certificate, err := client.GetTailscaleCertificate(globalCtx, &daemon.TailscaleCertificateRequest{
EndpointTag: endpoint.GetEndpointTag(),
Domain: domain,
MinValiditySeconds: int64(commandAPITailscaleCertificateExportFlagMinValidity / time.Second),
})
if err != nil {
return err
}
if certificateFile == "-" && keyFile == "-" {
_, err = os.Stdout.Write(certificate.GetCertificatePEM())
if err != nil {
return err
}
_, err = os.Stdout.Write(certificate.GetPrivateKeyPEM())
return err
}
err = writeTailscaleCertificateFile(certificateFile, certificate.GetCertificatePEM(), 0o644)
if err != nil {
return E.Cause(err, "write certificate")
}
err = writeTailscaleCertificateFile(keyFile, certificate.GetPrivateKeyPEM(), 0o600)
if err != nil {
return E.Cause(err, "write private key")
}
return nil
}
func writeTailscaleCertificateFile(path string, content []byte, mode os.FileMode) error {
if path == "-" {
_, err := os.Stdout.Write(content)
return err
}
err := os.WriteFile(path, content, mode)
if err != nil {
return err
}
writeStderrLine("wrote " + path)
return nil
}
func formatTailscaleCertificateDomains(domains []string) string {
return strings.Join(common.Map(domains, func(it string) string {
return " " + it
}), "\n")
}
@@ -0,0 +1,39 @@
package main
import (
"github.com/spf13/cobra"
)
var commandAPITailscaleCertificateList = &cobra.Command{
Use: "list",
Short: "List domains that certificates can be issued for",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runAPITailscaleCertificateList()
},
}
func init() {
commandAPITailscaleCertificate.AddCommand(commandAPITailscaleCertificateList)
}
func runAPITailscaleCertificateList() error {
clientConn, client, err := createAPIClient()
if err != nil {
return err
}
defer clientConn.Close()
endpoint, err := fetchTailscaleEndpoint(client)
if err != nil {
return err
}
table := tableWriter{
header: []string{"DOMAIN"},
emptyMessage: "no certificate domains, enable HTTPS in the Tailscale admin console",
}
for _, domain := range endpoint.GetCertDomains() {
table.addRow(domain)
}
table.flush()
return nil
}
+28
View File
@@ -1667,6 +1667,7 @@ func tailscaleEndpointStatusToProto(tag string, s *adapter.TailscaleEndpointStat
WaitingFileCount: s.WaitingFileCount,
ReceivingFileCount: s.ReceivingFileCount,
UnreadFileCount: s.UnreadFileCount,
CertDomains: s.CertDomains,
}
if s.Self != nil {
result.Self = tailscalePeerToProto(s.Self)
@@ -1777,6 +1778,33 @@ func (s *StartedService) TailscaleLogout(ctx context.Context, request *Tailscale
return &emptypb.Empty{}, nil
}
func (s *StartedService) GetTailscaleCertificate(ctx context.Context, request *TailscaleCertificateRequest) (*TailscaleCertificate, error) {
err := s.waitForStarted(ctx)
if err != nil {
return nil, err
}
s.serviceAccess.RLock()
boxService := s.instance
s.serviceAccess.RUnlock()
endpoint, err := resolveTailscaleEndpoint(boxService, request.EndpointTag)
if err != nil {
return nil, err
}
tsEndpoint, loaded := endpoint.(adapter.TailscaleEndpoint)
if !loaded {
return nil, status.Error(codes.FailedPrecondition, "endpoint does not support tailscale")
}
certificatePEM, privateKeyPEM, err := tsEndpoint.GetTailscaleCertificate(ctx, request.Domain, time.Duration(request.MinValiditySeconds)*time.Second)
if err != nil {
return nil, err
}
return &TailscaleCertificate{
CertificatePEM: certificatePEM,
PrivateKeyPEM: privateKeyPEM,
}, nil
}
func (s *StartedService) SubscribeOpenConnectStatus(
_ *emptypb.Empty,
server grpc.ServerStreamingServer[OpenConnectStatusUpdate],
File diff suppressed because it is too large Load Diff
+13
View File
@@ -35,6 +35,7 @@ service StartedService {
rpc StartTailscalePing(TailscalePingRequest) returns (stream TailscalePingResponse) {}
rpc SetTailscaleExitNode(SetTailscaleExitNodeRequest) returns (google.protobuf.Empty) {}
rpc TailscaleLogout(TailscaleLogoutRequest) returns (google.protobuf.Empty) {}
rpc GetTailscaleCertificate(TailscaleCertificateRequest) returns (TailscaleCertificate) {}
rpc StartTailscaleSSHSession(stream TailscaleSSHClientMessage) returns (stream TailscaleSSHServerMessage) {}
rpc SubscribeTaildropInbox(SubscribeTaildropInboxRequest) returns (stream TaildropInbox) {}
rpc MarkTaildropInboxRead(MarkTaildropInboxReadRequest) returns (google.protobuf.Empty) {}
@@ -293,6 +294,7 @@ message TailscaleEndpointStatus {
int32 waitingFileCount = 12;
int32 receivingFileCount = 13;
int32 unreadFileCount = 14;
repeated string certDomains = 15;
}
message TailscaleUserGroup {
@@ -347,6 +349,17 @@ message TailscaleLogoutRequest {
string endpointTag = 1;
}
message TailscaleCertificateRequest {
string endpointTag = 1;
string domain = 2;
int64 minValiditySeconds = 3;
}
message TailscaleCertificate {
bytes certificatePEM = 1;
bytes privateKeyPEM = 2;
}
message TailscaleSSHClientMessage {
oneof message {
TailscaleSSHStart start = 1;
+39
View File
@@ -40,6 +40,7 @@ const (
StartedService_StartTailscalePing_FullMethodName = "/daemon.StartedService/StartTailscalePing"
StartedService_SetTailscaleExitNode_FullMethodName = "/daemon.StartedService/SetTailscaleExitNode"
StartedService_TailscaleLogout_FullMethodName = "/daemon.StartedService/TailscaleLogout"
StartedService_GetTailscaleCertificate_FullMethodName = "/daemon.StartedService/GetTailscaleCertificate"
StartedService_StartTailscaleSSHSession_FullMethodName = "/daemon.StartedService/StartTailscaleSSHSession"
StartedService_SubscribeTaildropInbox_FullMethodName = "/daemon.StartedService/SubscribeTaildropInbox"
StartedService_MarkTaildropInboxRead_FullMethodName = "/daemon.StartedService/MarkTaildropInboxRead"
@@ -87,6 +88,7 @@ type StartedServiceClient interface {
StartTailscalePing(ctx context.Context, in *TailscalePingRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TailscalePingResponse], error)
SetTailscaleExitNode(ctx context.Context, in *SetTailscaleExitNodeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
TailscaleLogout(ctx context.Context, in *TailscaleLogoutRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
GetTailscaleCertificate(ctx context.Context, in *TailscaleCertificateRequest, opts ...grpc.CallOption) (*TailscaleCertificate, error)
StartTailscaleSSHSession(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TailscaleSSHClientMessage, TailscaleSSHServerMessage], error)
SubscribeTaildropInbox(ctx context.Context, in *SubscribeTaildropInboxRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TaildropInbox], error)
MarkTaildropInboxRead(ctx context.Context, in *MarkTaildropInboxReadRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
@@ -462,6 +464,16 @@ func (c *startedServiceClient) TailscaleLogout(ctx context.Context, in *Tailscal
return out, nil
}
func (c *startedServiceClient) GetTailscaleCertificate(ctx context.Context, in *TailscaleCertificateRequest, opts ...grpc.CallOption) (*TailscaleCertificate, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(TailscaleCertificate)
err := c.cc.Invoke(ctx, StartedService_GetTailscaleCertificate_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *startedServiceClient) StartTailscaleSSHSession(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[TailscaleSSHClientMessage, TailscaleSSHServerMessage], error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
stream, err := c.cc.NewStream(ctx, &StartedService_ServiceDesc.Streams[11], StartedService_StartTailscaleSSHSession_FullMethodName, cOpts...)
@@ -714,6 +726,7 @@ type StartedServiceServer interface {
StartTailscalePing(*TailscalePingRequest, grpc.ServerStreamingServer[TailscalePingResponse]) error
SetTailscaleExitNode(context.Context, *SetTailscaleExitNodeRequest) (*emptypb.Empty, error)
TailscaleLogout(context.Context, *TailscaleLogoutRequest) (*emptypb.Empty, error)
GetTailscaleCertificate(context.Context, *TailscaleCertificateRequest) (*TailscaleCertificate, error)
StartTailscaleSSHSession(grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]) error
SubscribeTaildropInbox(*SubscribeTaildropInboxRequest, grpc.ServerStreamingServer[TaildropInbox]) error
MarkTaildropInboxRead(context.Context, *MarkTaildropInboxReadRequest) (*emptypb.Empty, error)
@@ -840,6 +853,10 @@ func (UnimplementedStartedServiceServer) TailscaleLogout(context.Context, *Tails
return nil, status.Error(codes.Unimplemented, "method TailscaleLogout not implemented")
}
func (UnimplementedStartedServiceServer) GetTailscaleCertificate(context.Context, *TailscaleCertificateRequest) (*TailscaleCertificate, error) {
return nil, status.Error(codes.Unimplemented, "method GetTailscaleCertificate not implemented")
}
func (UnimplementedStartedServiceServer) StartTailscaleSSHSession(grpc.BidiStreamingServer[TailscaleSSHClientMessage, TailscaleSSHServerMessage]) error {
return status.Error(codes.Unimplemented, "method StartTailscaleSSHSession not implemented")
}
@@ -1297,6 +1314,24 @@ func _StartedService_TailscaleLogout_Handler(srv interface{}, ctx context.Contex
return interceptor(ctx, in, info, handler)
}
func _StartedService_GetTailscaleCertificate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(TailscaleCertificateRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(StartedServiceServer).GetTailscaleCertificate(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: StartedService_GetTailscaleCertificate_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(StartedServiceServer).GetTailscaleCertificate(ctx, req.(*TailscaleCertificateRequest))
}
return interceptor(ctx, in, info, handler)
}
func _StartedService_StartTailscaleSSHSession_Handler(srv interface{}, stream grpc.ServerStream) error {
return srv.(StartedServiceServer).StartTailscaleSSHSession(&grpc.GenericServerStream[TailscaleSSHClientMessage, TailscaleSSHServerMessage]{ServerStream: stream})
}
@@ -1573,6 +1608,10 @@ var StartedService_ServiceDesc = grpc.ServiceDesc{
MethodName: "TailscaleLogout",
Handler: _StartedService_TailscaleLogout_Handler,
},
{
MethodName: "GetTailscaleCertificate",
Handler: _StartedService_GetTailscaleCertificate_Handler,
},
{
MethodName: "MarkTaildropInboxRead",
Handler: _StartedService_MarkTaildropInboxRead_Handler,
+22
View File
@@ -0,0 +1,22 @@
//go:build with_gvisor
package tailscale
import (
"context"
"time"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
)
func (t *Endpoint) GetTailscaleCertificate(ctx context.Context, domain string, minValidity time.Duration) ([]byte, []byte, error) {
if !t.started.Load() {
return nil, nil, E.New("Tailscale is not ready yet")
}
certificatePEM, privateKeyPEM, err := common.Must1(t.server.LocalClient()).CertPairWithValidity(ctx, domain, minValidity)
if err != nil {
return nil, nil, E.Cause(err, "tailscale certificate")
}
return certificatePEM, privateKeyPEM, nil
}
+1
View File
@@ -42,6 +42,7 @@ func (t *Endpoint) SubscribeTailscaleStatus(ctx context.Context, fn func(*adapte
result.WaitingFileCount = t.taildrop.waitingFileCount()
result.ReceivingFileCount = t.taildrop.receivingFileCount()
result.UnreadFileCount = t.taildrop.unreadFileCount()
result.CertDomains = t.server.CertDomains()
if len(taildropTargets) > 0 {
for _, group := range result.UserGroups {
for _, peer := range group.Peers {