mirror of
1
Fork 0

[feature] Allow admins to expire remote public keys; refetch expired keys on demand (#2183)

This commit is contained in:
tobi 2023-09-12 11:43:12 +02:00 committed by GitHub
parent 2cac5a4613
commit 4b594516ec
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 841 additions and 117 deletions

View File

@ -445,6 +445,19 @@ definitions:
type: object type: object
x-go-name: AdminAccountInfo x-go-name: AdminAccountInfo
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: adminEmoji:
properties: properties:
category: category:
@ -1018,6 +1031,16 @@ definitions:
type: object type: object
x-go-name: DomainBlockCreateRequest x-go-name: DomainBlockCreateRequest
x-go-package: github.com/superseriousbusiness/gotosocial/internal/api/model 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: emoji:
properties: properties:
category: category:
@ -4103,6 +4126,56 @@ paths:
summary: View domain block with the given ID. summary: View domain block with the given ID.
tags: tags:
- admin - 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: /api/v1/admin/email/test:
post: post:
consumes: consumes:

View File

@ -31,6 +31,7 @@ const (
EmojiCategoriesPath = EmojiPath + "/categories" EmojiCategoriesPath = EmojiPath + "/categories"
DomainBlocksPath = BasePath + "/domain_blocks" DomainBlocksPath = BasePath + "/domain_blocks"
DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey DomainBlocksPathWithID = DomainBlocksPath + "/:" + IDKey
DomainKeysExpirePath = BasePath + "/domain_keys_expire"
AccountsPath = BasePath + "/accounts" AccountsPath = BasePath + "/accounts"
AccountsPathWithID = AccountsPath + "/:" + IDKey AccountsPathWithID = AccountsPath + "/:" + IDKey
AccountsActionPath = AccountsPathWithID + "/action" 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.MethodGet, DomainBlocksPathWithID, m.DomainBlockGETHandler)
attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler) attachHandler(http.MethodDelete, DomainBlocksPathWithID, m.DomainBlockDELETEHandler)
// domain maintenance stuff
attachHandler(http.MethodPost, DomainKeysExpirePath, m.DomainKeysExpirePOSTHandler)
// accounts stuff // accounts stuff
attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler) attachHandler(http.MethodPost, AccountsActionPath, m.AccountActionPOSTHandler)

View File

@ -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 <http://www.gnu.org/licenses/>.
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
}

View File

@ -178,6 +178,17 @@ type AdminActionRequest struct {
TargetID string `form:"-" json:"-" xml:"-"` 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 // MediaCleanupRequest models admin media cleanup parameters
// //
// swagger:parameters mediaCleanup // swagger:parameters mediaCleanup

View File

@ -79,3 +79,11 @@ type DomainBlockCreateRequest struct {
// public comment on the reason for the domain block // public comment on the reason for the domain block
PublicComment string `form:"public_comment" json:"public_comment" xml:"public_comment"` 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"`
}

View File

@ -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 <http://www.gnu.org/licenses/>.
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)
}
}

View File

