From 306bacc669d4fe180c697f9e5995855e58fbe01f Mon Sep 17 00:00:00 2001 From: Will Morrison Date: Wed, 31 Jan 2024 22:15:12 +0100 Subject: [PATCH] cmd/tailscale/cli: Add CLI command to update certs on Synology devices. Fixes #4674 Signed-off-by: Will Morrison --- cmd/tailscale/cli/configure-synology-cert.go | 220 ++++++++++++++++++ .../cli/configure-synology-cert_test.go | 140 +++++++++++ cmd/tailscale/cli/configure.go | 1 + 3 files changed, 361 insertions(+) create mode 100644 cmd/tailscale/cli/configure-synology-cert.go create mode 100644 cmd/tailscale/cli/configure-synology-cert_test.go diff --git a/cmd/tailscale/cli/configure-synology-cert.go b/cmd/tailscale/cli/configure-synology-cert.go new file mode 100644 index 000000000..aabcb8dfa --- /dev/null +++ b/cmd/tailscale/cli/configure-synology-cert.go @@ -0,0 +1,220 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log" + "os" + "os/exec" + "path" + "runtime" + "strings" + + "github.com/peterbourgon/ff/v3/ffcli" + "tailscale.com/hostinfo" + "tailscale.com/ipn" + "tailscale.com/version/distro" +) + +var synologyConfigureCertCmd = &ffcli.Command{ + Name: "synology-cert", + Exec: runConfigureSynologyCert, + ShortHelp: "Configure Synology with a TLS certificate for your tailnet", + ShortUsage: "synology-cert [--domain ]", + LongHelp: strings.TrimSpace(` +This command is intended to run periodically as root on a Synology device to +create or refresh the TLS certificate for the tailnet domain. + +See: https://tailscale.com/kb/1153/enabling-https +`), + FlagSet: (func() *flag.FlagSet { + fs := newFlagSet("synology-cert") + fs.StringVar(&synologyConfigureCertArgs.domain, "domain", "", "Tailnet domain to create or refresh certificates for. Ignored if only one domain exists.") + return fs + })(), +} + +var synologyConfigureCertArgs struct { + domain string +} + +func runConfigureSynologyCert(ctx context.Context, args []string) error { + if len(args) > 0 { + return errors.New("unknown arguments") + } + if runtime.GOOS != "linux" || distro.Get() != distro.Synology { + return errors.New("only implemented on Synology") + } + if uid := os.Getuid(); uid != 0 { + return fmt.Errorf("must be run as root, not %q (%v)", os.Getenv("USER"), uid) + } + hi := hostinfo.New() + isDSM6 := strings.HasPrefix(hi.DistroVersion, "6.") + isDSM7 := strings.HasPrefix(hi.DistroVersion, "7.") + if !isDSM6 && !isDSM7 { + return fmt.Errorf("unsupported DSM version %q", hi.DistroVersion) + } + + domain := synologyConfigureCertArgs.domain + if st, err := localClient.Status(ctx); err == nil { + if st.BackendState != ipn.Running.String() { + return fmt.Errorf("Tailscale is not running.") + } else if len(st.CertDomains) == 0 { + return fmt.Errorf("TLS certificate support is not enabled/configured for your tailnet.") + } else if len(st.CertDomains) == 1 { + if domain != "" && domain != st.CertDomains[0] { + log.Printf("Ignoring supplied domain %q, TLS certificate will be created for %q.\n", domain, st.CertDomains[0]) + } + domain = st.CertDomains[0] + } else { + var found bool + for _, d := range st.CertDomains { + if d == domain { + found = true + break + } + } + if !found { + return fmt.Errorf("Domain %q was not one of the valid domain options: %q.", domain, st.CertDomains) + } + } + } + + // Check for an existing certificate, and replace it if it already exists + var id string + certs, err := listCerts(ctx, synowebapiCommand{}) + if err != nil { + return err + } + for _, c := range certs { + if c.Subject.CommonName == domain { + id = c.ID + break + } + } + + certPEM, keyPEM, err := localClient.CertPair(ctx, domain) + if err != nil { + return err + } + + // Certs have to be written to file for the upload command to work. + tmpDir, err := os.MkdirTemp("", "") + if err != nil { + return fmt.Errorf("can't create temp dir: %w", err) + } + defer os.RemoveAll(tmpDir) + keyFile := path.Join(tmpDir, "key.pem") + os.WriteFile(keyFile, keyPEM, 0600) + certFile := path.Join(tmpDir, "cert.pem") + os.WriteFile(certFile, certPEM, 0600) + + if err := uploadCert(ctx, synowebapiCommand{}, certFile, keyFile, id); err != nil { + return err + } + + return nil +} + +type subject struct { + CommonName string `json:"common_name"` +} + +type certificateInfo struct { + ID string `json:"id"` + Desc string `json:"desc"` + Subject subject `json:"subject"` +} + +// listCerts fetches a list of the certificates that DSM knows about +func listCerts(ctx context.Context, c synoAPICaller) ([]certificateInfo, error) { + rawData, err := c.Call(ctx, "SYNO.Core.Certificate.CRT", "list", nil) + if err != nil { + return nil, err + } + + var payload struct { + Certificates []certificateInfo `json:"certificates"` + } + if err := json.Unmarshal(rawData, &payload); err != nil { + return nil, fmt.Errorf("decoding certificate list response payload: %w", err) + } + + return payload.Certificates, nil +} + +// uploadCert creates or replaces a certificate. If id is given, it will attempt to replace the certificate with that ID. +func uploadCert(ctx context.Context, c synoAPICaller, certFile, keyFile string, id string) error { + params := map[string]string{ + "key_tmp": keyFile, + "cert_tmp": certFile, + "desc": "Tailnet Certificate", + } + if id != "" { + params["id"] = id + } + + rawData, err := c.Call(ctx, "SYNO.Core.Certificate", "import", params) + if err != nil { + return err + } + + var payload struct { + NewID string `json:"id"` + } + if err := json.Unmarshal(rawData, &payload); err != nil { + return fmt.Errorf("decoding certificate upload response payload: %w", err) + } + log.Printf("Tailnet Certificate uploaded with ID %q.", payload.NewID) + + return nil + +} + +type synoAPICaller interface { + Call(context.Context, string, string, map[string]string) (json.RawMessage, error) +} + +type apiResponse struct { + Success bool `json:"success"` + Error *apiError `json:"error,omitempty"` + Data json.RawMessage `json:"data"` +} + +type apiError struct { + Code int64 `json:"code"` + Errors string `json:"errors"` +} + +// synowebapiCommand implements synoAPICaller using the /usr/syno/bin/synowebapi binary. Must be run as root. +type synowebapiCommand struct{} + +func (s synowebapiCommand) Call(ctx context.Context, api, method string, params map[string]string) (json.RawMessage, error) { + args := []string{"--exec", fmt.Sprintf("api=%s", api), fmt.Sprintf("method=%s", method)} + + for k, v := range params { + args = append(args, fmt.Sprintf("%s=%q", k, v)) + } + + out, err := exec.CommandContext(ctx, "/usr/syno/bin/synowebapi", args...).Output() + if err != nil { + return nil, fmt.Errorf("calling %q method of %q API: %v, %s", method, api, err, out) + } + + var payload apiResponse + if err := json.Unmarshal(out, &payload); err != nil { + return nil, fmt.Errorf("decoding response json from %q method of %q API: %w", method, api, err) + } + + if payload.Error != nil { + return nil, fmt.Errorf("error response from %q method of %q API: %v", method, api, payload.Error) + } + + return payload.Data, nil +} diff --git a/cmd/tailscale/cli/configure-synology-cert_test.go b/cmd/tailscale/cli/configure-synology-cert_test.go new file mode 100644 index 000000000..801285e55 --- /dev/null +++ b/cmd/tailscale/cli/configure-synology-cert_test.go @@ -0,0 +1,140 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package cli + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "testing" +) + +type fakeAPICaller struct { + Data json.RawMessage + Error error +} + +func (c fakeAPICaller) Call(_ context.Context, _, _ string, _ map[string]string) (json.RawMessage, error) { + return c.Data, c.Error +} + +func Test_listCerts(t *testing.T) { + tests := []struct { + name string + caller synoAPICaller + want []certificateInfo + wantErr bool + }{ + { + name: "normal response", + caller: fakeAPICaller{ + Data: json.RawMessage(`{ +"certificates" : [ + { + "desc" : "Tailnet Certificate", + "id" : "cG2XBt", + "is_broken" : false, + "is_default" : false, + "issuer" : { + "common_name" : "R3", + "country" : "US", + "organization" : "Let's Encrypt" + }, + "key_types" : "ECC", + "renewable" : false, + "services" : [ + { + "display_name" : "DSM Desktop Service", + "display_name_i18n" : "common:web_desktop", + "isPkg" : false, + "multiple_cert" : true, + "owner" : "root", + "service" : "default", + "subscriber" : "system", + "user_setable" : true + } + ], + "signature_algorithm" : "sha256WithRSAEncryption", + "subject" : { + "common_name" : "foo.tailscale.ts.net", + "sub_alt_name" : [ "foo.tailscale.ts.net" ] + }, + "user_deletable" : true, + "valid_from" : "Sep 26 11:39:43 2023 GMT", + "valid_till" : "Dec 25 11:39:42 2023 GMT" + }, + { + "desc" : "", + "id" : "sgmnpb", + "is_broken" : false, + "is_default" : false, + "issuer" : { + "city" : "Taipei", + "common_name" : "Synology Inc. CA", + "country" : "TW", + "organization" : "Synology Inc." + }, + "key_types" : "", + "renewable" : false, + "self_signed_cacrt_info" : { + "issuer" : { + "city" : "Taipei", + "common_name" : "Synology Inc. CA", + "country" : "TW", + "organization" : "Synology Inc." + }, + "subject" : { + "city" : "Taipei", + "common_name" : "Synology Inc. CA", + "country" : "TW", + "organization" : "Synology Inc." + } + }, + "services" : [], + "signature_algorithm" : "sha256WithRSAEncryption", + "subject" : { + "city" : "Taipei", + "common_name" : "synology.com", + "country" : "TW", + "organization" : "Synology Inc.", + "sub_alt_name" : [] + }, + "user_deletable" : true, + "valid_from" : "May 27 00:23:19 2019 GMT", + "valid_till" : "Feb 11 00:23:19 2039 GMT" + } +] +}`), + Error: nil, + }, + want: []certificateInfo{ + {Desc: "Tailnet Certificate", ID: "cG2XBt", Subject: subject{CommonName: "foo.tailscale.ts.net"}}, + {Desc: "", ID: "sgmnpb", Subject: subject{CommonName: "synology.com"}}, + }, + }, + { + name: "call error", + caller: fakeAPICaller{nil, fmt.Errorf("caller failed")}, + wantErr: true, + }, + { + name: "payload decode error", + caller: fakeAPICaller{json.RawMessage("This isn't JSON!"), nil}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := listCerts(context.Background(), tt.caller) + if (err != nil) != tt.wantErr { + t.Errorf("listCerts() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("listCerts() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/tailscale/cli/configure.go b/cmd/tailscale/cli/configure.go index 2ebed0503..e8e9cd8f2 100644 --- a/cmd/tailscale/cli/configure.go +++ b/cmd/tailscale/cli/configure.go @@ -33,6 +33,7 @@ func configureSubcommands() (out []*ffcli.Command) { if runtime.GOOS == "linux" && distro.Get() == distro.Synology { out = append(out, synologyConfigureCmd) + out = append(out, synologyConfigureCertCmd) } return out }