mirror of
https://github.com/superseriousbusiness/gotosocial.git
synced 2025-12-23 03:16:16 -06:00
[feature] Discover webfinger through host-meta (#1588)
* [feature] Discover webfinger through host-meta This implements a fallback for discovering the webfinger endpoint in case the /.well-known/webfinger endpoint wasn't properly redirected. Some instances do this because the recommendation used to be to use host-meta for the webfinger redirect in the before times. Closes #1558. * [bug] Ensure we only ever update cache on success * [chore] Move finger tests to their own place This adds a test suite for transport and moves the finger cache tests into there instead of abusing the search test suite. * [chore] cleanup the test a bit more We don't really need a separate function for the oddly located webfinger response as we check the full URL string anyway * Address review comments * [chore] update config example * [chore] access DB only through state in controller
This commit is contained in:
parent
b344c2c8f4
commit
e397272fe8
13 changed files with 563 additions and 30 deletions
|
|
@ -32,9 +32,9 @@ import (
|
|||
"github.com/superseriousbusiness/activity/pub"
|
||||
"github.com/superseriousbusiness/activity/streams"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/config"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation/federatingdb"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/log"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
)
|
||||
|
||||
// Controller generates transports for use in making federation requests to other servers.
|
||||
|
|
@ -47,7 +47,7 @@ type Controller interface {
|
|||
}
|
||||
|
||||
type controller struct {
|
||||
db db.DB
|
||||
state *state.State
|
||||
fedDB federatingdb.DB
|
||||
clock pub.Clock
|
||||
client pub.HttpClient
|
||||
|
|
@ -57,14 +57,14 @@ type controller struct {
|
|||
}
|
||||
|
||||
// NewController returns an implementation of the Controller interface for creating new transports
|
||||
func NewController(db db.DB, federatingDB federatingdb.DB, clock pub.Clock, client pub.HttpClient) Controller {
|
||||
func NewController(state *state.State, federatingDB federatingdb.DB, clock pub.Clock, client pub.HttpClient) Controller {
|
||||
applicationName := config.GetApplicationName()
|
||||
host := config.GetHost()
|
||||
proto := config.GetProtocol()
|
||||
version := config.GetSoftwareVersion()
|
||||
|
||||
c := &controller{
|
||||
db: db,
|
||||
state: state,
|
||||
fedDB: federatingDB,
|
||||
clock: clock,
|
||||
client: client,
|
||||
|
|
@ -138,7 +138,7 @@ func (c *controller) NewTransportForUsername(ctx context.Context, username strin
|
|||
u = username
|
||||
}
|
||||
|
||||
ourAccount, err := c.db.GetAccountByUsernameDomain(ctx, u, "")
|
||||
ourAccount, err := c.state.DB.GetAccountByUsernameDomain(ctx, u, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting account %s from db: %s", username, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,29 +20,61 @@ package transport
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
|
||||
apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
|
||||
)
|
||||
|
||||
func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) {
|
||||
// Prepare URL string
|
||||
urlStr := "https://" +
|
||||
targetDomain +
|
||||
"/.well-known/webfinger?resource=acct:" +
|
||||
targetUsername + "@" + targetDomain
|
||||
// webfingerURLFor returns the URL to try a webfinger request against, as
|
||||
// well as if the URL was retrieved from cache. When the URL is retrieved
|
||||
// from cache we don't have to try and do host-meta discovery
|
||||
func (t *transport) webfingerURLFor(targetDomain string) (string, bool) {
|
||||
url := "https://" + targetDomain + "/.well-known/webfinger"
|
||||
|
||||
// Generate new GET request from URL string
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", urlStr, nil)
|
||||
wc := t.controller.state.Caches.GTS.Webfinger()
|
||||
// We're doing the manual locking/unlocking here to be able to
|
||||
// safely call Cache.Get instead of Get, as the latter updates the
|
||||
// item expiry which we don't want to do here
|
||||
wc.Lock()
|
||||
item, ok := wc.Cache.Get(targetDomain)
|
||||
wc.Unlock()
|
||||
|
||||
if ok {
|
||||
url = item.Value
|
||||
}
|
||||
|
||||
return url, ok
|
||||
}
|
||||
|
||||
func prepWebfingerReq(ctx context.Context, loc, domain, username string) (*http.Request, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, loc, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
value := url.QueryEscape("acct:" + username + "@" + domain)
|
||||
req.URL.RawQuery = "resource=" + value
|
||||
|
||||
req.Header.Add("Accept", string(apiutil.AppJSON))
|
||||
req.Header.Add("Accept", "application/jrd+json")
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (t *transport) Finger(ctx context.Context, targetUsername string, targetDomain string) ([]byte, error) {
|
||||
// Generate new GET request
|
||||
url, cached := t.webfingerURLFor(targetDomain)
|
||||
req, err := prepWebfingerReq(ctx, url, targetDomain, targetUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Perform the HTTP request
|
||||
rsp, err := t.GET(req)
|
||||
if err != nil {
|
||||
|
|
@ -50,10 +82,117 @@ func (t *transport) Finger(ctx context.Context, targetUsername string, targetDom
|
|||
}
|
||||
defer rsp.Body.Close()
|
||||
|
||||
// Check for an expected status code
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("GET request to %s failed: %s", urlStr, rsp.Status)
|
||||
// Check if the request succeeded so we can bail out early
|
||||
if rsp.StatusCode == http.StatusOK {
|
||||
if cached {
|
||||
// If we got a success on a cached URL, i.e one set by us later on when
|
||||
// a host-meta based webfinger request succeeded, set it again here to
|
||||
// renew the TTL
|
||||
t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, url)
|
||||
}
|
||||
return io.ReadAll(rsp.Body)
|
||||
}
|
||||
|
||||
// From here on out, we're handling different failure scenarios and
|
||||
// deciding whether we should do a host-meta based fallback or not
|
||||
|
||||
if (rsp.StatusCode >= 500 && rsp.StatusCode < 600) || cached {
|
||||
// In case we got a 5xx, bail out irrespective of if the value
|
||||
// was cached or not. The target may be broken or be signalling
|
||||
// us to back-off.
|
||||
//
|
||||
// If it's any error but the URL was cached, bail out too
|
||||
return nil, fmt.Errorf("GET request to %s failed: %s", req.URL.String(), rsp.Status)
|
||||
}
|
||||
|
||||
// So far we've failed to get a successful response from the expected
|
||||
// webfinger endpoint. Lets try and discover the webfinger endpoint
|
||||
// through /.well-known/host-meta
|
||||
host, err := t.webfingerFromHostMeta(ctx, targetDomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to discover webfinger URL fallback for: %s through host-meta: %w", targetDomain, err)
|
||||
}
|
||||
|
||||
// Check if the original and host-meta URL are the same. If they
|
||||
// are there's no sense in us trying the request again as it just
|
||||
// failed
|
||||
if host == url {
|
||||
return nil, fmt.Errorf("webfinger discovery on %s returned endpoint we already tried: %s", targetDomain, host)
|
||||
}
|
||||
|
||||
// Now that we have a different URL for the webfinger
|
||||
// endpoint, try the request against that endpoint instead
|
||||
req, err = prepWebfingerReq(ctx, host, targetDomain, targetUsername)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Perform the HTTP request
|
||||
rsp, err = t.GET(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rsp.Body.Close()
|
||||
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
// We've reached the end of the line here, both the original request
|
||||
// and our attempt to resolve it through the fallback have failed
|
||||
return nil, fmt.Errorf("GET request to %s failed: %s", req.URL.String(), rsp.Status)
|
||||
}
|
||||
|
||||
// Set the URL in cache here, since host-meta told us this should be the
|
||||
// valid one, it's different from the default and our request to it did
|
||||
// not fail in any manner
|
||||
t.controller.state.Caches.GTS.Webfinger().Set(targetDomain, host)
|
||||
|
||||
return io.ReadAll(rsp.Body)
|
||||
}
|
||||
|
||||
func (t *transport) webfingerFromHostMeta(ctx context.Context, targetDomain string) (string, error) {
|
||||
// Build the request for the host-meta endpoint
|
||||
hmurl := "https://" + targetDomain + "/.well-known/host-meta"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hmurl, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// We're doing XML
|
||||
req.Header.Add("Accept", string(apiutil.AppXML))
|
||||
req.Header.Add("Accept", "application/xrd+xml")
|
||||
req.Header.Set("Host", req.URL.Host)
|
||||
|
||||
// Perform the HTTP request
|
||||
rsp, err := t.GET(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rsp.Body.Close()
|
||||
|
||||
// Doesn't look like host-meta is working for this instance
|
||||
if rsp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GET request for %s failed: %s", req.URL.String(), rsp.Status)
|
||||
}
|
||||
|
||||
e := xml.NewDecoder(rsp.Body)
|
||||
var hm apimodel.HostMeta
|
||||
if err := e.Decode(&hm); err != nil {
|
||||
// We got something, but it's not a host-meta document we understand
|
||||
return "", fmt.Errorf("failed to decode host-meta response for %s at %s: %w", targetDomain, req.URL.String(), err)
|
||||
}
|
||||
|
||||
for _, link := range hm.Link {
|
||||
// Based on what we currently understand, there should not be more than one
|
||||
// of these with Rel="lrdd" in a host-meta document
|
||||
if link.Rel == "lrdd" {
|
||||
u, err := url.Parse(link.Template)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("lrdd link is not a valid url: %w", err)
|
||||
}
|
||||
// Get rid of the query template, we only want the scheme://host/path part
|
||||
u.RawQuery = ""
|
||||
urlStr := u.String()
|
||||
return urlStr, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no webfinger URL found")
|
||||
}
|
||||
|
|
|
|||
118
internal/transport/finger_test.go
Normal file
118
internal/transport/finger_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
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 transport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
|
||||
type FingerTestSuite struct {
|
||||
TransportTestSuite
|
||||
}
|
||||
|
||||
func (suite *FingerTestSuite) TestFinger() {
|
||||
wc := suite.state.Caches.GTS.Webfinger()
|
||||
suite.Equal(0, wc.Len(), "expect webfinger cache to be empty")
|
||||
|
||||
_, err := suite.transport.Finger(context.TODO(), "brand_new_person", "unknown-instance.com")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(0, wc.Len(), "expect webfinger cache to be empty for normal webfinger request")
|
||||
}
|
||||
|
||||
func (suite *FingerTestSuite) TestFingerWithHostMeta() {
|
||||
wc := suite.state.Caches.GTS.Webfinger()
|
||||
suite.Equal(0, wc.Len(), "expect webfinger cache to be empty")
|
||||
|
||||
_, err := suite.transport.Finger(context.TODO(), "someone", "misconfigured-instance.com")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(1, wc.Len(), "expect webfinger cache to hold one entry")
|
||||
suite.True(wc.Has("misconfigured-instance.com"), "expect webfinger cache to have entry for misconfigured-instance.com")
|
||||
}
|
||||
|
||||
func (suite *FingerTestSuite) TestFingerWithHostMetaCacheStrategy() {
|
||||
wc := suite.state.Caches.GTS.Webfinger()
|
||||
suite.Equal(0, wc.Len(), "expect webfinger cache to be empty")
|
||||
|
||||
_, err := suite.transport.Finger(context.TODO(), "someone", "misconfigured-instance.com")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
suite.Equal(1, wc.Len(), "expect webfinger cache to hold one entry")
|
||||
wc.Lock()
|
||||
suite.True(wc.Cache.Has("misconfigured-instance.com"), "expect webfinger cache to have entry for misconfigured-instance.com")
|
||||
ent, _ := wc.Cache.Get("misconfigured-instance.com")
|
||||
wc.Unlock()
|
||||
|
||||
initialTime := ent.Expiry
|
||||
|
||||
// finger them again
|
||||
_, err = suite.transport.Finger(context.TODO(), "someone", "misconfigured-instance.com")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
|
||||
// there should still only be 1 cache entry
|
||||
suite.Equal(1, wc.Len(), "expect webfinger cache to hold one entry")
|
||||
wc.Lock()
|
||||
suite.True(wc.Cache.Has("misconfigured-instance.com"), "expect webfinger cache to have entry for misconfigured-instance.com")
|
||||
rep, _ := wc.Cache.Get("misconfigured-instance.com")
|
||||
wc.Unlock()
|
||||
|
||||
repeatTime := rep.Expiry
|
||||
|
||||
// the TTL of the entry should have extended because we did a second
|
||||
// successful finger
|
||||
suite.NotEqual(initialTime, repeatTime, "expected webfinger cache entry to have different expiry times")
|
||||
if repeatTime.Before(initialTime) {
|
||||
suite.FailNow("expected webfinger cache entry to not be a time traveller")
|
||||
}
|
||||
|
||||
// finger a non-existing user on that same instance which will return an error
|
||||
_, err = suite.transport.Finger(context.TODO(), "invalid", "misconfigured-instance.com")
|
||||
if err == nil {
|
||||
suite.FailNow("expected request for invalid user to fail")
|
||||
}
|
||||
|
||||
// there should still only be 1 cache entry, because we don't evict from cache on failure
|
||||
suite.Equal(1, wc.Len(), "expect webfinger cache to hold one entry")
|
||||
wc.Lock()
|
||||
suite.True(wc.Cache.Has("misconfigured-instance.com"), "expect webfinger cache to have entry for misconfigured-instance.com")
|
||||
last, _ := wc.Cache.Get("misconfigured-instance.com")
|
||||
wc.Unlock()
|
||||
|
||||
lastTime := last.Expiry
|
||||
|
||||
// The TTL of the previous and new entry should be the same since
|
||||
// a failed request must not extend the entry TTL
|
||||
suite.Equal(repeatTime, lastTime)
|
||||
}
|
||||
|
||||
func TestFingerTestSuite(t *testing.T) {
|
||||
suite.Run(t, &FingerTestSuite{})
|
||||
}
|
||||
101
internal/transport/transport_test.go
Normal file
101
internal/transport/transport_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
GoToSocial
|
||||
Copyright (C) 2021-2023 GoToSocial Authors admin@gotosocial.org
|
||||
|
||||
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 transport_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stretchr/testify/suite"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/db"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/email"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/federation"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/media"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/processing"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/state"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/storage"
|
||||
"github.com/superseriousbusiness/gotosocial/internal/transport"
|
||||
"github.com/superseriousbusiness/gotosocial/testrig"
|
||||
)
|
||||
|
||||
type TransportTestSuite struct {
|
||||
// standard suite interfaces
|
||||
suite.Suite
|
||||
db db.DB
|
||||
storage *storage.Driver
|
||||
mediaManager media.Manager
|
||||
federator federation.Federator
|
||||
processor *processing.Processor
|
||||
emailSender email.Sender
|
||||
sentEmails map[string]string
|
||||
state state.State
|
||||
|
||||
// standard suite models
|
||||
testTokens map[string]*gtsmodel.Token
|
||||
testClients map[string]*gtsmodel.Client
|
||||
testApplications map[string]*gtsmodel.Application
|
||||
testUsers map[string]*gtsmodel.User
|
||||
testAccounts map[string]*gtsmodel.Account
|
||||
|
||||
transport transport.Transport
|
||||
}
|
||||
|
||||
func (suite *TransportTestSuite) SetupSuite() {
|
||||
suite.testTokens = testrig.NewTestTokens()
|
||||
suite.testClients = testrig.NewTestClients()
|
||||
suite.testApplications = testrig.NewTestApplications()
|
||||
suite.testUsers = testrig.NewTestUsers()
|
||||
suite.testAccounts = testrig.NewTestAccounts()
|
||||
}
|
||||
|
||||
func (suite *TransportTestSuite) SetupTest() {
|
||||
suite.state.Caches.Init()
|
||||
testrig.StartWorkers(&suite.state)
|
||||
|
||||
testrig.InitTestConfig()
|
||||
testrig.InitTestLog()
|
||||
|
||||
suite.db = testrig.NewTestDB(&suite.state)
|
||||
suite.state.DB = suite.db
|
||||
suite.storage = testrig.NewInMemoryStorage()
|
||||
suite.state.Storage = suite.storage
|
||||
|
||||
suite.mediaManager = testrig.NewTestMediaManager(&suite.state)
|
||||
suite.federator = testrig.NewTestFederator(&suite.state, testrig.NewTestTransportController(&suite.state, testrig.NewMockHTTPClient(nil, "../../testrig/media")), suite.mediaManager)
|
||||
suite.sentEmails = make(map[string]string)
|
||||
suite.emailSender = testrig.NewEmailSender("../../web/template/", suite.sentEmails)
|
||||
suite.processor = testrig.NewTestProcessor(&suite.state, suite.federator, suite.emailSender, suite.mediaManager)
|
||||
|
||||
testrig.StandardDBSetup(suite.db, nil)
|
||||
testrig.StandardStorageSetup(suite.storage, "../../testrig/media")
|
||||
|
||||
ts, err := suite.federator.TransportController().NewTransportForUsername(context.TODO(), "")
|
||||
if err != nil {
|
||||
suite.FailNow(err.Error())
|
||||
}
|
||||
suite.transport = ts
|
||||
|
||||
suite.NoError(suite.processor.Start())
|
||||
}
|
||||
|
||||
func (suite *TransportTestSuite) TearDownTest() {
|
||||
testrig.StandardDBTeardown(suite.db)
|
||||
testrig.StandardStorageTeardown(suite.storage)
|
||||
testrig.StopWorkers(&suite.state)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue