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