cmd/tailscale/cli: Add CLI command to update certs on Synology devices.

Fixes #4674

Signed-off-by: Will Morrison <william.barr.morrison@gmail.com>
This commit is contained in:
Will Morrison 2024-01-31 22:15:12 +01:00 committed by Brad Fitzpatrick
parent 9699bb0a20
commit 306bacc669
3 changed files with 361 additions and 0 deletions

View File

@ -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 <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
}

View File

@ -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)
}
})
}
}

View File

@ -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
}