From 4b594516ec5fe6d849663d877db5a0614de03089 Mon Sep 17 00:00:00 2001 From: tobi <31960611+tsmethurst@users.noreply.github.com> Date: Tue, 12 Sep 2023 11:43:12 +0200 Subject: [feature] Allow admins to expire remote public keys; refetch expired keys on demand (#2183) --- docs/api/swagger.yaml | 73 +++++++ internal/api/client/admin/admin.go | 4 + internal/api/client/admin/domainkeysexpire.go | 149 +++++++++++++ internal/api/model/admin.go | 11 + internal/api/model/domain.go | 8 + .../20230905133904_remote_pubkey_expiry.go | 45 ++++ internal/federation/authenticate.go | 232 ++++++++++++++++----- internal/federation/federatingprotocol.go | 20 +- internal/federation/federatingprotocol_test.go | 27 +++ internal/federation/federator.go | 3 +- internal/federation/federator_test.go | 11 +- internal/gtsmodel/account.go | 16 +- internal/gtsmodel/adminaction.go | 5 + internal/processing/admin/domainkeysexpire.go | 87 ++++++++ internal/processing/fedi/common.go | 6 +- internal/processing/fedi/user.go | 9 +- testrig/transportcontroller.go | 66 ++++-- web/source/settings/admin/actions.js | 62 ------ .../settings/admin/actions/keys/expireremote.jsx | 61 ++++++ web/source/settings/admin/actions/keys/index.jsx | 32 +++ .../settings/admin/actions/media/cleanup.jsx | 59 ++++++ web/source/settings/admin/actions/media/index.jsx | 32 +++ web/source/settings/index.js | 7 +- web/source/settings/lib/query/admin/index.js | 9 + 24 files changed, 879 insertions(+), 155 deletions(-) create mode 100644 internal/api/client/admin/domainkeysexpire.go create mode 100644 internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go create mode 100644 internal/processing/admin/domainkeysexpire.go delete mode 100644 web/source/settings/admin/actions.js create mode 100644 web/source/settings/admin/actions/keys/expireremote.jsx create mode 100644 web/source/settings/admin/actions/keys/index.jsx create mode 100644 web/source/settings/admin/actions/media/cleanup.jsx create mode 100644 web/source/settings/admin/actions/media/index.jsx diff --git a/docs/api/swagger.yaml b/docs/api/swagger.yaml index 793478aeb..d9bf40b06 100644 --- a/docs/api/swagger.yaml +++ b/docs/api/swagger.yaml @@ -445,6 +445,19 @@ definitions: type: object x-go-name: AdminAccountInfo x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + adminActionResponse: + description: |- + AdminActionResponse models the server + response to an admin action. + properties: + action_id: + description: Internal ID of the action. + example: 01H9QG6TZ9W5P0402VFRVM17TH + type: string + x-go-name: ActionID + type: object + x-go-name: AdminActionResponse + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model adminEmoji: properties: category: @@ -1018,6 +1031,16 @@ definitions: type: object x-go-name: DomainBlockCreateRequest x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model + domainKeysExpireRequest: + properties: + domain: + description: hostname/domain to expire keys for. + type: string + x-go-name: Domain + title: DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys. + type: object + x-go-name: DomainKeysExpireRequest + x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model emoji: properties: category: @@ -4103,6 +4126,56 @@ paths: summary: View domain block with the given ID. tags: - admin + /api/v1/admin/domain_keys_expire: + post: + consumes: + - multipart/form-data + description: |- + This is useful in cases where the remote domain has had to rotate their keys for whatever + reason (security issue, data leak, routine safety procedure, etc), and your instance can no + longer communicate with theirs properly using cached keys. A key marked as expired in this way + will be lazily refetched next time a request is made to your instance signed by the owner of that + key, so no further action should be required in order to reestablish communication with that domain. + + This endpoint is explicitly not for rotating your *own* keys, it only works for remote instances. + + Using this endpoint to expire keys for a domain that hasn't rotated all of their keys is not + harmful and won't break federation, but it is pointless and will cause unnecessary requests to + be performed. + operationId: domainKeysExpire + parameters: + - description: Domain to expire keys for. + example: example.org + in: formData + name: domain + type: string + produces: + - application/json + responses: + "202": + description: Request accepted and will be processed. Check the logs for progress / errors. + schema: + $ref: '#/definitions/adminActionResponse' + "400": + description: bad request + "401": + description: unauthorized + "403": + description: forbidden + "404": + description: not found + "406": + description: not acceptable + "409": + description: 'Conflict: There is already an admin action running that conflicts with this action. Check the error message in the response body for more information. This is a temporary error; it should be possible to process this action if you try again in a bit.' + "500": + description: internal server error + security: + - OAuth2 Bearer: + - admin + summary: Force expiry of cached public keys for all accounts on the given domain stored in your database. + tags: + - admin /api/v1/admin/email/test: post: consumes: diff --git a/internal/api/client/admin/admin.go b/internal/api/client/admin/admin.go index ce6604c29..605c53731 100644 --- a/internal/api/client/admin/admin.go +++ b/internal/api/client/admin/admin.go @@ -31,6 +31,7 @@ const ( EmojiCategoriesPath = EmojiPath + "/categories" DomainBlocksPath = BasePath + "/domain_blocks" DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey + DomainKeysExpirePath = BasePath + "/domain_keys_expire" AccountsPath = BasePath + "/accounts" AccountsPathWithID = AccountsPath + "/:" + IDKey AccountsActionPath = AccountsPathWithID + "/action" @@ -83,6 +84,9 @@ func (m *Module) Route(attachHandler func(method string, path string, f ...gin.H attachHandler(http.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler) attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) + // domain maintenance stuff + attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler) + // accounts stuff attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler) diff --git a/internal/api/client/admin/domainkeysexpire.go b/internal/api/client/admin/domainkeysexpire.go new file mode 100644 index 000000000..73a811dd4 --- /dev/null +++ b/internal/api/client/admin/domainkeysexpire.go @@ -0,0 +1,149 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/oauth" +) + +// DomainKeysExpirePOSTHandler swagger:operation POST /api/v1/admin/domain_keys_expire domainKeysExpire +// +// Force expiry of cached public keys for all accounts on the given domain stored in your database. +// +// This is useful in cases where the remote domain has had to rotate their keys for whatever +// reason (security issue, data leak, routine safety procedure, etc), and your instance can no +// longer communicate with theirs properly using cached keys. A key marked as expired in this way +// will be lazily refetched next time a request is made to your instance signed by the owner of that +// key, so no further action should be required in order to reestablish communication with that domain. +// +// This endpoint is explicitly not for rotating your *own* keys, it only works for remote instances. +// +// Using this endpoint to expire keys for a domain that hasn't rotated all of their keys is not +// harmful and won't break federation, but it is pointless and will cause unnecessary requests to +// be performed. +// +// --- +// tags: +// - admin +// +// consumes: +// - multipart/form-data +// +// produces: +// - application/json +// +// parameters: +// - +// name: domain +// in: formData +// description: Domain to expire keys for. +// example: example.org +// type: string +// +// security: +// - OAuth2 Bearer: +// - admin +// +// responses: +// '202': +// description: >- +// Request accepted and will be processed. +// Check the logs for progress / errors. +// schema: +// "$ref": "#/definitions/adminActionResponse" +// '400': +// description: bad request +// '401': +// description: unauthorized +// '403': +// description: forbidden +// '404': +// description: not found +// '406': +// description: not acceptable +// '409': +// description: >- +// Conflict: There is already an admin action running that conflicts with this action. +// Check the error message in the response body for more information. This is a temporary +// error; it should be possible to process this action if you try again in a bit. +// '500': +// description: internal server error +func (m *Module) DomainKeysExpirePOSTHandler(c *gin.Context) { + authed, err := oauth.Authed(c, true, true, true, true) + if err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorUnauthorized(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if !*authed.User.Admin { + err := fmt.Errorf("user %s not an admin", authed.User.ID) + apiutil.ErrorHandler(c, gtserror.NewErrorForbidden(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if _, err := apiutil.NegotiateAccept(c, apiutil.JSONAcceptHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + form := new(apimodel.DomainKeysExpireRequest) + if err := c.ShouldBind(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + if err := validateDomainKeysExpire(form); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorBadRequest(err, err.Error()), m.processor.InstanceGetV1) + return + } + + actionID, errWithCode := m.processor.Admin().DomainKeysExpire( + c.Request.Context(), + authed.Account, + form.Domain, + ) + if errWithCode != nil { + apiutil.ErrorHandler(c, errWithCode, m.processor.InstanceGetV1) + return + } + + c.JSON(http.StatusAccepted, &apimodel.AdminActionResponse{ActionID: actionID}) +} + +func validateDomainKeysExpire(form *apimodel.DomainKeysExpireRequest) error { + form.Domain = strings.TrimSpace(form.Domain) + if form.Domain == "" { + return errors.New("no domain given") + } + + if form.Domain == config.GetHost() || form.Domain == config.GetAccountDomain() { + return errors.New("provided domain was this domain, but must be a remote domain") + } + + return nil +} diff --git a/internal/api/model/admin.go b/internal/api/model/admin.go index 6be3e9cbd..ca4aa32da 100644 --- a/internal/api/model/admin.go +++ b/internal/api/model/admin.go @@ -178,6 +178,17 @@ type AdminActionRequest struct { TargetID string `form:"-" json:"-" xml:"-"` } +// AdminActionResponse models the server +// response to an admin action. +// +// swagger:model adminActionResponse +type AdminActionResponse struct { + // Internal ID of the action. + // + // example: 01H9QG6TZ9W5P0402VFRVM17TH + ActionID string `json:"action_id"` +} + // MediaCleanupRequest models admin media cleanup parameters // // swagger:parameters mediaCleanup diff --git a/internal/api/model/domain.go b/internal/api/model/domain.go index 045dc2700..c5f77c82f 100644 --- a/internal/api/model/domain.go +++ b/internal/api/model/domain.go @@ -79,3 +79,11 @@ type DomainBlockCreateRequest struct { // public comment on the reason for the domain block PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` } + +// DomainBlockCreateRequest is the form submitted as a POST to /api/v1/admin/domain_keys_expire to expire a domain's public keys. +// +// swagger:model domainKeysExpireRequest +type DomainKeysExpireRequest struct { + // hostname/domain to expire keys for. + Domain string `form:"domain" json:"domain" xml:"domain"` +} diff --git a/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go b/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go new file mode 100644 index 000000000..814540505 --- /dev/null +++ b/internal/db/bundb/migrations/20230905133904_remote_pubkey_expiry.go @@ -0,0 +1,45 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package migrations + +import ( + "context" + "strings" + + "github.com/uptrace/bun" +) + +func init() { + up := func(ctx context.Context, db *bun.DB) error { + _, err := db.ExecContext(ctx, "ALTER TABLE ? ADD COLUMN ? TIMESTAMPTZ", bun.Ident("accounts"), bun.Ident("public_key_expires_at")) + if err != nil && !(strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "duplicate column name") || strings.Contains(err.Error(), "SQLSTATE 42701")) { + return err + } + return nil + } + + down := func(ctx context.Context, db *bun.DB) error { + return db.RunInTx(ctx, nil, func(ctx context.Context, tx bun.Tx) error { + return nil + }) + } + + if err := Migrations.Register(up, down); err != nil { + panic(err) + } +} diff --git a/internal/federation/authenticate.go b/internal/federation/authenticate.go index 80998680e..12d6f459a 100644 --- a/internal/federation/authenticate.go +++ b/internal/federation/authenticate.go @@ -25,14 +25,17 @@ import ( "fmt" "net/http" "net/url" + "time" "codeberg.org/gruf/go-kv" "github.com/go-fed/httpsig" "github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/config" + "github.com/superseriousbusiness/gotosocial/internal/db" "github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" "github.com/superseriousbusiness/gotosocial/internal/log" ) @@ -45,11 +48,47 @@ var ( } ) +// PubKeyAuth models authorization information for a remote +// Actor making a signed HTTP request to this GtS instance +// using a public key. +type PubKeyAuth struct { + // CachedPubKey is the public key found in the db + // for the Actor whose request we're now authenticating. + // Will be set only in cases where we had the Owner + // of the key stored in the database already. + CachedPubKey *rsa.PublicKey + + // FetchedPubKey is an up-to-date public key fetched + // from the remote instance. Will be set in cases + // where EITHER we hadn't seen the Actor before whose + // request we're now authenticating, OR a CachedPubKey + // was found in our database, but was expired. + FetchedPubKey *rsa.PublicKey + + // OwnerURI is the ActivityPub id of the owner of + // the public key used to sign the request we're + // now authenticating. This will always be set + // even if Owner isn't, so that callers can use + // this URI to go fetch the Owner from remote. + OwnerURI *url.URL + + // Owner is the account corresponding to OwnerURI. + // + // Owner will only be defined if the account who + // owns the public key was already cached in the + // database when we received the request we're now + // authenticating (ie., we've seen it before). + // + // If it's not defined, callers should use OwnerURI + // to go and dereference it. + Owner *gtsmodel.Account +} + // AuthenticateFederatedRequest authenticates any kind of incoming federated // request from a remote server. This includes things like GET requests for // dereferencing our users or statuses etc, and POST requests for delivering -// new Activities. The function returns the URL of the owner of the public key -// used in the requesting http signature. +// new Activities. The function returns details of the public key(s) used to +// authenticate the requesting http signature. // // 'Authenticate' in this case is defined as making sure that the http request // is actually signed by whoever claims to have signed it, by fetching the public @@ -70,7 +109,7 @@ var ( // Also note that this function *does not* dereference the remote account that // the signature key is associated with. Other functions should use the returned // URL to dereference the remote account, if required. -func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedUsername string) (*url.URL, gtserror.WithCode) { +func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedUsername string) (*PubKeyAuth, gtserror.WithCode) { // Thanks to the signature check middleware, // we should already have an http signature // verifier set on the context. If we don't, @@ -102,10 +141,10 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU // so now we need to validate the signature. var ( - pubKeyIDStr = pubKeyID.String() - requestingAccountURI *url.URL - pubKey interface{} - errWithCode gtserror.WithCode + pubKeyIDStr = pubKeyID.String() + local = (pubKeyID.Host == config.GetHost()) + pubKeyAuth *PubKeyAuth + errWithCode gtserror.WithCode ) l := log. @@ -115,37 +154,49 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU {"pubKeyID", pubKeyIDStr}, }...) - if pubKeyID.Host == config.GetHost() { - l.Trace("public key is ours, no dereference needed") - requestingAccountURI, pubKey, errWithCode = f.derefDBOnly(ctx, pubKeyIDStr) + if local { + l.Trace("public key is local, no dereference needed") + pubKeyAuth, errWithCode = f.derefPubKeyDBOnly(ctx, pubKeyIDStr) } else { - l.Trace("public key is not ours, checking if we need to dereference") - requestingAccountURI, pubKey, errWithCode = f.deref(ctx, requestedUsername, pubKeyIDStr, pubKeyID) + l.Trace("public key is remote, checking if we need to dereference") + pubKeyAuth, errWithCode = f.derefPubKey(ctx, requestedUsername, pubKeyIDStr, pubKeyID) } if errWithCode != nil { return nil, errWithCode } - // Ensure public key now defined. - if pubKey == nil { - err := gtserror.New("public key was nil") + if local && pubKeyAuth == nil { + // We signed this request, apparently, but + // local lookup didn't find anything. This + // is an almost impossible error condition! + err := gtserror.Newf("local public key %s could not be found; "+ + "has the account been manually removed from the db?", pubKeyIDStr) return nil, gtserror.NewErrorInternalError(err) } // Try to authenticate using permitted algorithms in - // order of most -> least common. Return OK as soon - // as one passes. - for _, algo := range signingAlgorithms { - l.Tracef("trying %s", algo) - - err := verifier.Verify(pubKey, algo) - if err == nil { - l.Tracef("authentication PASSED with %s", algo) - return requestingAccountURI, nil + // order of most -> least common, checking each defined + // pubKey for this Actor. Return OK as soon as one passes. + for _, pubKey := range [2]*rsa.PublicKey{ + pubKeyAuth.FetchedPubKey, + pubKeyAuth.CachedPubKey, + } { + if pubKey == nil { + continue } - l.Tracef("authentication NOT PASSED with %s: %q", algo, err) + for _, algo := range signingAlgorithms { + l.Tracef("trying %s", algo) + + err := verifier.Verify(pubKey, algo) + if err == nil { + l.Tracef("authentication PASSED with %s", algo) + return pubKeyAuth, nil + } + + l.Tracef("authentication NOT PASSED with %s: %q", algo, err) + } } // At this point no algorithms passed. @@ -157,36 +208,52 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU return nil, gtserror.NewErrorUnauthorized(err, err.Error()) } -// derefDBOnly tries to dereference the given public -// key using only entries already in the database. -func (f *federator) derefDBOnly( +// derefPubKeyDBOnly tries to dereference the given +// pubKey using only entries already in the database. +// +// In case of a db or URL error, will return the error. +// +// In case an entry for the pubKey owner just doesn't +// exist in the db (yet), will return nil, nil. +func (f *federator) derefPubKeyDBOnly( ctx context.Context, pubKeyIDStr string, -) (*url.URL, interface{}, gtserror.WithCode) { - reqAcct, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr) +) (*PubKeyAuth, gtserror.WithCode) { + owner, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr) if err != nil { + if errors.Is(err, db.ErrNoEntries) { + // We don't have this + // account stored (yet). + return nil, nil + } + err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err) - return nil, nil, gtserror.NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } - reqAcctURI, err := url.Parse(reqAcct.URI) + ownerURI, err := url.Parse(owner.URI) if err != nil { err = gtserror.Newf("error parsing account uri with pubKeyID %s: %w", pubKeyIDStr, err) - return nil, nil, gtserror.NewErrorInternalError(err) + return nil, gtserror.NewErrorInternalError(err) } - return reqAcctURI, reqAcct.PublicKey, nil + return &PubKeyAuth{ + CachedPubKey: owner.PublicKey, + OwnerURI: ownerURI, + Owner: owner, + }, nil } -// deref tries to dereference the given public key by first -// checking in the database, and then (if no entries found) -// calling the remote pub key URI and extracting the key. -func (f *federator) deref( +// derefPubKey tries to dereference the given public key by first +// checking in the database, and then (if no entry found, or entry +// found but pubKey expired) calling the remote pub key URI and +// extracting the key. +func (f *federator) derefPubKey( ctx context.Context, requestedUsername string, pubKeyIDStr string, pubKeyID *url.URL, -) (*url.URL, interface{}, gtserror.WithCode) { +) (*PubKeyAuth, gtserror.WithCode) { l := log. WithContext(ctx). WithFields(kv.Fields{ @@ -196,42 +263,101 @@ func (f *federator) deref( // Try a database only deref first. We may already // have the requesting account cached locally. - reqAcctURI, pubKey, errWithCode := f.derefDBOnly(ctx, pubKeyIDStr) - if errWithCode == nil { - l.Trace("public key cached, no dereference needed") - return reqAcctURI, pubKey, nil + pubKeyAuth, errWithCode := f.derefPubKeyDBOnly(ctx, pubKeyIDStr) + if errWithCode != nil { + return nil, errWithCode } - l.Trace("public key not cached, trying dereference") + var ( + // Just haven't seen this + // Actor + their pubkey yet. + uncached = (pubKeyAuth == nil) + + // Have seen this Actor + their + // pubkey but latter is now expired. + expired = (!uncached && pubKeyAuth.Owner.PubKeyExpired()) + ) + + switch { + case uncached: + l.Trace("public key was not cached, trying dereference of public key") + case !expired: + l.Trace("public key cached and up to date, no dereference needed") + return pubKeyAuth, nil + case expired: + // This is fairly rare and it may be helpful for + // admins to see what's going on, so log at info. + l.Infof( + "public key was cached, but expired at %s, trying dereference of new public key", + pubKeyAuth.Owner.PublicKeyExpiresAt, + ) + } // If we've tried to get this account before and we // now have a tombstone for it (ie., it's been deleted // from remote), don't try to dereference it again. gone, err := f.CheckGone(ctx, pubKeyID) if err != nil { - err := gtserror.Newf("error checking for tombstone for %s: %w", pubKeyIDStr, err) - return nil, nil, gtserror.NewErrorInternalError(err) + err := gtserror.Newf("error checking for tombstone (%s): %w", pubKeyIDStr, err) + return nil, gtserror.NewErrorInternalError(err) } if gone { - err := gtserror.Newf("account with public key %s is gone", pubKeyIDStr) - return nil, nil, gtserror.NewErrorGone(err) + err := gtserror.Newf("account with public key is gone (%s)", pubKeyIDStr) + return nil, gtserror.NewErrorGone(err) } - // Make an http call to get the pubkey. + // Make an http call to get the (refreshed) pubkey. pubKeyBytes, errWithCode := f.callForPubKey(ctx, requestedUsername, pubKeyID) if errWithCode != nil { - return nil, nil, errWithCode + return nil, errWithCode } // Extract the key and the owner from the response. pubKey, pubKeyOwner, err := parsePubKeyBytes(ctx, pubKeyBytes, pubKeyID) if err != nil { - err := fmt.Errorf("error parsing public key %s: %w", pubKeyID, err) - return nil, nil, gtserror.NewErrorUnauthorized(err) + err := fmt.Errorf("error parsing public key (%s): %w", pubKeyID, err) + return nil, gtserror.NewErrorUnauthorized(err) + } + + if !expired { + // PubKeyResponse was nil before because + // we had nothing cached; return the key + // we just fetched, and nothing else. + return &PubKeyAuth{ + FetchedPubKey: pubKey, + OwnerURI: pubKeyOwner, + }, nil + } + + // Add newly-fetched key to response. + pubKeyAuth.FetchedPubKey = pubKey + + // If key was expired, that means we already + // had an owner stored for it locally. Since + // we now successfully refreshed the pub key, + // we should update the account to reflect that. + ownerAcct := pubKeyAuth.Owner + ownerAcct.PublicKey = pubKeyAuth.FetchedPubKey + ownerAcct.PublicKeyExpiresAt = time.Time{} + + l.Info("obtained a new public key to replace expired key, caching now; " + + "authorization for this request will be attempted with both old and new keys") + + if err := f.db.UpdateAccount( + ctx, + ownerAcct, + "public_key", + "public_key_expires_at", + ); err != nil { + err := gtserror.Newf("db error updating account with refreshed public key (%s): %w", pubKeyIDStr, err) + return nil, gtserror.NewErrorInternalError(err) } - return pubKeyOwner, pubKey, nil + // Return both new and cached (now + // expired) keys, authentication + // will be attempted with both. + return pubKeyAuth, nil } // callForPubKey handles the nitty gritty of actually diff --git a/internal/federation/federatingprotocol.go b/internal/federation/federatingprotocol.go index ef42639ed..fb4e5bfb9 100644 --- a/internal/federation/federatingprotocol.go +++ b/internal/federation/federatingprotocol.go @@ -209,7 +209,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } // Check who's trying to deliver to us by inspecting the http signature. - pubKeyOwner, errWithCode := f.AuthenticateFederatedRequest(ctx, receivingAccount.Username) + pubKeyAuth, errWithCode := f.AuthenticateFederatedRequest(ctx, receivingAccount.Username) if errWithCode != nil { switch errWithCode.Code() { case http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: @@ -232,12 +232,14 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr } } + pubKeyOwnerURI := pubKeyAuth.OwnerURI + // Authentication has passed, check if we need to create a // new instance entry for the Host of the requesting account. - if _, err := f.db.GetInstance(ctx, pubKeyOwner.Host); err != nil { + if _, err := f.db.GetInstance(ctx, pubKeyOwnerURI.Host); err != nil { if !errors.Is(err, db.ErrNoEntries) { // There's been an actual error. - err = gtserror.Newf("error getting instance %s: %w", pubKeyOwner.Host, err) + err = gtserror.Newf("error getting instance %s: %w", pubKeyOwnerURI.Host, err) return ctx, false, err } @@ -247,17 +249,17 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr gtscontext.SetFastFail(ctx), username, &url.URL{ - Scheme: pubKeyOwner.Scheme, - Host: pubKeyOwner.Host, + Scheme: pubKeyOwnerURI.Scheme, + Host: pubKeyOwnerURI.Host, }, ) if err != nil { - err = gtserror.Newf("error dereferencing instance %s: %w", pubKeyOwner.Host, err) + err = gtserror.Newf("error dereferencing instance %s: %w", pubKeyOwnerURI.Host, err) return nil, false, err } if err := f.db.PutInstance(ctx, instance); err != nil && !errors.Is(err, db.ErrAlreadyExists) { - err = gtserror.Newf("error inserting instance entry for %s: %w", pubKeyOwner.Host, err) + err = gtserror.Newf("error inserting instance entry for %s: %w", pubKeyOwnerURI.Host, err) return nil, false, err } } @@ -268,7 +270,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr requestingAccount, _, err := f.GetAccountByURI( gtscontext.SetFastFail(ctx), username, - pubKeyOwner, + pubKeyOwnerURI, ) if err != nil { if gtserror.StatusCode(err) == http.StatusGone { @@ -282,7 +284,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr return ctx, false, nil } - err = gtserror.Newf("couldn't get requesting account %s: %w", pubKeyOwner, err) + err = gtserror.Newf("couldn't get requesting account %s: %w", pubKeyOwnerURI, err) return nil, false, err } diff --git a/internal/federation/federatingprotocol_test.go b/internal/federation/federatingprotocol_test.go index 8da6859dd..7a8343048 100644 --- a/internal/federation/federatingprotocol_test.go +++ b/internal/federation/federatingprotocol_test.go @@ -257,6 +257,33 @@ func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInbox() { suite.Equal(http.StatusOK, code) } +func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInboxKeyExpired() { + var ( + ctx = context.Background() + activity = suite.testActivities["dm_for_zork"] + receivingAccount = suite.testAccounts["local_account_1"] + ) + + // Update remote account to mark key as expired. + remoteAcct := >smodel.Account{} + *remoteAcct = *suite.testAccounts["remote_account_1"] + remoteAcct.PublicKeyExpiresAt = testrig.TimeMustParse("2022-06-10T15:22:08Z") + if err := suite.state.DB.UpdateAccount(ctx, remoteAcct, "public_key_expires_at"); err != nil { + suite.FailNow(err.Error()) + } + + ctx, authed, resp, code := suite.authenticatePostInbox( + ctx, + receivingAccount, + activity, + ) + + suite.NotNil(gtscontext.RequestingAccount(ctx)) + suite.True(authed) + suite.Equal([]byte{}, resp) + suite.Equal(http.StatusOK, code) +} + func (suite *FederatingProtocolTestSuite) TestAuthenticatePostGoneWithTombstone() { var ( activity = suite.testActivities["delete_https://somewhere.mysterious/users/rest_in_piss#main-key"] diff --git a/internal/federation/federator.go b/internal/federation/federator.go index 40af08d25..ad6db8ff7 100644 --- a/internal/federation/federator.go +++ b/internal/federation/federator.go @@ -19,7 +19,6 @@ package federation import ( "context" - "net/url" "github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/gotosocial/internal/db" @@ -49,7 +48,7 @@ type Federator interface { // If the request does not pass authentication, or there's a domain block, nil, false, nil will be returned. // // If something goes wrong during authentication, nil, false, and an error will be returned. - AuthenticateFederatedRequest(ctx context.Context, username string) (*url.URL, gtserror.WithCode) + AuthenticateFederatedRequest(ctx context.Context, username string) (*PubKeyAuth, gtserror.WithCode) pub.CommonBehavior pub.FederatingProtocol diff --git a/internal/federation/federator_test.go b/internal/federation/federator_test.go index a80d590a4..3287cd11a 100644 --- a/internal/federation/federator_test.go +++ b/internal/federation/federator_test.go @@ -18,6 +18,8 @@ package federation_test import ( + "context" + "github.com/stretchr/testify/suite" "github.com/superseriousbusiness/gotosocial/internal/federation" @@ -71,7 +73,14 @@ func (suite *FederatorStandardTestSuite) SetupTest() { suite.typeconverter, ) - suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media") + // Ensure it's possible to deref + // main key of foss satan. + fossSatanPerson, err := suite.typeconverter.AccountToAS(context.Background(), suite.testAccounts["remote_account_1"]) + if err != nil { + suite.FailNow(err.Error()) + } + + suite.httpClient = testrig.NewMockHTTPClient(nil, "../../testrig/media", fossSatanPerson) suite.httpClient.TestRemotePeople = testrig.NewTestFediPeople() suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() diff --git a/internal/gtsmodel/account.go b/internal/gtsmodel/account.go index 7b27f076a..578d4c811 100644 --- a/internal/gtsmodel/account.go +++ b/internal/gtsmodel/account.go @@ -72,9 +72,10 @@ type Account struct { FollowersURI string `bun:",nullzero,unique"` // URI for getting the followers list of this account FeaturedCollectionURI string `bun:",nullzero,unique"` // URL for getting the featured collection list of this account ActorType string `bun:",nullzero,notnull"` // What type of activitypub actor is this account? - PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for validating activitypub requests, will only be defined for local accounts - PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for encoding activitypub requests, will be defined for both local and remote accounts + PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for signing activitypub requests, will only be defined for local accounts + PublicKey *rsa.PublicKey `bun:",notnull"` // Publickey for authorizing signed activitypub requests, will be defined for both local and remote accounts PublicKeyURI string `bun:",nullzero,notnull,unique"` // Web-reachable location of this account's public key + PublicKeyExpiresAt time.Time `bun:"type:timestamptz,nullzero"` // PublicKey will expire/has expired at given time, and should be fetched again as appropriate. Only ever set for remote accounts. SensitizedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account set to have all its media shown as sensitive? SilencedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account silenced (eg., statuses only visible to followers, not public)? SuspendedAt time.Time `bun:"type:timestamptz,nullzero"` // When was this account suspended (eg., don't allow it to log in/post, don't accept media/posts from this account) @@ -129,6 +130,17 @@ func (a *Account) EmojisPopulated() bool { return true } +// PubKeyExpired returns true if the account's public key +// has been marked as expired, and the expiry time has passed. +func (a *Account) PubKeyExpired() bool { + if a == nil { + return false + } + + return !a.PublicKeyExpiresAt.IsZero() && + a.PublicKeyExpiresAt.Before(time.Now()) +} + // AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis. type AccountToEmoji struct { AccountID string `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` diff --git a/internal/gtsmodel/adminaction.go b/internal/gtsmodel/adminaction.go index c6c598b32..1e55a33f9 100644 --- a/internal/gtsmodel/adminaction.go +++ b/internal/gtsmodel/adminaction.go @@ -72,6 +72,7 @@ const ( AdminActionUnsilence AdminActionSuspend AdminActionUnsuspend + AdminActionExpireKeys ) func (t AdminActionType) String() string { @@ -88,6 +89,8 @@ func (t AdminActionType) String() string { return "suspend" case AdminActionUnsuspend: return "unsuspend" + case AdminActionExpireKeys: + return "expire-keys" default: return "unknown" } @@ -107,6 +110,8 @@ func NewAdminActionType(in string) AdminActionType { return AdminActionSuspend case "unsuspend": return AdminActionUnsuspend + case "expire-keys": + return AdminActionExpireKeys default: return AdminActionUnknown } diff --git a/internal/processing/admin/domainkeysexpire.go b/internal/processing/admin/domainkeysexpire.go new file mode 100644 index 000000000..886da8b2f --- /dev/null +++ b/internal/processing/admin/domainkeysexpire.go @@ -0,0 +1,87 @@ +// GoToSocial +// Copyright (C) GoToSocial Authors admin@gotosocial.org +// SPDX-License-Identifier: AGPL-3.0-or-later +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package admin + +import ( + "context" + "time" + + "github.com/superseriousbusiness/gotosocial/internal/gtserror" + "github.com/superseriousbusiness/gotosocial/internal/gtsmodel" + "github.com/superseriousbusiness/gotosocial/internal/id" +) + +// DomainKeysExpire iterates through all +// accounts belonging to the given domain, +// and expires the public key of each +// account found this way. +// +// The PublicKey for each account will be +// re-fetched next time a signed request +// from that account is received. +func (p *Processor) DomainKeysExpire( + ctx context.Context, + adminAcct *gtsmodel.Account, + domain string, +) (string, gtserror.WithCode) { + actionID := id.NewULID() + + // Process key expiration asynchronously. + if errWithCode := p.actions.Run( + ctx, + >smodel.AdminAction{ + ID: actionID, + TargetCategory: gtsmodel.AdminActionCategoryDomain, + TargetID: domain, + Type: gtsmodel.AdminActionExpireKeys, + AccountID: adminAcct.ID, + }, + func(ctx context.Context) gtserror.MultiError { + return p.domainKeysExpireSideEffects(ctx, domain) + }, + ); errWithCode != nil { + return actionID, errWithCode + } + + return actionID, nil +} + +func (p *Processor) domainKeysExpireSideEffects(ctx context.Context, domain string) gtserror.MultiError { + var ( + expiresAt = time.Now() + errs gtserror.MultiError + ) + + // For each account on this domain, expire + // the public key and update the account. + if err := p.rangeDomainAccounts(ctx, domain, func(account *gtsmodel.Account) { + account.PublicKeyExpiresAt = expiresAt + + if err := p.state.DB.UpdateAccount( + ctx, + account, + "public_key_expires_at", + ); err != nil { + errs.Appendf("db error updating account: %w", err) + } + }); err != nil { + errs.Appendf("db error ranging through accounts: %w", err) + } + + return errs +} diff --git a/internal/processing/fedi/common.go b/internal/processing/fedi/common.go index 1331a20e0..38c31ffd2 100644 --- a/internal/processing/fedi/common.go +++ b/internal/processing/fedi/common.go @@ -48,7 +48,7 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string) // Ensure request signed, and use signature URI to // get requesting account, dereferencing if necessary. - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) if errWithCode != nil { return nil, nil, errWithCode } @@ -56,10 +56,10 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string) requestingAccount, _, err := p.federator.GetAccountByURI( gtscontext.SetFastFail(ctx), requestedUsername, - requestingAccountURI, + pubKeyAuth.OwnerURI, ) if err != nil { - err = gtserror.Newf("error getting account %s: %w", requestingAccountURI, err) + err = gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err) return nil, nil, gtserror.NewErrorUnauthorized(err) } diff --git a/internal/processing/fedi/user.go b/internal/processing/fedi/user.go index 4a55df01f..f3305c103 100644 --- a/internal/processing/fedi/user.go +++ b/internal/processing/fedi/user.go @@ -66,7 +66,7 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque // If the request is not on a public key path, we want to // try to authenticate it before we serve any data, so that // we can serve a more complete profile. - requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) + pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) if errWithCode != nil { return nil, errWithCode // likely 401 } @@ -89,7 +89,7 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque // Instead, we end up in an 'I'll show you mine if you show me // yours' situation, where we sort of agree to reveal each // other's profiles at the same time. - if p.federator.Handshaking(requestedUsername, requestingAccountURI) { + if p.federator.Handshaking(requestedUsername, pubKeyAuth.OwnerURI) { return data(person) } @@ -98,10 +98,11 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque requestingAccount, _, err := p.federator.GetAccountByURI( // On a hot path so fail quickly. gtscontext.SetFastFail(ctx), - requestedUsername, requestingAccountURI, + requestedUsername, + pubKeyAuth.OwnerURI, ) if err != nil { - err := gtserror.Newf("error getting account %s: %w", requestingAccountURI, err) + err := gtserror.Newf("error getting account %s: %w", pubKeyAuth.OwnerURI, err) return nil, gtserror.NewErrorUnauthorized(err) } diff --git a/testrig/transportcontroller.go b/testrig/transportcontroller.go index 1c75e1974..46a9b0fb2 100644 --- a/testrig/transportcontroller.go +++ b/testrig/transportcontroller.go @@ -78,7 +78,7 @@ type MockHTTPClient struct { // to customize how the client is mocked. // // Note that you should never ever make ACTUAL http calls with this thing. -func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relativeMediaPath string) *MockHTTPClient { +func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relativeMediaPath string, extraPeople ...vocab.ActivityStreamsPerson) *MockHTTPClient { mockHTTPClient := &MockHTTPClient{} if do != nil { @@ -95,10 +95,13 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat mockHTTPClient.TestTombstones = NewTestTombstones() mockHTTPClient.do = func(req *http.Request) (*http.Response, error) { - responseCode := http.StatusNotFound - responseBytes := []byte(`{"error":"404 not found"}`) - responseContentType := applicationJSON - responseContentLength := len(responseBytes) + var ( + responseCode = http.StatusNotFound + responseBytes = []byte(`{"error":"404 not found"}`) + responseContentType = applicationJSON + responseContentLength = len(responseBytes) + reqURLString = req.URL.String() + ) if req.Method == http.MethodPost { b, err := io.ReadAll(req.Body) @@ -106,26 +109,26 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat panic(err) } - if sI, loaded := mockHTTPClient.SentMessages.LoadOrStore(req.URL.String(), [][]byte{b}); loaded { + if sI, loaded := mockHTTPClient.SentMessages.LoadOrStore(reqURLString, [][]byte{b}); loaded { s, ok := sI.([][]byte) if !ok { panic("SentMessages entry wasn't [][]byte") } s = append(s, b) - mockHTTPClient.SentMessages.Store(req.URL.String(), s) + mockHTTPClient.SentMessages.Store(reqURLString, s) } responseCode = http.StatusOK responseBytes = []byte(`{"ok":"accepted"}`) responseContentType = applicationJSON responseContentLength = len(responseBytes) - } else if strings.Contains(req.URL.String(), ".well-known/webfinger") { + } else if strings.Contains(reqURLString, ".well-known/webfinger") { responseCode, responseBytes, responseContentType, responseContentLength = WebfingerResponse(req) - } else if strings.Contains(req.URL.String(), ".weird-webfinger-location/webfinger") { + } else if strings.Contains(reqURLString, ".weird-webfinger-location/webfinger") { responseCode, responseBytes, responseContentType, responseContentLength = WebfingerResponse(req) - } else if strings.Contains(req.URL.String(), ".well-known/host-meta") { + } else if strings.Contains(reqURLString, ".well-known/host-meta") { responseCode, responseBytes, responseContentType, responseContentLength = HostMetaResponse(req) - } else if note, ok := mockHTTPClient.TestRemoteStatuses[req.URL.String()]; ok { + } else if note, ok := mockHTTPClient.TestRemoteStatuses[reqURLString]; ok { // the request is for a note that we have stored noteI, err := streams.Serialize(note) if err != nil { @@ -139,7 +142,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat responseBytes = noteJSON responseContentType = applicationActivityJSON responseContentLength = len(noteJSON) - } else if person, ok := mockHTTPClient.TestRemotePeople[req.URL.String()]; ok { + } else if person, ok := mockHTTPClient.TestRemotePeople[reqURLString]; ok { // the request is for a person that we have stored personI, err := streams.Serialize(person) if err != nil { @@ -153,7 +156,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat responseBytes = personJSON responseContentType = applicationActivityJSON responseContentLength = len(personJSON) - } else if group, ok := mockHTTPClient.TestRemoteGroups[req.URL.String()]; ok { + } else if group, ok := mockHTTPClient.TestRemoteGroups[reqURLString]; ok { // the request is for a person that we have stored groupI, err := streams.Serialize(group) if err != nil { @@ -167,7 +170,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat responseBytes = groupJSON responseContentType = applicationActivityJSON responseContentLength = len(groupJSON) - } else if service, ok := mockHTTPClient.TestRemoteServices[req.URL.String()]; ok { + } else if service, ok := mockHTTPClient.TestRemoteServices[reqURLString]; ok { serviceI, err := streams.Serialize(service) if err != nil { panic(err) @@ -180,7 +183,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat responseBytes = serviceJSON responseContentType = applicationActivityJSON responseContentLength = len(serviceJSON) - } else if emoji, ok := mockHTTPClient.TestRemoteEmojis[req.URL.String()]; ok { + } else if emoji, ok := mockHTTPClient.TestRemoteEmojis[reqURLString]; ok { emojiI, err := streams.Serialize(emoji) if err != nil { panic(err) @@ -193,16 +196,45 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat responseBytes = emojiJSON responseContentType = applicationActivityJSON responseContentLength = len(emojiJSON) - } else if attachment, ok := mockHTTPClient.TestRemoteAttachments[req.URL.String()]; ok { + } else if attachment, ok := mockHTTPClient.TestRemoteAttachments[reqURLString]; ok { responseCode = http.StatusOK responseBytes = attachment.Data responseContentType = attachment.ContentType responseContentLength = len(attachment.Data) - } else if _, ok := mockHTTPClient.TestTombstones[req.URL.String()]; ok { + } else if _, ok := mockHTTPClient.TestTombstones[reqURLString]; ok { responseCode = http.StatusGone responseBytes = []byte{} responseContentType = "text/html" responseContentLength = 0 + } else { + for _, person := range extraPeople { + // For any extra people, check if the + // request matches one of: + // + // - Public key URI + // - ActivityPub URI/id + // - Web URL. + // + // Since this is a test environment, + // just assume all these values have + // been properly set. + if reqURLString == person.GetW3IDSecurityV1PublicKey().At(0).Get().GetJSONLDId().GetIRI().String() || + reqURLString == person.GetJSONLDId().GetIRI().String() || + reqURLString == person.GetActivityStreamsUrl().At(0).GetIRI().String() { + personI, err := streams.Serialize(person) + if err != nil { + panic(err) + } + personJSON, err := json.Marshal(personI) + if err != nil { + panic(err) + } + responseCode = http.StatusOK + responseBytes = personJSON + responseContentType = applicationActivityJSON + responseContentLength = len(personJSON) + } + } } log.Debugf(nil, "returning response %s", string(responseBytes)) diff --git a/web/source/settings/admin/actions.js b/web/source/settings/admin/actions.js deleted file mode 100644 index 7f25299e5..000000000 --- a/web/source/settings/admin/actions.js +++ /dev/null @@ -1,62 +0,0 @@ -/* - GoToSocial - Copyright (C) GoToSocial Authors admin@gotosocial.org - SPDX-License-Identifier: AGPL-3.0-or-later - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program. If not, see . -*/ - -"use strict"; - -const React = require("react"); - -const query = require("../lib/query"); - -const { useTextInput } = require("../lib/form"); -const { TextInput } = require("../components/form/inputs"); - -const MutationButton = require("../components/form/mutation-button"); - -module.exports = function AdminActionPanel() { - const daysField = useTextInput("days", { defaultValue: 30 }); - - const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation(); - - function submitMediaCleanup(e) { - e.preventDefault(); - mediaCleanup(daysField.value); - } - - return ( - <> -

Admin Actions

-
-

Media cleanup

-

- Clean up remote media older than the specified number of days. - If the remote instance is still online they will be refetched when needed. - Also cleans up unused headers and avatars from the media cache. -

- - - - - ); -}; \ No newline at end of file diff --git a/web/source/settings/admin/actions/keys/expireremote.jsx b/web/source/settings/admin/actions/keys/expireremote.jsx new file mode 100644 index 000000000..b9045a7ed --- /dev/null +++ b/web/source/settings/admin/actions/keys/expireremote.jsx @@ -0,0 +1,61 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../../lib/query"); + +const { useTextInput } = require("../../../lib/form"); +const { TextInput } = require("../../../components/form/inputs"); + +const MutationButton = require("../../../components/form/mutation-button"); + +module.exports = function ExpireRemote({}) { + const domainField = useTextInput("domain"); + + const [expire, expireResult] = query.useInstanceKeysExpireMutation(); + + function submitExpire(e) { + e.preventDefault(); + expire(domainField.value); + } + + return ( +
+

