From 8baf686e6b558f3aae5b68c5907703d84074c07a Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:31:03 -0700 Subject: [PATCH 1/8] go.mod: update dependencies --- go.mod | 19 +++++++++---------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 28 deletions(-) diff --git a/go.mod b/go.mod index 886beb8..2afe989 100644 --- a/go.mod +++ b/go.mod @@ -8,16 +8,15 @@ require ( github.com/aws/aws-sdk-go-v2/credentials v1.19.5 github.com/aws/aws-sdk-go-v2/service/s3 v1.93.2 github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 - github.com/creachadair/command v0.2.0 + github.com/creachadair/command v0.2.4 github.com/creachadair/flax v0.0.5 - github.com/creachadair/mds v0.25.15 + github.com/creachadair/mds v0.27.1 github.com/creachadair/msync v0.8.1 - github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/google/go-cmp v0.7.0 github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 github.com/tink-crypto/tink-go/v2 v2.6.0 golang.org/x/term v0.38.0 - honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 + honnef.co/go/tools v0.7.0 tailscale.com v1.92.1 ) @@ -70,17 +69,17 @@ require ( github.com/x448/float16 v0.8.4 // indirect go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f // indirect - golang.org/x/mod v0.30.0 // indirect - golang.org/x/net v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sync v0.18.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.31.0 // indirect + golang.org/x/text v0.32.0 // indirect golang.org/x/time v0.11.0 // indirect - golang.org/x/tools v0.39.0 // indirect + golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard/windows v0.5.3 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/go.sum b/go.sum index 913557b..39ca97e 100644 --- a/go.sum +++ b/go.sum @@ -60,12 +60,12 @@ github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NA github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= -github.com/creachadair/command v0.2.0 h1:qTA9cMMhZePAxFoNdnk6F6nn94s1qPndIg9hJbqI9cA= -github.com/creachadair/command v0.2.0/go.mod h1:j+Ar+uYnFsHpkMeV9kGj6lJ45y9u2xqtg8FYy6cm+0o= +github.com/creachadair/command v0.2.4 h1:dR4ZbdaSIortWQ/ZvGrNlohmtNECJaFyTIMuqlRBSV4= +github.com/creachadair/command v0.2.4/go.mod h1:oZUQWtYwThS+2p91b5OcGhdJuYpSIe5JhExYgQecxU0= github.com/creachadair/flax v0.0.5 h1:zt+CRuXQASxwQ68e9GHAOnEgAU29nF0zYMHOCrL5wzE= github.com/creachadair/flax v0.0.5/go.mod h1:F1PML0JZLXSNDMNiRGK2yjm5f+L9QCHchyHBldFymj8= -github.com/creachadair/mds v0.25.15 h1:i8CUqtfgbCqbvZ++L7lm8No3cOeic9YKF4vHEvEoj+Y= -github.com/creachadair/mds v0.25.15/go.mod h1:XtMfRW15sjd1iOi1Z1k+dq0pRsR5xPbulpoTrpyhk8w= +github.com/creachadair/mds v0.27.1 h1:GlO1tPbrsaoafkF6mz7dFutkGXAtIfQLI450u0ypqwA= +github.com/creachadair/mds v0.27.1/go.mod h1:dMBTCSy3iS3dwh4Rb1zxeZz2d7K8+N24GCTsayWtQRI= github.com/creachadair/msync v0.8.1 h1:QRd8si3qZ2Q4TaDL7tS/MG/lFE3YND7U7J9fy42eAFM= github.com/creachadair/msync v0.8.1/go.mod h1:dt0bscS09J8Ie3AdccK9JpCb7LfStaDGlAmDLukOlY4= github.com/creachadair/taskgroup v0.13.2 h1:3KyqakBuFsm3KkXi/9XIb0QcA8tEzLHLgaoidf0MdVc= @@ -209,35 +209,35 @@ go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4 go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.27.0 h1:C8gA4oWU/tKkdCfYT6T2u4faJu3MeNS5O8UPWlPF61w= golang.org/x/image v0.27.0/go.mod h1:xbdrClrAUway1MUTEZDq9mz/UpRwYAkFFNUslZtcB+g= -golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= -golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054 h1:CHVDrNHx9ZoOrNN9kKWYIbT5Rj+WF2rlwPkhbQQ5V4U= +golang.org/x/tools v0.40.1-0.20260108161641-ca281cf95054/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= @@ -252,8 +252,8 @@ gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633 h1:2gap+Kh/3F47cO6hAu3idFvsJ0ue6TRcEi2IUkv/F8k= gvisor.dev/gvisor v0.0.0-20250205023644-9414b50a5633/go.mod h1:5DMfjtclAbTIjbXqO1qCe2K5GKKxWz2JHvCChuTcJEM= -honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0 h1:5SXjd4ET5dYijLaf0O3aOenC0Z4ZafIWSpjUzsQaNho= -honnef.co/go/tools v0.7.0-0.dev.0.20251022135355-8273271481d0/go.mod h1:EPDDhEZqVHhWuPI5zPAsjU0U7v9xNIWjoOVyZ5ZcniQ= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= From 626db477d80d88dac8c2be9a2deb306b040be5df Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:31:31 -0700 Subject: [PATCH 2/8] types/api: add types for secret metadata updates --- types/api/api.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/types/api/api.go b/types/api/api.go index 671dabe..c582559 100644 --- a/types/api/api.go +++ b/types/api/api.go @@ -9,6 +9,8 @@ import ( "errors" "strconv" "time" + + "tailscale.com/types/opt" ) var ( @@ -68,6 +70,9 @@ type SecretInfo struct { // If known, the last time in UTC when the secret was accessed for any // operation other than "info", otherwise zero. LastAccess time.Time `json:",omitzero"` + + // An optional human-readable description for the secret. + Description string `json:",omitzero"` } // ListRequest is a request to list secrets. @@ -98,6 +103,28 @@ type InfoRequest struct { Name string } +// MaxDescriptionBytes is the maximum permitted length of a secret description. +// Descriptions in excess of this length will be rejected. +const MaxDescriptionBytes = 1000 + +// SetInfoRequest is a request to update secret metadata. +type SetInfoRequest struct { + // Name is the name of the secret whose metadata should be updated. + Name string + + SecretInfoUpdate `json:",inline"` +} + +// SecretInfoUpdate describes metadata fields of a secret that should be +// updated to new values. Each field is optional: If a field of the update is +// set, its corresponding value in the secret is updated; otherwise the value +// stored with the secret preserved as-stored. +type SecretInfoUpdate struct { + // Description, if set, is a human-readable text description to apply. + // The value, if set, must be valid UTF-8 and not exceed [MaxDescriptionBytes]. + Description opt.Value[string] `json:",omitzero"` +} + // PutRequest is a request to write a secret value. type PutRequest struct { // Name is the name of the secret to write. From ff92fa92b506fd31a323d5a68d491a1b4d19f018 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:32:01 -0700 Subject: [PATCH 3/8] db: store descriptions and implement SetInfo --- db/db.go | 18 ++++++++++++++++++ db/kv.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) diff --git a/db/db.go b/db/db.go index 45e96cd..5b4039e 100644 --- a/db/db.go +++ b/db/db.go @@ -59,6 +59,9 @@ var ( // ErrInvalidVersion indicates that an attempt was made to create a // version of a secret using an invalid version number (<=0). ErrInvalidVersion = errors.New("invalid version") + // ErrInvalidParams indicates that one or more request parameters are + // invalid (in an otherwise-authorized request). + ErrInvalidParams = errors.New("invalid parameters") ) // Config carries the parameters required to construct a [DB]. @@ -388,6 +391,21 @@ func (db *DB) deleteConfigLocked(name string) error { return fmt.Errorf("unknown config value %q", name) } +// SetInfo updates the metadata of the specified secret with the specified +// values. It reports an error if the secret does not exist, or if the +func (db *DB) SetInfo(caller Caller, name string, update api.SecretInfoUpdate) error { + db.mu.Lock() + defer db.mu.Unlock() + + if err := db.checkAndLog(caller, acl.ActionSetInfo, name, 0); err != nil { + return err + } + if strings.HasPrefix(name, configPrefix) { + return fmt.Errorf("cannot set info for config %q", name) + } + return db.kv.setInfo(name, update) +} + // AccessIndex is an index mapping secret names to last-access records. type AccessIndex map[string]LastAccess diff --git a/db/kv.go b/db/kv.go index 0671f21..a19c807 100644 --- a/db/kv.go +++ b/db/kv.go @@ -13,6 +13,7 @@ import ( "maps" "os" "slices" + "unicode/utf8" "github.com/tailscale/setec/types/api" "github.com/tink-crypto/tink-go/v2/aead" @@ -91,15 +92,22 @@ type secret struct { // We rely on api.SecretVersion being a type encoding/json will translate to // a JSON string (currently an integer). Versions map[api.SecretVersion]byteString + // ActiveVersion is the secret version that gets returned to // clients who don't ask for a specific version of the secret. ActiveVersion api.SecretVersion + // LatestVersion is the highest version that has already been used // by a previous Put or CreateVersion. LatestVersion api.SecretVersion + // DeletedVersions tracks versions that were previously set but // have since been deleted. These are not permitted to be set again. DeletedVersions map[api.SecretVersion]bool + + // Description is an optional human-readable text describing the role or + // purpose of the secret. A valid Description must be UTF-8 encoded. + Description string } // byteString is an alias for a string, but encodes to JSON as the conventional @@ -279,6 +287,7 @@ func (kv *kv) info(name string) (*api.SecretInfo, error) { info := &api.SecretInfo{ Name: name, ActiveVersion: secret.ActiveVersion, + Description: secret.Description, } for v := range secret.Versions { info.Versions = append(info.Versions, v) @@ -287,6 +296,44 @@ func (kv *kv) info(name string) (*api.SecretInfo, error) { return info, nil } +// setInfo applies an update to the metadata of a secret. +func (kv *kv) setInfo(name string, update api.SecretInfoUpdate) error { + secret := kv.secrets[name] + if secret == nil { + return ErrNotFound + } + + // Make a shallow copy of the secret so we can revert if save fails. + // An update does not modify the maps, so it's safe to share them. + backup := *secret + + // Apply any set fields of the update. + // We require that at least one update is set. When adding new metadata + // fields, add additional conditional blocks below. + + var hasUpdate bool + if desc, ok := update.Description.GetOk(); ok { + if !utf8.ValidString(desc) { + return fmt.Errorf("%w: description is not utf-8", ErrInvalidParams) + } else if len(desc) > api.MaxDescriptionBytes { + return fmt.Errorf("%w: description too long (%d bytes > %d)", + ErrInvalidParams, len(desc), api.MaxDescriptionBytes) + } + secret.Description = desc + hasUpdate = true + } + // .. add additional fields here + + if !hasUpdate { + return fmt.Errorf("%w: no updates specified", ErrInvalidParams) + } + if err := kv.save(); err != nil { + *secret = backup // restore + return err + } + return nil +} + // get returns a secret's active value. func (kv *kv) get(name string) (*api.SecretValue, error) { secret := kv.secrets[name] From 225d77a4f95c0f077a6797979e03411c68a59d67 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:32:24 -0700 Subject: [PATCH 4/8] acl: add set-info permission and tests --- acl/acl.go | 5 +++++ acl/acl_test.go | 10 ++++++++++ 2 files changed, 15 insertions(+) diff --git a/acl/acl.go b/acl/acl.go index 3cf31fc..cb59f7c 100644 --- a/acl/acl.go +++ b/acl/acl.go @@ -23,6 +23,11 @@ const ( // secret values. ActionInfo = Action("info") + // ActionSetInfo ("set-info" in the API) denotes permission to write the + // metadata for a secret, including the human-readable description, but not + // the secret values. + ActionSetInfo = Action("set-info") + // ActionPut ("put" in the API) denotes permission to put a new value of a // secret. ActionPut = Action("put") diff --git a/acl/acl_test.go b/acl/acl_test.go index 8fbace4..f9cdba0 100644 --- a/acl/acl_test.go +++ b/acl/acl_test.go @@ -23,6 +23,10 @@ func TestACL(t *testing.T) { Action: []acl.Action{acl.ActionDelete}, Secret: []acl.Secret{"dev/*"}, }, + acl.Rule{ + Action: []acl.Action{acl.ActionGet, acl.ActionSetInfo}, + Secret: []acl.Secret{"special/*/magic"}, + }, } type testCase struct { @@ -73,6 +77,12 @@ func TestACL(t *testing.T) { deny("delete", "control/bar"), deny("delete", "something/else"), deny("delete", "dev"), + + allow("get", "special/foo/magic"), + deny("get", "special/foo/more-magic"), + allow("set-info", "special/bar/magic"), + deny("set-info", "special/bar/more-magic"), + deny("set-info", "some/other/nonsense"), } for _, test := range tests { From 26720012ff7ef528a56efd7b708e4d2117f12b26 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:33:13 -0700 Subject: [PATCH 5/8] server,docs: implement /api/set-info and add tests --- docs/api.md | 14 +++++++++ server/server.go | 12 +++++++ server/server_test.go | 63 +++++++++++++++++++++++++++++++++++++ server/templates/index.html | 5 ++- 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index c404eb0..9165e02 100644 --- a/docs/api.md +++ b/docs/api.md @@ -108,6 +108,20 @@ The service defines named _actions_ that are subject to access control: {"Name":"example","Versions":[1,2,3],"ActiveVersion":2} ``` +- `/api/set-info`: Set metadata for a single secret. + + **Requires:** `set-info` permission for the specified secret. + + **Request:** `api.SetInfoRequest` + + **Example request:** + ``json + {"Name":"example","Description":"a demonstration secret, not used in production"}` + ``` + + **Constraints:** The value of the *Description* field must be valid UTF-8 and may + not exceed 1000 bytes in length. + - `/api/put`: Add a new value for a secret. **Requires:** `put` permission for the specified name. diff --git a/server/server.go b/server/server.go index 9e2b08a..6a419d1 100644 --- a/server/server.go +++ b/server/server.go @@ -165,6 +165,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) { cfg.Mux.HandleFunc("/api/list", ret.list) cfg.Mux.HandleFunc("/api/get", ret.get) cfg.Mux.HandleFunc("/api/info", ret.info) + cfg.Mux.HandleFunc("/api/set-info", ret.setInfo) cfg.Mux.HandleFunc("/api/put", ret.put) cfg.Mux.HandleFunc("/api/create-version", ret.createVersion) cfg.Mux.HandleFunc("/api/activate", ret.activate) @@ -261,6 +262,13 @@ func (s *Server) info(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) setInfo(w http.ResponseWriter, r *http.Request) { + serveJSON(s, w, r, func(req api.SetInfoRequest, id db.Caller) (struct{}, error) { + err := s.db.SetInfo(id, req.Name, req.SecretInfoUpdate) + return struct{}{}, err + }) +} + func (s *Server) put(w http.ResponseWriter, r *http.Request) { serveJSON(s, w, r, func(req api.PutRequest, id db.Caller) (api.SecretVersion, error) { return s.db.Put(id, req.Name, req.Value) @@ -410,6 +418,10 @@ func serveJSON[REQ any, RESP any](s *Server, w http.ResponseWriter, r *http.Requ s.countCallAlreadySet.Add(apiMethod, 1) http.Error(w, "version already set", http.StatusPreconditionFailed) return + } else if errors.Is(err, db.ErrInvalidParams) { + s.countCallBadRequest.Add(apiMethod, 1) + http.Error(w, err.Error(), http.StatusBadRequest) + return } else if err != nil { s.countCallInternalError.Add(apiMethod, 1) http.Error(w, "internal error", http.StatusInternalServerError) diff --git a/server/server_test.go b/server/server_test.go index ba291d8..bbfe84b 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "github.com/tailscale/setec/acl" @@ -23,6 +24,7 @@ import ( "github.com/tailscale/setec/types/api" "tailscale.com/client/tailscale/apitype" "tailscale.com/tailcfg" + "tailscale.com/types/opt" ) func TestNew(t *testing.T) { @@ -167,3 +169,64 @@ func TestServerStatus(t *testing.T) { t.Errorf("DeleteVersion %v: unexpected error %v", ov2, err) } } + +func TestServerSetInfo(t *testing.T) { + d := setectest.NewDB(t, nil) + d.MustPut(d.Superuser, "pet", "miniature giant space hamster") + + ss := setectest.NewServer(t, d, nil) + hs := httptest.NewServer(ss.Mux) + defer hs.Close() + + cli := setec.Client{Server: hs.URL, DoHTTP: hs.Client().Do} + checkDesc := func(t *testing.T, name, want string) { + t.Helper() + info, err := cli.Info(t.Context(), name) + if err != nil { + t.Fatalf("Info %q: unexpected error: %v", name, err) + } + if got := info.Description; got != want { + t.Errorf("Info %q: got description %q, want %q", name, got, want) + } + } + checkSetInfo := func(t *testing.T, name string, update api.SecretInfoUpdate, want string) { + t.Helper() + err := cli.SetInfo(t.Context(), name, update) + if want != "" { + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("SetInfo %q: got error %v, want %q", name, err, want) + } + } else if err != nil { + t.Errorf("SetInfo %q: unexpected error: %v", name, err) + } + } + + // Case 1: The initial description is empty. + checkDesc(t, "pet", "") + + // Case 2: An empty update should fail. + checkSetInfo(t, "pet", api.SecretInfoUpdate{}, "no updates specified") + + // Case 3: A too-long description should fail, leaving the secret unchanged. + checkSetInfo(t, "pet", api.SecretInfoUpdate{ + Description: opt.ValueOf(strings.Repeat("q", 2000)), + }, "description too long") + checkDesc(t, "pet", "") + + // Case 4: A valid update should succeed, and update the secret. + checkSetInfo(t, "pet", api.SecretInfoUpdate{ + Description: opt.ValueOf("Boo"), + }, "") + checkDesc(t, "pet", "Boo") + + // Case 5: An update to a non-existent secret should fail. + checkSetInfo(t, "fruit", api.SecretInfoUpdate{ + Description: opt.ValueOf("apple"), + }, "not found") + + // Case 6: An empty description is valid (distinct from "unspecified"). + checkSetInfo(t, "pet", api.SecretInfoUpdate{ + Description: opt.ValueOf(""), + }, "") + checkDesc(t, "pet", "") +} diff --git a/server/templates/index.html b/server/templates/index.html index 98f706f..9fff549 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -9,7 +9,7 @@

Secrets List

- + {{- range $info := .}} @@ -31,6 +31,9 @@

Secrets List

{{- timeFormat . "2006-01-02T15:04:05Z"}} {{- else}}(unknown){{end}} + {{- end}}
NameVersionsLast Accessed
NameVersionsLast AccessedDescription
{{$info.Name}} + {{$info.Description}} +
From 44fc1c500a30546940e7b8bef8003133f6d7587b Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:33:34 -0700 Subject: [PATCH 6/8] setectest: add support for SetInfo --- setectest/dbtest.go | 12 +++++++++++- setectest/server.go | 3 ++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/setectest/dbtest.go b/setectest/dbtest.go index 7cbeed5..376a02f 100644 --- a/setectest/dbtest.go +++ b/setectest/dbtest.go @@ -30,7 +30,8 @@ func superuser() db.Caller { Permissions: acl.Rules{ acl.Rule{ Action: []acl.Action{ - acl.ActionGet, acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, + acl.ActionGet, acl.ActionInfo, acl.ActionSetInfo, acl.ActionPut, + acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, }, Secret: []acl.Secret{"*"}, }, @@ -150,3 +151,12 @@ func (db *DB) MustCreateVersion(caller db.Caller, name string, version api.Secre db.t.Fatalf("CreateVersion %v of %q failed: %v", version, name, err) } } + +// MustSetInfo updates the metadata for the named secret or fails. +func (db *DB) MustSetInfo(caller db.Caller, name string, update api.SecretInfoUpdate) { + db.t.Helper() + + if err := db.Actual.SetInfo(caller, name, update); err != nil { + db.t.Fatalf("SetInfo %q to %+v failed: %v", name, update, err) + } +} diff --git a/setectest/server.go b/setectest/server.go index 7761fda..68822ba 100644 --- a/setectest/server.go +++ b/setectest/server.go @@ -105,7 +105,8 @@ var allAccessCap []tailcfg.RawMessage func init() { rule, err := json.Marshal(acl.Rule{ Action: []acl.Action{ - acl.ActionGet, acl.ActionInfo, acl.ActionPut, acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, + acl.ActionGet, acl.ActionInfo, acl.ActionSetInfo, acl.ActionPut, + acl.ActionCreateVersion, acl.ActionActivate, acl.ActionDelete, }, Secret: []acl.Secret{"*"}, }) From 0ad3d856ac556bb63eeaaa7c9c74e04ea6032da1 Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:33:51 -0700 Subject: [PATCH 7/8] client/setec: add a SetInfo client method --- client/setec/client.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/setec/client.go b/client/setec/client.go index 59a9c36..661fd82 100644 --- a/client/setec/client.go +++ b/client/setec/client.go @@ -229,6 +229,15 @@ func (c Client) Info(ctx context.Context, name string) (*api.SecretInfo, error) }) } +// SetInfo updates metadata for the given secret name. +func (c Client) SetInfo(ctx context.Context, name string, update api.SecretInfoUpdate) error { + _, err := do[struct{}](ctx, c, "/api/set-info", api.SetInfoRequest{ + Name: name, + SecretInfoUpdate: update, + }) + return err +} + // Put creates a secret called name, with the given value. If a secret called // name already exist, the value is saved as a new inactive version. // From 8c0fd807f92528d314643378bb79737ba516fc0a Mon Sep 17 00:00:00 2001 From: "M. J. Fromberger" Date: Mon, 27 Apr 2026 13:34:06 -0700 Subject: [PATCH 8/8] cmd/setec: add set-info subcommand and update info output --- cmd/setec/setec.go | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/cmd/setec/setec.go b/cmd/setec/setec.go index f14402c..e3567e5 100644 --- a/cmd/setec/setec.go +++ b/cmd/setec/setec.go @@ -39,6 +39,7 @@ import ( "golang.org/x/term" "tailscale.com/tsnet" "tailscale.com/tsweb" + "tailscale.com/types/opt" ) func main() { @@ -97,6 +98,13 @@ Most of the settings can be set via environment variables as well as flags. Help: "Get metadata for the specified secret.", Run: command.Adapt(runInfo), }, + { + Name: "set-info", + Usage: "", + Help: `Set metadata for the specified secret.`, + SetFlags: command.Flags(flax.MustBind, &setInfoArgs), + Run: command.Adapt(runSetInfo), + }, { Name: "get", Usage: "", @@ -327,7 +335,7 @@ func runList(env *command.Env) error { } tw := newTabWriter(os.Stdout) - io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\tLAST ACCESSED\n") + io.WriteString(tw, "NAME\tACTIVE\tVERSIONS\tLAST ACCESSED\tDESCRIPTION\n") for _, s := range secrets { vers := make([]string, 0, len(s.Versions)) for _, v := range s.Versions { @@ -337,7 +345,8 @@ func runList(env *command.Env) error { if !s.LastAccess.IsZero() { lastAccess = s.LastAccess.Format(time.RFC3339) } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", s.Name, s.ActiveVersion, strings.Join(vers, ","), lastAccess) + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n", + s.Name, s.ActiveVersion, strings.Join(vers, ","), lastAccess, s.Description) } return tw.Flush() } @@ -358,6 +367,9 @@ func runInfo(env *command.Env, name string) error { } tw := newTabWriter(os.Stdout) fmt.Fprintf(tw, "Name:\t%s\n", info.Name) + if d := info.Description; d != "" { + fmt.Fprintf(tw, "Description:\t%s\n", d) + } fmt.Fprintf(tw, "Active version:\t%s\n", info.ActiveVersion) fmt.Fprintf(tw, "Versions:\t%s\n", strings.Join(vers, ", ")) if !info.LastAccess.IsZero() { @@ -366,6 +378,24 @@ func runInfo(env *command.Env, name string) error { return tw.Flush() } +var setInfoArgs struct { + Description string `flag:"description,Set the human-readable description of the secret"` +} + +func runSetInfo(env *command.Env, name string) error { + c, err := newClient() + if err != nil { + return err + } + + // If more metadata fields are added, this list will need to be extended. + var update api.SecretInfoUpdate + if env.IsFlagSet("description") { + update.Description = opt.ValueOf(setInfoArgs.Description) + } + return c.SetInfo(env.Context(), name, update) +} + var getArgs struct { IfChanged bool `flag:"if-changed,Get active version if changed from --version"` Version uint64 `flag:"version,Secret version to retrieve (default: the active version)"`