@ -25,14 +25,17 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
"time"
"codeberg.org/gruf/go-kv" "codeberg.org/gruf/go-kv"
"github.com/go-fed/httpsig" "github.com/go-fed/httpsig"
"github.com/superseriousbusiness/activity/streams" "github.com/superseriousbusiness/activity/streams"
"github.com/superseriousbusiness/gotosocial/internal/ap" "github.com/superseriousbusiness/gotosocial/internal/ap"
"github.com/superseriousbusiness/gotosocial/internal/config" "github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
"github.com/superseriousbusiness/gotosocial/internal/gtscontext" "github.com/superseriousbusiness/gotosocial/internal/gtscontext"
"github.com/superseriousbusiness/gotosocial/internal/gtserror" "github.com/superseriousbusiness/gotosocial/internal/gtserror"
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
"github.com/superseriousbusiness/gotosocial/internal/log" "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 // AuthenticateFederatedRequest authenticates any kind of incoming federated
// request from a remote server. This includes things like GET requests for // request from a remote server. This includes things like GET requests for
// dereferencing our users or statuses etc, and POST requests for delivering // 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 // new Activities. The function returns details of the public key(s) used to
// used in the requesting http signature. // authenticate the requesting http signature.
// //
// 'Authenticate' in this case is defined as making sure that the http request // '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 // 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 // Also note that this function *does not* dereference the remote account that
// the signature key is associated with. Other functions should use the returned // the signature key is associated with. Other functions should use the returned
// URL to dereference the remote account, if required. // 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, // Thanks to the signature check middleware,
// we should already have an http signature // we should already have an http signature
// verifier set on the context. If we don't, // verifier set on the context. If we don't,
@ -103,8 +142,8 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
var ( var (
pubKeyIDStr = pubKeyID.String() pubKeyIDStr = pubKeyID.String()
requestingAccountURI *url.URL local = (pubKeyID.Host == config.GetHost())
pubKey interface{} pubKeyAuth *PubKeyAuth
errWithCode gtserror.WithCode errWithCode gtserror.WithCode
) )
@ -115,38 +154,50 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
{"pubKeyID", pubKeyIDStr}, {"pubKeyID", pubKeyIDStr},
}...) }...)
if pubKeyID.Host == config.GetHost() { if local {
l.Trace("public key is ours, no dereference needed") l.Trace("public key is local, no dereference needed")
requestingAccountURI, pubKey, errWithCode = f.derefDBOnly(ctx, pubKeyIDStr) pubKeyAuth, errWithCode = f.derefPubKeyDBOnly(ctx, pubKeyIDStr)
} else { } else {
l.Trace("public key is not ours, checking if we need to dereference") l.Trace("public key is remote, checking if we need to dereference")
requestingAccountURI, pubKey, errWithCode = f.deref(ctx, requestedUsername, pubKeyIDStr, pubKeyID) pubKeyAuth, errWithCode = f.derefPubKey(ctx, requestedUsername, pubKeyIDStr, pubKeyID)
} }
if errWithCode != nil { if errWithCode != nil {
return nil, errWithCode return nil, errWithCode
} }
// Ensure public key now defined. if local && pubKeyAuth == nil {
if pubKey == nil { // We signed this request, apparently, but
err := gtserror.New("public key was nil") // 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) return nil, gtserror.NewErrorInternalError(err)
} }
// Try to authenticate using permitted algorithms in // Try to authenticate using permitted algorithms in
// order of most -> least common. Return OK as soon // order of most -> least common, checking each defined
// as one passes. // 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
}
for _, algo := range signingAlgorithms { for _, algo := range signingAlgorithms {
l.Tracef("trying %s", algo) l.Tracef("trying %s", algo)
err := verifier.Verify(pubKey, algo) err := verifier.Verify(pubKey, algo)
if err == nil { if err == nil {
l.Tracef("authentication PASSED with %s", algo) l.Tracef("authentication PASSED with %s", algo)
return requestingAccountURI, nil return pubKeyAuth, nil
} }
l.Tracef("authentication NOT PASSED with %s: %q", algo, err) l.Tracef("authentication NOT PASSED with %s: %q", algo, err)
} }
}
// At this point no algorithms passed. // At this point no algorithms passed.
err := gtserror.Newf( err := gtserror.Newf(
@ -157,36 +208,52 @@ func (f *federator) AuthenticateFederatedRequest(ctx context.Context, requestedU
return nil, gtserror.NewErrorUnauthorized(err, err.Error()) return nil, gtserror.NewErrorUnauthorized(err, err.Error())
} }
// derefDBOnly tries to dereference the given public // derefPubKeyDBOnly tries to dereference the given
// key using only entries already in the database. // pubKey using only entries already in the database.
func (f *federator) derefDBOnly( //
// 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, ctx context.Context,
pubKeyIDStr string, pubKeyIDStr string,
) (*url.URL, interface{}, gtserror.WithCode) { ) (*PubKeyAuth, gtserror.WithCode) {
reqAcct, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr) owner, err := f.db.GetAccountByPubkeyID(ctx, pubKeyIDStr)
if err != nil { if err != nil {
err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err) if errors.Is(err, db.ErrNoEntries) {
return nil, nil, gtserror.NewErrorInternalError(err) // We don't have this
// account stored (yet).
return nil, nil
} }
reqAcctURI, err := url.Parse(reqAcct.URI) err = gtserror.Newf("db error getting account with pubKeyID %s: %w", pubKeyIDStr, err)
return nil, gtserror.NewErrorInternalError(err)
}
ownerURI, err := url.Parse(owner.URI)
if err != nil { if err != nil {
err = gtserror.Newf("error parsing account uri with pubKeyID %s: %w", pubKeyIDStr, err) 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 // derefPubKey tries to dereference the given public key by first
// checking in the database, and then (if no entries found) // checking in the database, and then (if no entry found, or entry
// calling the remote pub key URI and extracting the key. // found but pubKey expired) calling the remote pub key URI and
func (f *federator) deref( // extracting the key.
func (f *federator) derefPubKey(
ctx context.Context, ctx context.Context,
requestedUsername string, requestedUsername string,
pubKeyIDStr string, pubKeyIDStr string,
pubKeyID *url.URL, pubKeyID *url.URL,
) (*url.URL, interface{}, gtserror.WithCode) { ) (*PubKeyAuth, gtserror.WithCode) {
l := log. l := log.
WithContext(ctx). WithContext(ctx).
WithFields(kv.Fields{ WithFields(kv.Fields{
@ -196,42 +263,101 @@ func (f *federator) deref(
// Try a database only deref first. We may already // Try a database only deref first. We may already
// have the requesting account cached locally. // have the requesting account cached locally.
reqAcctURI, pubKey, errWithCode := f.derefDBOnly(ctx, pubKeyIDStr) pubKeyAuth, errWithCode := f.derefPubKeyDBOnly(ctx, pubKeyIDStr)
if errWithCode == nil { if errWithCode != nil {
l.Trace("public key cached, no dereference needed") return nil, errWithCode
return reqAcctURI, pubKey, nil
} }
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 // If we've tried to get this account before and we
// now have a tombstone for it (ie., it's been deleted // now have a tombstone for it (ie., it's been deleted
// from remote), don't try to dereference it again. // from remote), don't try to dereference it again.
gone, err := f.CheckGone(ctx, pubKeyID) gone, err := f.CheckGone(ctx, pubKeyID)
if err != nil { if err != nil {
err := gtserror.Newf("error checking for tombstone for %s: %w", pubKeyIDStr, err) err := gtserror.Newf("error checking for tombstone (%s): %w", pubKeyIDStr, err)
return nil, nil, gtserror.NewErrorInternalError(err) return nil, gtserror.NewErrorInternalError(err)
} }
if gone { if gone {
err := gtserror.Newf("account with public key %s is gone", pubKeyIDStr) err := gtserror.Newf("account with public key is gone (%s)", pubKeyIDStr)
return nil, nil, gtserror.NewErrorGone(err) 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) pubKeyBytes, errWithCode := f.callForPubKey(ctx, requestedUsername, pubKeyID)
if errWithCode != nil { if errWithCode != nil {
return nil, nil, errWithCode return nil, errWithCode
} }
// Extract the key and the owner from the response. // Extract the key and the owner from the response.
pubKey, pubKeyOwner, err := parsePubKeyBytes(ctx, pubKeyBytes, pubKeyID) pubKey, pubKeyOwner, err := parsePubKeyBytes(ctx, pubKeyBytes, pubKeyID)
if err != nil { if err != nil {
err := fmt.Errorf("error parsing public key %s: %w", pubKeyID, err) err := fmt.Errorf("error parsing public key (%s): %w", pubKeyID, err)
return nil, nil, gtserror.NewErrorUnauthorized(err) return nil, gtserror.NewErrorUnauthorized(err)
} }
return pubKeyOwner, pubKey, nil 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 both new and cached (now
// expired) keys, authentication
// will be attempted with both.
return pubKeyAuth, nil
} }
// callForPubKey handles the nitty gritty of actually // callForPubKey handles the nitty gritty of actually

View File

@ -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. // 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 { if errWithCode != nil {
switch errWithCode.Code() { switch errWithCode.Code() {
case http.StatusUnauthorized, http.StatusForbidden, http.StatusBadRequest: 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 // Authentication has passed, check if we need to create a
// new instance entry for the Host of the requesting account. // 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) { if !errors.Is(err, db.ErrNoEntries) {
// There's been an actual error. // 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 return ctx, false, err
} }
@ -247,17 +249,17 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
gtscontext.SetFastFail(ctx), gtscontext.SetFastFail(ctx),
username, username,
&url.URL{ &url.URL{
Scheme: pubKeyOwner.Scheme, Scheme: pubKeyOwnerURI.Scheme,
Host: pubKeyOwner.Host, Host: pubKeyOwnerURI.Host,
}, },
) )
if err != nil { 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 return nil, false, err
} }
if err := f.db.PutInstance(ctx, instance); err != nil && !errors.Is(err, db.ErrAlreadyExists) { 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 return nil, false, err
} }
} }
@ -268,7 +270,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
requestingAccount, _, err := f.GetAccountByURI( requestingAccount, _, err := f.GetAccountByURI(
gtscontext.SetFastFail(ctx), gtscontext.SetFastFail(ctx),
username, username,
pubKeyOwner, pubKeyOwnerURI,
) )
if err != nil { if err != nil {
if gtserror.StatusCode(err) == http.StatusGone { if gtserror.StatusCode(err) == http.StatusGone {
@ -282,7 +284,7 @@ func (f *federator) AuthenticatePostInbox(ctx context.Context, w http.ResponseWr
return ctx, false, nil 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 return nil, false, err
} }

View File

@ -257,6 +257,33 @@ func (suite *FederatingProtocolTestSuite) TestAuthenticatePostInbox() {
suite.Equal(http.StatusOK, code) 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 := &gtsmodel.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() { func (suite *FederatingProtocolTestSuite) TestAuthenticatePostGoneWithTombstone() {
var ( var (
activity = suite.testActivities["delete_https://somewhere.mysterious/users/rest_in_piss#main-key"] activity = suite.testActivities["delete_https://somewhere.mysterious/users/rest_in_piss#main-key"]

View File

@ -19,7 +19,6 @@ package federation
import ( import (
"context" "context"
"net/url"
"github.com/superseriousbusiness/activity/pub" "github.com/superseriousbusiness/activity/pub"
"github.com/superseriousbusiness/gotosocial/internal/db" "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 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. // 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.CommonBehavior
pub.FederatingProtocol pub.FederatingProtocol

View File

@ -18,6 +18,8 @@
package federation_test package federation_test
import ( import (
"context"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"github.com/superseriousbusiness/gotosocial/internal/federation" "github.com/superseriousbusiness/gotosocial/internal/federation"
@ -71,7 +73,14 @@ func (suite *FederatorStandardTestSuite) SetupTest() {
suite.typeconverter, 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.TestRemotePeople = testrig.NewTestFediPeople()
suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses() suite.httpClient.TestRemoteStatuses = testrig.NewTestFediStatuses()

View File

@ -72,9 +72,10 @@ type Account struct {
FollowersURI string `bun:",nullzero,unique"` // URI for getting the followers list of this account 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 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? 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 PrivateKey *rsa.PrivateKey `bun:""` // Privatekey for signing 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 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 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? 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)? 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) 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 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. // AccountToEmoji is an intermediate struct to facilitate the many2many relationship between an account and one or more emojis.
type AccountToEmoji struct { type AccountToEmoji struct {
AccountID string `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"` AccountID string `bun:"type:CHAR(26),unique:accountemoji,nullzero,notnull"`

View File

@ -72,6 +72,7 @@ const (
AdminActionUnsilence AdminActionUnsilence
AdminActionSuspend AdminActionSuspend
AdminActionUnsuspend AdminActionUnsuspend
AdminActionExpireKeys
) )
func (t AdminActionType) String() string { func (t AdminActionType) String() string {
@ -88,6 +89,8 @@ func (t AdminActionType) String() string {
return "suspend" return "suspend"
case AdminActionUnsuspend: case AdminActionUnsuspend:
return "unsuspend" return "unsuspend"
case AdminActionExpireKeys:
return "expire-keys"
default: default:
return "unknown" return "unknown"
} }
@ -107,6 +110,8 @@ func NewAdminActionType(in string) AdminActionType {
return AdminActionSuspend return AdminActionSuspend
case "unsuspend": case "unsuspend":
return AdminActionUnsuspend return AdminActionUnsuspend
case "expire-keys":
return AdminActionExpireKeys
default: default:
return AdminActionUnknown return AdminActionUnknown
} }

View File

@ -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 <http://www.gnu.org/licenses/>.
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,
&gtsmodel.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
}

View File

@ -48,7 +48,7 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string)
// Ensure request signed, and use signature URI to // Ensure request signed, and use signature URI to
// get requesting account, dereferencing if necessary. // get requesting account, dereferencing if necessary.
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil { if errWithCode != nil {
return nil, nil, errWithCode return nil, nil, errWithCode
} }
@ -56,10 +56,10 @@ func (p *Processor) authenticate(ctx context.Context, requestedUsername string)
requestingAccount, _, err := p.federator.GetAccountByURI( requestingAccount, _, err := p.federator.GetAccountByURI(
gtscontext.SetFastFail(ctx), gtscontext.SetFastFail(ctx),
requestedUsername, requestedUsername,
requestingAccountURI, pubKeyAuth.OwnerURI,
) )
if err != nil { 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) return nil, nil, gtserror.NewErrorUnauthorized(err)
} }

View File

@ -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 // If the request is not on a public key path, we want to
// try to authenticate it before we serve any data, so that // try to authenticate it before we serve any data, so that
// we can serve a more complete profile. // we can serve a more complete profile.
requestingAccountURI, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername) pubKeyAuth, errWithCode := p.federator.AuthenticateFederatedRequest(ctx, requestedUsername)
if errWithCode != nil { if errWithCode != nil {
return nil, errWithCode // likely 401 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 // 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 // yours' situation, where we sort of agree to reveal each
// other's profiles at the same time. // other's profiles at the same time.
if p.federator.Handshaking(requestedUsername, requestingAccountURI) { if p.federator.Handshaking(requestedUsername, pubKeyAuth.OwnerURI) {
return data(person) return data(person)
} }
@ -98,10 +98,11 @@ func (p *Processor) UserGet(ctx context.Context, requestedUsername string, reque
requestingAccount, _, err := p.federator.GetAccountByURI( requestingAccount, _, err := p.federator.GetAccountByURI(
// On a hot path so fail quickly. // On a hot path so fail quickly.
gtscontext.SetFastFail(ctx), gtscontext.SetFastFail(ctx),
requestedUsername, requestingAccountURI, requestedUsername,
pubKeyAuth.OwnerURI,
) )
if err != nil { 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) return nil, gtserror.NewErrorUnauthorized(err)
} }

View File

@ -78,7 +78,7 @@ type MockHTTPClient struct {
// to customize how the client is mocked. // to customize how the client is mocked.
// //
// Note that you should never ever make ACTUAL http calls with this thing. // 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{} mockHTTPClient := &MockHTTPClient{}
if do != nil { if do != nil {
@ -95,10 +95,13 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
mockHTTPClient.TestTombstones = NewTestTombstones() mockHTTPClient.TestTombstones = NewTestTombstones()
mockHTTPClient.do = func(req *http.Request) (*http.Response, error) { mockHTTPClient.do = func(req *http.Request) (*http.Response, error) {
responseCode := http.StatusNotFound var (
responseBytes := []byte(`{"error":"404 not found"}`) responseCode = http.StatusNotFound
responseContentType := applicationJSON responseBytes = []byte(`{"error":"404 not found"}`)
responseContentLength := len(responseBytes) responseContentType = applicationJSON
responseContentLength = len(responseBytes)
reqURLString = req.URL.String()
)
if req.Method == http.MethodPost { if req.Method == http.MethodPost {
b, err := io.ReadAll(req.Body) b, err := io.ReadAll(req.Body)
@ -106,26 +109,26 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
panic(err) 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) s, ok := sI.([][]byte)
if !ok { if !ok {
panic("SentMessages entry wasn't [][]byte") panic("SentMessages entry wasn't [][]byte")
} }
s = append(s, b) s = append(s, b)
mockHTTPClient.SentMessages.Store(req.URL.String(), s) mockHTTPClient.SentMessages.Store(reqURLString, s)
} }
responseCode = http.StatusOK responseCode = http.StatusOK
responseBytes = []byte(`{"ok":"accepted"}`) responseBytes = []byte(`{"ok":"accepted"}`)
responseContentType = applicationJSON responseContentType = applicationJSON
responseContentLength = len(responseBytes) 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) 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) 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) 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 // the request is for a note that we have stored
noteI, err := streams.Serialize(note) noteI, err := streams.Serialize(note)
if err != nil { if err != nil {
@ -139,7 +142,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = noteJSON responseBytes = noteJSON
responseContentType = applicationActivityJSON responseContentType = applicationActivityJSON
responseContentLength = len(noteJSON) 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 // the request is for a person that we have stored
personI, err := streams.Serialize(person) personI, err := streams.Serialize(person)
if err != nil { if err != nil {
@ -153,7 +156,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = personJSON responseBytes = personJSON
responseContentType = applicationActivityJSON responseContentType = applicationActivityJSON
responseContentLength = len(personJSON) 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 // the request is for a person that we have stored
groupI, err := streams.Serialize(group) groupI, err := streams.Serialize(group)
if err != nil { if err != nil {
@ -167,7 +170,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = groupJSON responseBytes = groupJSON
responseContentType = applicationActivityJSON responseContentType = applicationActivityJSON
responseContentLength = len(groupJSON) 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) serviceI, err := streams.Serialize(service)
if err != nil { if err != nil {
panic(err) panic(err)
@ -180,7 +183,7 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = serviceJSON responseBytes = serviceJSON
responseContentType = applicationActivityJSON responseContentType = applicationActivityJSON
responseContentLength = len(serviceJSON) 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) emojiI, err := streams.Serialize(emoji)
if err != nil { if err != nil {
panic(err) panic(err)
@ -193,16 +196,45 @@ func NewMockHTTPClient(do func(req *http.Request) (*http.Response, error), relat
responseBytes = emojiJSON responseBytes = emojiJSON
responseContentType = applicationActivityJSON responseContentType = applicationActivityJSON
responseContentLength = len(emojiJSON) responseContentLength = len(emojiJSON)
} else if attachment, ok := mockHTTPClient.TestRemoteAttachments[req.URL.String()]; ok { } else if attachment, ok := mockHTTPClient.TestRemoteAttachments[reqURLString]; ok {
responseCode = http.StatusOK responseCode = http.StatusOK
responseBytes = attachment.Data responseBytes = attachment.Data
responseContentType = attachment.ContentType responseContentType = attachment.ContentType
responseContentLength = len(attachment.Data) responseContentLength = len(attachment.Data)
} else if _, ok := mockHTTPClient.TestTombstones[req.URL.String()]; ok { } else if _, ok := mockHTTPClient.TestTombstones[reqURLString]; ok {
responseCode = http.StatusGone responseCode = http.StatusGone
responseBytes = []byte{} responseBytes = []byte{}
responseContentType = "text/html" responseContentType = "text/html"
responseContentLength = 0 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)) log.Debugf(nil, "returning response %s", string(responseBytes))

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
"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 (
<form onSubmit={submitExpire}>
<h2>Expire remote instance keys</h2>
<p>
Mark all public keys from the given remote instance as expired.<br/><br/>
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.
</p>
<TextInput
field={domainField}
label="Domain"
type="string"
placeholder="example.org"
/>
<MutationButton label="Expire keys" result={expireResult} />
</form>
);
};

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const ExpireRemote = require("./expireremote");
module.exports = function Keys() {
return (
<>
<h1>Key Actions</h1>
<ExpireRemote />
</>
);
};

View File

@ -21,28 +21,26 @@
const React = require("react"); const React = require("react");
const query = require("../lib/query"); const query = require("../../../lib/query");
const { useTextInput } = require("../lib/form"); const { useTextInput } = require("../../../lib/form");
const { TextInput } = require("../components/form/inputs"); const { TextInput } = require("../../../components/form/inputs");
const MutationButton = require("../components/form/mutation-button"); const MutationButton = require("../../../components/form/mutation-button");
module.exports = function AdminActionPanel() { module.exports = function Cleanup({}) {
const daysField = useTextInput("days", { defaultValue: 30 }); const daysField = useTextInput("days", { defaultValue: 30 });
const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation(); const [mediaCleanup, mediaCleanupResult] = query.useMediaCleanupMutation();
function submitMediaCleanup(e) { function submitCleanup(e) {
e.preventDefault(); e.preventDefault();
mediaCleanup(daysField.value); mediaCleanup(daysField.value);
} }
return ( return (
<> <form onSubmit={submitCleanup}>
<h1>Admin Actions</h1> <h2>Cleanup</h2>
<form onSubmit={submitMediaCleanup}>
<h2>Media cleanup</h2>
<p> <p>
Clean up remote media older than the specified number of days. Clean up remote media older than the specified number of days.
If the remote instance is still online they will be refetched when needed. If the remote instance is still online they will be refetched when needed.
@ -57,6 +55,5 @@ module.exports = function AdminActionPanel() {
/> />
<MutationButton label="Remove old media" result={mediaCleanupResult} /> <MutationButton label="Remove old media" result={mediaCleanupResult} />
</form> </form>
</>
); );
}; };

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
"use strict";
const React = require("react");
const Cleanup = require("./cleanup");
module.exports = function Media() {
return (
<>
<h1>Media Actions</h1>
<Cleanup />
</>
);
};

View File

@ -55,7 +55,10 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
defaultUrl: "/settings/admin/settings", defaultUrl: "/settings/admin/settings",
permissions: ["admin"] 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" }, [ Menu("Custom Emoji", { icon: "fa-smile-o" }, [
Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")), Item("Local", { icon: "fa-home", wildcard: true }, require("./admin/emoji/local")),
Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote")) Item("Remote", { icon: "fa-cloud" }, require("./admin/emoji/remote"))
@ -63,7 +66,7 @@ const { Sidebar, ViewRouter } = createNavigation("/settings", [
Menu("Settings", { icon: "fa-sliders" }, [ Menu("Settings", { icon: "fa-sliders" }, [
Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")), Item("Settings", { icon: "fa-sliders", url: "" }, require("./admin/settings")),
Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules")) Item("Rules", { icon: "fa-dot-circle-o", wildcard: true }, require("./admin/settings/rules"))
]) ]),
]) ])
]); ]);

View File

@ -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({ instanceBlocks: build.query({
query: () => ({ query: () => ({
url: `/api/v1/admin/domain_blocks` url: `/api/v1/admin/domain_blocks`