Expire remote instance keys

+

+ Mark all public keys from the given remote instance as expired.

+ This is useful in cases where the remote domain has had to rotate their keys for whatever + reason (security issue, data leak, routine safety procedure, etc), and your instance can no + longer communicate with theirs properly using cached keys. A key marked as expired in this way + will be lazily refetched next time a request is made to your instance signed by the owner of that + key. +

+ + + + ); +}; diff --git a/web/source/settings/admin/actions/keys/index.jsx b/web/source/settings/admin/actions/keys/index.jsx new file mode 100644 index 000000000..b40835c12 --- /dev/null +++ b/web/source/settings/admin/actions/keys/index.jsx @@ -0,0 +1,32 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +"use strict"; + +const React = require("react"); +const ExpireRemote = require("./expireremote"); + +module.exports = function Keys() { + return ( + <> +

Key Actions

+ + + ); +}; diff --git a/web/source/settings/admin/actions/media/cleanup.jsx b/web/source/settings/admin/actions/media/cleanup.jsx new file mode 100644 index 000000000..61ee15258 --- /dev/null +++ b/web/source/settings/admin/actions/media/cleanup.jsx @@ -0,0 +1,59 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +"use strict"; + +const React = require("react"); + +const query = require("../../../lib/query"); + +const { useTextInput } = require("../../../lib/form"); +const { TextInput } = require("../../../components/form/inputs"); + +const MutationButton = require("../../../components/form/mutation-button"); + +module.exports = function Cleanup({}) { + const daysField = useTextInput("days", { defaultValue: 30 }); + + const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation(); + + function submitCleanup(e) { + e.preventDefault(); + mediaCleanup(daysField.value); + } + + return ( +
+

Cleanup

+

+ Clean up remote media older than the specified number of days. + If the remote instance is still online they will be refetched when needed. + Also cleans up unused headers and avatars from the media cache. +

+ + + + ); +}; diff --git a/web/source/settings/admin/actions/media/index.jsx b/web/source/settings/admin/actions/media/index.jsx new file mode 100644 index 000000000..c5167506a --- /dev/null +++ b/web/source/settings/admin/actions/media/index.jsx @@ -0,0 +1,32 @@ +/* + GoToSocial + Copyright (C) GoToSocial Authors admin@gotosocial.org + SPDX-License-Identifier: AGPL-3.0-or-later + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +"use strict"; + +const React = require("react"); +const Cleanup = require("./cleanup"); + +module.exports = function Media() { + return ( + <> +

Media Actions

+ + + ); +}; diff --git a/web/source/settings/index.js b/web/source/settings/index.js index 398bca0f6..9758e89e6 100644 --- a/web/source/settings/index.js +++ b/web/source/settings/index.js @@ -55,7 +55,10 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [ defaultUrl: "/settings/admin/settings", permissions: ["admin"] }, [ - Item("Actions", { icon: "fa-bolt" }, require("./admin/actions")), + Menu("Actions", { icon: "fa-bolt" }, [ + Item("Media", { icon: "fa-photo" }, require("./admin/actions/media")), + Item("Keys", { icon: "fa-key-modern" }, require("./admin/actions/keys")), + ]), Menu("Custom Emoji", { icon: "fa-smile-o" }, [ Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")), Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote")) @@ -63,7 +66,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [ Menu("Settings", { icon: "fa-sliders" }, [ Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")), Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules")) - ]) + ]), ]) ]); diff --git a/web/source/settings/lib/query/admin/index.js b/web/source/settings/lib/query/admin/index.js index 515d8edcf..7b46e6ba4 100644 --- a/web/source/settings/lib/query/admin/index.js +++ b/web/source/settings/lib/query/admin/index.js @@ -47,6 +47,15 @@ const endpoints = (build) => ({ } }) }), + instanceKeysExpire: build.mutation({ + query: (domain) => ({ + method: "POST", + url: `/api/v1/admin/domain_keys_expire`, + params: { + domain: domain + } + }) + }), instanceBlocks: build.query({ query: () => ({ url: `/api/v1/admin/domain_blocks` -- cgit v1.2.3