migration to new secrets nearly complete

This commit is contained in:
Mikaël Cluseau 2023-02-12 15:18:42 +01:00
parent 3bc20e95cc
commit 11f3c953e2
12 changed files with 482 additions and 559 deletions

View File

@ -6,27 +6,44 @@ import (
"log"
"path"
cfsslconfig "github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
yaml "gopkg.in/yaml.v2"
"novit.tech/direktil/pkg/config"
)
var templateFuncs = map[string]interface{}{
func templateFuncs(sslCfg *cfsslconfig.Config) map[string]any {
getKeyCert := func(cluster, caName, name, profile, label, reqJson string) (kc KeyCert, err error) {
certReq := &csr.CertificateRequest{
KeyRequest: csr.NewKeyRequest(),
}
err = json.Unmarshal([]byte(reqJson), certReq)
if err != nil {
log.Print("CSR unmarshal failed on: ", reqJson)
return
}
return getUsableKeyCert(cluster, caName, name, profile, label, certReq, sslCfg)
}
return map[string]any{
"password": func(cluster, name string) (password string, err error) {
password = secretData.Password(cluster, name)
password, _, err = clusterPasswords.Get(cluster + "/" + name)
if err != nil {
return
}
if len(password) == 0 {
err = fmt.Errorf("password %q not defined for cluster %q", name, cluster)
}
return
},
"token": func(cluster, name string) (s string, err error) {
return secretData.Token(cluster, name)
},
"token": getOrCreateClusterToken,
"ca_key": func(cluster, name string) (s string, err error) {
ca, err := secretData.CA(cluster, name)
ca, err := getUsableClusterCA(cluster, name)
if err != nil {
return
}
@ -36,7 +53,7 @@ var templateFuncs = map[string]interface{}{
},
"ca_crt": func(cluster, name string) (s string, err error) {
ca, err := secretData.CA(cluster, name)
ca, err := getUsableClusterCA(cluster, name)
if err != nil {
return
}
@ -46,7 +63,7 @@ var templateFuncs = map[string]interface{}{
},
"ca_dir": func(cluster, name string) (s string, err error) {
ca, err := secretData.CA(cluster, name)
ca, err := getUsableClusterCA(cluster, name)
if err != nil {
return
}
@ -88,7 +105,7 @@ var templateFuncs = map[string]interface{}{
},
"tls_dir": func(dir, cluster, caName, name, profile, label, reqJson string) (s string, err error) {
ca, err := secretData.CA(cluster, caName)
ca, err := getUsableClusterCA(cluster, caName)
if err != nil {
return
}
@ -118,7 +135,7 @@ var templateFuncs = map[string]interface{}{
},
"ssh_host_keys": func(dir, cluster, host string) (s string, err error) {
pairs, err := secretData.SSHKeyPairs(cluster, host)
pairs, err := getSSHKeyPairs(host)
if err != nil {
return
}
@ -143,20 +160,7 @@ var templateFuncs = map[string]interface{}{
return asYaml(files)
},
}
func getKeyCert(cluster, caName, name, profile, label, reqJson string) (kc *KeyCert, err error) {
certReq := &csr.CertificateRequest{
KeyRequest: csr.NewKeyRequest(),
}
err = json.Unmarshal([]byte(reqJson), certReq)
if err != nil {
log.Print("CSR unmarshal failed on: ", reqJson)
return
}
return secretData.KeyCert(cluster, caName, name, profile, label, certReq)
}
func asYaml(v interface{}) (string, error) {

View File

@ -25,7 +25,7 @@ var cmdlineParam = restful.QueryParameter("cmdline", "Linux kernel cmdline addit
type renderContext struct {
Host *localconfig.Host
SSLConfig string
SSLConfig *cfsslconfig.Config
// Linux kernel extra cmdline
CmdLine string `yaml:"-"`
@ -61,12 +61,7 @@ func renderCtx(w http.ResponseWriter, r *http.Request, ctx *renderContext, what
return nil
}
var prevSSLConfig = "-"
func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) {
if prevSSLConfig != cfg.SSLConfig {
var sslCfg *cfsslconfig.Config
func sslConfigFromLocalConfig(cfg *localconfig.Config) (sslCfg *cfsslconfig.Config, err error) {
if len(cfg.SSLConfig) == 0 {
sslCfg = &cfsslconfig.Config{}
} else {
@ -75,18 +70,18 @@ func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *ren
return
}
}
return
}
err = loadSecretData(sslCfg)
func newRenderContext(host *localconfig.Host, cfg *localconfig.Config) (ctx *renderContext, err error) {
sslCfg, err := sslConfigFromLocalConfig(cfg)
if err != nil {
return
}
prevSSLConfig = cfg.SSLConfig
}
return &renderContext{
SSLConfig: cfg.SSLConfig,
Host: host,
SSLConfig: sslCfg,
}, nil
}
@ -120,7 +115,7 @@ func (ctx *renderContext) BootstrapConfig() (ba []byte, cfg *bsconfig.Config, er
func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
tmpl, err := template.New(ctx.Host.Name + "/config").
Funcs(templateFuncs).
Funcs(templateFuncs(ctx.SSLConfig)).
Parse(templateText)
if err != nil {
@ -132,13 +127,6 @@ func (ctx *renderContext) render(templateText string) (ba []byte, err error) {
return
}
if secretData.Changed() {
err = secretData.Save()
if err != nil {
return
}
}
ba = buf.Bytes()
return
}

View File

@ -1,31 +1,16 @@
package main
import (
"crypto"
"crypto/rand"
"encoding/base32"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/cespare/xxhash"
"github.com/cloudflare/cfssl/certinfo"
"github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/helpers"
"github.com/cloudflare/cfssl/initca"
"github.com/cloudflare/cfssl/log"
"github.com/cloudflare/cfssl/signer"
"github.com/cloudflare/cfssl/signer/local"
"k8s.io/apimachinery/pkg/util/validation"
"k8s.io/apimachinery/pkg/util/validation/field"
)
var (
@ -34,12 +19,7 @@ var (
)
type SecretData struct {
l sync.Mutex
prevHash uint64
clusters map[string]*ClusterSecrets
changed bool
config *config.Config
}
@ -50,13 +30,6 @@ type ClusterSecrets struct {
SSHKeyPairs map[string][]SSHKeyPair
}
type CA struct {
Key []byte
Cert []byte
Signed map[string]*KeyCert
}
type KeyCert struct {
Key []byte
Cert []byte
@ -72,14 +45,12 @@ func loadSecretData(config *config.Config) (err error) {
sd := &SecretData{
clusters: make(map[string]*ClusterSecrets),
changed: false,
config: config,
}
ba, err := ioutil.ReadFile(secretDataPath())
if err != nil {
if os.IsNotExist(err) {
sd.changed = true
err = nil
secretData = sd
return
@ -91,221 +62,10 @@ func loadSecretData(config *config.Config) (err error) {
return
}
sd.prevHash = xxhash.Sum64(ba)
secretData = sd
return
}
func (sd *SecretData) Changed() bool {
return sd.changed
}
func (sd *SecretData) Save() (err error) {
if DontSave {
return
}
sd.l.Lock()
defer sd.l.Unlock()
ba, err := json.Marshal(sd.clusters)
if err != nil {
return
}
h := xxhash.Sum64(ba)
if h == sd.prevHash {
return
}
log.Info("Saving secret data")
err = ioutil.WriteFile(secretDataPath(), ba, 0600)
if err == nil {
sd.prevHash = h
}
return
}
func newClusterSecrets() *ClusterSecrets {
return &ClusterSecrets{
CAs: make(map[string]*CA),
Tokens: make(map[string]string),
Passwords: make(map[string]string),
}
}
func (sd *SecretData) cluster(name string) (cs *ClusterSecrets) {
cs, ok := sd.clusters[name]
if ok {
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new cluster: ", name)
cs = newClusterSecrets()
sd.clusters[name] = cs
sd.changed = true
return
}
func (sd *SecretData) Passwords(cluster string) (passwords []string) {
cs := sd.cluster(cluster)
passwords = make([]string, 0, len(cs.Passwords))
for name := range cs.Passwords {
passwords = append(passwords, name)
}
sort.Strings(passwords)
return
}
func (sd *SecretData) Password(cluster, name string) (password string) {
cs := sd.cluster(cluster)
if cs.Passwords == nil {
cs.Passwords = make(map[string]string)
}
password = cs.Passwords[name]
return
}
func (sd *SecretData) SetPassword(cluster, name, password string) {
cs := sd.cluster(cluster)
if cs.Passwords == nil {
cs.Passwords = make(map[string]string)
}
cs.Passwords[name] = password
sd.changed = true
}
func (sd *SecretData) Token(cluster, name string) (token string, err error) {
cs := sd.cluster(cluster)
token = cs.Tokens[name]
if token != "" {
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new token in cluster ", cluster, ": ", name)
b := make([]byte, 16)
_, err = rand.Read(b)
if err != nil {
return
}
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
cs.Tokens[name] = token
sd.changed = true
return
}
func (sd *SecretData) RenewCACert(cluster, name string) (err error) {
cs := sd.cluster(cluster)
ca := cs.CAs[name]
var signer crypto.Signer
signer, err = helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
newCert, _, err := initca.NewFromSigner(newCACertReq(), signer)
if err != nil {
return
}
sd.l.Lock()
defer sd.l.Unlock()
cs.CAs[name].Cert = newCert
sd.changed = true
return
}
func newCACertReq() *csr.CertificateRequest {
return &csr.CertificateRequest{
CN: "Direktil Local Server",
KeyRequest: &csr.KeyRequest{
A: "ecdsa",
S: 521, // 256, 384, 521
},
Names: []csr.Name{
{
C: "NC",
O: "novit.nc",
},
},
}
}
func (sd *SecretData) CA(cluster, name string) (ca *CA, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("cluster %s CA %s: %w", cluster, name, err)
}
}()
cs := sd.cluster(cluster)
ca, ok := cs.CAs[name]
if ok {
checkErr := checkCertUsable(ca.Cert)
if checkErr != nil {
log.Infof("secret-data cluster %s: CA %s: regenerating certificate: %v", cluster, name, checkErr)
err = sd.RenewCACert(cluster, name)
if err != nil {
err = fmt.Errorf("renew: %w", err)
}
}
return
}
sd.l.Lock()
defer sd.l.Unlock()
log.Info("secret-data: new CA in cluster ", cluster, ": ", name)
req := newCACertReq()
cert, _, key, err := initca.New(req)
if err != nil {
err = fmt.Errorf("initca: %w", err)
return
}
ca = &CA{
Key: key,
Cert: cert,
Signed: make(map[string]*KeyCert),
}
cs.CAs[name] = ca
sd.changed = true
return
}
func checkCertUsable(certPEM []byte) error {
cert, err := certinfo.ParseCertificatePEM(certPEM)
if err != nil {
@ -321,104 +81,3 @@ func checkCertUsable(certPEM []byte) error {
return nil
}
func (sd *SecretData) KeyCert(cluster, caName, name, profile, label string, req *csr.CertificateRequest) (kc *KeyCert, err error) {
for idx, host := range req.Hosts {
if ip := net.ParseIP(host); ip != nil {
// valid IP (v4 or v6)
continue
}
if host == "*" {
continue
}
if errs := validation.IsDNS1123Subdomain(host); len(errs) == 0 {
continue
}
if errs := validation.IsWildcardDNS1123Subdomain(host); len(errs) == 0 {
continue
}
path := field.NewPath(cluster, name, "hosts").Index(idx)
return nil, fmt.Errorf("%v: %q is not an IP or FQDN", path, host)
}
if req.CA != nil {
err = errors.New("no CA section allowed here")
return
}
ca, err := sd.CA(cluster, caName)
if err != nil {
return
}
logPrefix := fmt.Sprintf("secret-data: cluster %s: CA %s:", cluster, caName)
rh := hash(req)
kc, ok := ca.Signed[name]
if ok && rh == kc.ReqHash {
err = checkCertUsable(kc.Cert)
if err == nil {
return
}
log.Infof("%s regenerating certificate: ", err)
} else if ok {
log.Infof("%s CSR changed for %s: hash=%q previous=%q", name, rh, kc.ReqHash)
} else {
log.Infof("%s new CSR for %s", logPrefix, name)
}
sd.l.Lock()
defer sd.l.Unlock()
sgr, err := ca.Signer(sd.config.Signing)
if err != nil {
return
}
generator := &csr.Generator{Validator: func(_ *csr.CertificateRequest) error { return nil }}
csr, key, err := generator.ProcessRequest(req)
if err != nil {
return
}
signReq := signer.SignRequest{
Request: string(csr),
Profile: profile,
Label: label,
}
cert, err := sgr.Sign(signReq)
if err != nil {
return
}
kc = &KeyCert{
Key: key,
Cert: cert,
ReqHash: rh,
}
ca.Signed[name] = kc
sd.changed = true
return
}
func (ca *CA) Signer(policy *config.Signing) (result *local.Signer, err error) {
caCert, err := helpers.ParseCertificatePEM(ca.Cert)
if err != nil {
return
}
caKey, err := helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
return local.NewSigner(caKey, caCert, signer.DefaultSigAlgo(caKey), policy)
}

View File

@ -15,34 +15,16 @@ import (
"os/exec"
)
var sshHostKeys = KVSecrets[[]SSHKeyPair]{"hosts/ssh-host-keys"}
type SSHKeyPair struct {
Type string
Public string
Private string
}
func (sd *SecretData) SSHKeyPairs(cluster, host string) (pairs []SSHKeyPair, err error) {
cs := sd.cluster(cluster)
if cs.SSHKeyPairs == nil {
cs.SSHKeyPairs = map[string][]SSHKeyPair{}
}
outFile, err := ioutil.TempFile("/tmp", "dls-key.")
if err != nil {
return
}
outPath := outFile.Name()
removeTemp := func() {
os.Remove(outPath)
os.Remove(outPath + ".pub")
}
defer removeTemp()
pairs = cs.SSHKeyPairs[host]
func getSSHKeyPairs(host string) (pairs []SSHKeyPair, err error) {
pairs, _, err = sshHostKeys.Get(host)
didGenerate := false
@ -59,9 +41,21 @@ genLoop:
}
}
didGenerate = true
err = func() (err error) {
outFile, err := ioutil.TempFile("/tmp", "dls-key.")
if err != nil {
return
}
outPath := outFile.Name()
removeTemp := func() {
os.Remove(outPath)
os.Remove(outPath + ".pub")
}
removeTemp()
defer removeTemp()
var out, privKey, pubKey []byte
@ -80,25 +74,31 @@ genLoop:
return
}
os.Remove(outPath)
pubKey, err = ioutil.ReadFile(outPath + ".pub")
if err != nil {
return
}
os.Remove(outPath + ".pub")
pairs = append(pairs, SSHKeyPair{
Type: keyType,
Public: string(pubKey),
Private: string(privKey),
})
didGenerate = true
return
}()
if err != nil {
return
}
}
if didGenerate {
cs.SSHKeyPairs[host] = pairs
err = sd.Save()
err = sshHostKeys.Put(host, pairs)
if err != nil {
return
}
}
return

View File

@ -7,11 +7,8 @@ func init() {
}
func TestSSHKeyGet(t *testing.T) {
sd := &SecretData{
clusters: make(map[string]*ClusterSecrets),
}
if _, err := sd.SSHKeyPairs("test", "host"); err != nil {
t.Error(err)
}
// TODO needs fake secret store
// if _, err := getSSHKeyPairs("host"); err != nil {
// t.Error(err)
// }
}

View File

@ -0,0 +1,178 @@
package main
import (
"crypto"
"errors"
"fmt"
"log"
"net"
"github.com/cloudflare/cfssl/config"
"github.com/cloudflare/cfssl/csr"
"github.com/cloudflare/cfssl/helpers"
"github.com/cloudflare/cfssl/initca"
"github.com/cloudflare/cfssl/signer"
"github.com/cloudflare/cfssl/signer/local"
"k8s.io/apimachinery/pkg/util/validation"
)
type CA struct {
Key []byte
Cert []byte
Signed map[string]*KeyCert
}
func (ca *CA) Init() (err error) {
req := ca.newReq()
cert, _, key, err := initca.New(req)
if err != nil {
err = fmt.Errorf("initca: %w", err)
return
}
ca.Key = key
ca.Cert = cert
return
}
func (ca *CA) RenewCert() (err error) {
var signer crypto.Signer
signer, err = helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
newCert, _, err := initca.NewFromSigner(ca.newReq(), signer)
if err != nil {
return
}
ca.Cert = newCert
return
}
func (_ CA) newReq() *csr.CertificateRequest {
return &csr.CertificateRequest{
CN: "Direktil Local Server",
KeyRequest: &csr.KeyRequest{
A: "ecdsa",
S: 521, // 256, 384, 521
},
Names: []csr.Name{
{
O: "novit.io",
},
},
}
}
func (ca CA) Signer(policy *config.Signing) (result *local.Signer, err error) {
caCert, err := helpers.ParseCertificatePEM(ca.Cert)
if err != nil {
return
}
caKey, err := helpers.ParsePrivateKeyPEM(ca.Key)
if err != nil {
return
}
return local.NewSigner(caKey, caCert, signer.DefaultSigAlgo(caKey), policy)
}
func getUsableKeyCert(cluster, caName, name, profile, label string, req *csr.CertificateRequest, cfg *config.Config) (kc KeyCert, err error) {
log := log.New(log.Default().Writer(), cluster+": CA "+caName+": ", log.Flags()|log.Lmsgprefix)
ca, err := getUsableClusterCA(cluster, caName)
if err != nil {
return
}
for _, host := range req.Hosts {
if ip := net.ParseIP(host); ip != nil {
// valid IP (v4 or v6)
continue
}
if host == "*" {
continue
}
if errs := validation.IsDNS1123Subdomain(host); len(errs) == 0 {
continue
}
if errs := validation.IsWildcardDNS1123Subdomain(host); len(errs) == 0 {
continue
}
err = fmt.Errorf("%q is not an IP or FQDN", host)
return
}
if req.CA != nil {
err = errors.New("no CA section allowed here")
return
}
rh := hash(req)
key := cluster + "/" + caName + "/" + name
kc, found, err := clusterCASignedKeys.Get(key)
if err != nil {
return
}
if found {
if rh == kc.ReqHash {
err = checkCertUsable(kc.Cert)
if err == nil {
return // all good, no need to create or renew
}
log.Print("regenerating certificate: ", err)
} else {
log.Printf("CSR changed for %s: hash=%q previous=%q", name, rh, kc.ReqHash)
}
} else {
log.Print("new CSR for ", name)
}
sgr, err := ca.Signer(cfg.Signing)
if err != nil {
return
}
generator := &csr.Generator{Validator: func(_ *csr.CertificateRequest) error { return nil }}
csr, tlsKey, err := generator.ProcessRequest(req)
if err != nil {
return
}
signReq := signer.SignRequest{
Request: string(csr),
Profile: profile,
Label: label,
}
cert, err := sgr.Sign(signReq)
if err != nil {
return
}
kc = KeyCert{
Key: tlsKey,
Cert: cert,
ReqHash: rh,
}
err = clusterCASignedKeys.Put(key, kc)
return
}

View File

@ -1,6 +1,9 @@
package main
import (
"fmt"
"github.com/cloudflare/cfssl/log"
restful "github.com/emicklei/go-restful"
)
@ -18,6 +21,51 @@ func wsClusterCA(req *restful.Request, resp *restful.Response) {
clusterCAs.WsGet(resp, clusterName+"/"+name)
}
func getUsableClusterCA(cluster, name string) (ca CA, err error) {
defer func() {
if err != nil {
err = fmt.Errorf("cluster %s CA %s: %w", cluster, name, err)
}
}()
key := cluster + "/" + name
ca, found, err := clusterCAs.Get(key)
if err != nil {
return
}
if !found {
log.Info("new CA in cluster ", cluster, ": ", name)
err = ca.Init()
if err != nil {
return
}
err = clusterCAs.Put(key, ca)
if err != nil {
return
}
return
}
checkErr := checkCertUsable(ca.Cert)
if checkErr != nil {
log.Infof("cluster %s: CA %s: regenerating certificate: %v", cluster, name, checkErr)
err = ca.RenewCert()
if err != nil {
err = fmt.Errorf("renew: %w", err)
}
err = clusterCAs.Put(key, ca)
}
return
}
var clusterCASignedKeys = newClusterSecretKV[KeyCert]("CA-signed-keys")
func wsClusterCASignedKeys(req *restful.Request, resp *restful.Response) {

View File

@ -1,11 +1,35 @@
package main
import (
"crypto/rand"
"encoding/base32"
restful "github.com/emicklei/go-restful"
)
var clusterTokens = newClusterSecretKV[string]("tokens")
func getOrCreateClusterToken(cluster, name string) (token string, err error) {
key := cluster + "/" + name
token, found, err := clusterTokens.Get(key)
if err != nil || found {
return
}
b := make([]byte, 16)
_, err = rand.Read(b)
if err != nil {
return
}
token = base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)
err = clusterTokens.Put(key, token)
return
}
func wsClusterTokens(req *restful.Request, resp *restful.Response) {
clusterName := req.PathParameter("cluster-name")
clusterTokens.WsList(resp, clusterName+"/")

View File

@ -68,7 +68,18 @@ func wsClusterAddons(req *restful.Request, resp *restful.Response) {
return
}
wsRender(resp, cluster.Addons, cluster)
cfg := wsReadConfig(resp)
if cfg == nil {
return
}
sslCfg, err := sslConfigFromLocalConfig(cfg)
if err != nil {
wsError(resp, err)
return
}
wsRender(resp, sslCfg, cluster.Addons, cluster)
}
func wsClusterCACert(req *restful.Request, resp *restful.Response) {

View File

@ -8,6 +8,7 @@ import (
"strings"
"text/template"
cfsslconfig "github.com/cloudflare/cfssl/config"
"github.com/emicklei/go-restful"
"novit.tech/direktil/pkg/localconfig"
@ -186,8 +187,8 @@ func wsError(resp *restful.Response, err error) {
http.StatusText(http.StatusInternalServerError))
}
func wsRender(resp *restful.Response, tmplStr string, value interface{}) {
tmpl, err := template.New("wsRender").Funcs(templateFuncs).Parse(tmplStr)
func wsRender(resp *restful.Response, sslCfg *cfsslconfig.Config, tmplStr string, value interface{}) {
tmpl, err := template.New("wsRender").Funcs(templateFuncs(sslCfg)).Parse(tmplStr)
if err != nil {
wsError(resp, err)
return

View File

@ -1,21 +1,32 @@
export default {
props: [ 'name', 'href', 'token' ],
data() { return {showCopied: false} },
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndCopy()">{{name}}<small>&nbsp;&#x1F5D0;</small></a></span>`,
template: `<span class="notif"><div v-if="showCopied">copied!</div><a :href="href" @click="fetchAndSave()">{{name}}</a>&nbsp;<a href="#" class="copy" @click="fetchAndCopy()">&#x1F5D0;</a></span>`,
methods: {
fetchAndCopy() {
fetch() {
event.preventDefault()
fetch(this.href, {
return fetch(this.href, {
method: 'GET',
headers: { 'Authorization': 'Bearer ' + this.token },
}).then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
})
},
handleFetchError(e) {
console.log("failed to get value:", e)
alert('failed to get value')
},
fetchAndSave() {
this.fetch().then(resp => resp.blob()).then((value) => {
window.open(URL.createObjectURL(value), "_blank")
}).catch(this.handleFetchError)
},
fetchAndCopy() {
this.fetch()
.then((resp) => resp.headers.get("content-type") == "application/json" ? resp.json() : resp.text())
.then((value) => {
window.navigator.clipboard.writeText(value)
this.showCopied = true
setTimeout(() => { this.showCopied = false }, 1000)
})
.catch((e) => { console.log("failed to get value:", e); alert('failed to get value') })
}).catch(this.handleFetchError)
},
},
}

View File

@ -145,3 +145,5 @@ header .utils > * {
background: black;
}
}
.copy { font-size: small; }