mirror of https://github.com/tailscale/tailscale/
cmd/tailscale/cli: Add CLI command to update certs on Synology devices.
Fixes #4674 Signed-off-by: Will Morrison <william.barr.morrison@gmail.com>pull/11638/head
parent
9699bb0a20
commit
306bacc669
@ -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
|
||||
}
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue