From a312238e7909c6451e608a91c326ad250dda875c Mon Sep 17 00:00:00 2001 From: Daenney Date: Thu, 9 Mar 2023 18:55:45 +0100 Subject: [PATCH] [feature] Provide .well-known/host-meta endpoint (#1604) * [feature] Provide .well-known/host-meta endpoint This adds the host-meta endpoint as Mastodon clients use this to discover the API domain to use when the host and account domains aren't the same. * Address review comments --- docs/configuration/general.md | 3 + docs/installation_guide/advanced.md | 15 +++- example/config.yaml | 5 ++ internal/api/util/mime.go | 1 + internal/api/util/negotiate.go | 5 ++ internal/api/wellknown.go | 4 + internal/api/wellknown/hostmeta/hostmeta.go | 45 ++++++++++++ .../api/wellknown/hostmeta/hostmetaget.go | 73 +++++++++++++++++++ internal/processing/fedi/wellknown.go | 20 +++++ 9 files changed, 167 insertions(+), 4 deletions(-) create mode 100644 internal/api/wellknown/hostmeta/hostmeta.go create mode 100644 internal/api/wellknown/hostmeta/hostmetaget.go diff --git a/docs/configuration/general.md b/docs/configuration/general.md index a6f68982a..cf2bb5c30 100644 --- a/docs/configuration/general.md +++ b/docs/configuration/general.md @@ -43,6 +43,9 @@ host: "localhost" # to "gts.example.org/.well-known/webfinger" so that GtS can handle them properly. # # You should also redirect requests at "example.org/.well-known/nodeinfo" in the same way. +# +# You should also redirect requests at "example.org/.well-known/host-meta" in the same way. This endpoint is used by a number of clients to discover the API endpoint to use when the host and account domain are different. +# # An empty string (ie., not set) means that the same value as 'host' will be used. # # DO NOT change this after your server has already run once, or you will break things! diff --git a/docs/installation_guide/advanced.md b/docs/installation_guide/advanced.md index 023bacb73..5e8a4874f 100644 --- a/docs/installation_guide/advanced.md +++ b/docs/installation_guide/advanced.md @@ -32,6 +32,9 @@ host: "localhost" # to "gts.example.org/.well-known/webfinger" so that GtS can handle them properly. # # You should also redirect requests at "example.org/.well-known/nodeinfo" in the same way. +# +# You should also redirect requests at "example.org/.well-known/host-meta" in the same way. This endpoint is used by a number of clients to discover the API endpoint to use when the host and account domain are different. +# # An empty string (ie., not set) means that the same value as 'host' will be used. # # DO NOT change this after your server has already run once, or you will break things! @@ -71,6 +74,10 @@ http { rewrite ^.*$ https://fedi.example.org/.well-known/webfinger permanent; } + location /.well-known/host-meta { + rewrite ^.*$ https://fedi.example.org/.well-known/host-meta permanent; + } + location /.well-known/nodeinfo { rewrite ^.*$ https://fedi.example.org/.well-known/nodeinfo permanent; } @@ -91,7 +98,7 @@ If `example.org` is running on [Traefik](https://doc.traefik.io/traefik/), we co labels: - 'traefik.http.routers.myservice.rule=Host(`example.org`)' - 'traefik.http.middlewares.myservice-gts.redirectregex.permanent=true' - - 'traefik.http.middlewares.myservice-gts.redirectregex.regex=^https://(.*)/.well-known/(webfinger|nodeinfo)$$' + - 'traefik.http.middlewares.myservice-gts.redirectregex.regex=^https://(.*)/.well-known/(webfinger|nodeinfo|host-meta)$$' - 'traefik.http.middlewares.myservice-gts.redirectregex.replacement=https://fedi.$${1}/.well-known/$${2}' - 'traefik.http.routers.myservice.middlewares=myservice-gts@docker' ``` @@ -279,9 +286,9 @@ This section contains a number of additional things for configuring nginx. If you want to harden up your NGINX deployment with advanced configuration options, there are many guides online for doing so ([for example](https://beaglesecurity.com/blog/article/nginx-server-security.html)). Try to find one that's up to date. Mozilla also publishes best-practice ssl configuration [here](https://ssl-config.mozilla.org/). -### Caching Webfinger and Public Key responses +### Caching Webfinger, Webhost Metadata and Public Key responses -It's possible to use nginx to cache webfinger and public key responses. This may be useful in order to ensure clients still get a response on these endpoints even if your GoToSocial instance is (temporarily) down, or requests are being throttled. +It's possible to use nginx to cache webfinger, host-meta and public key responses. This may be useful in order to ensure clients still get a response on these endpoints even if your GoToSocial instance is (temporarily) down, or requests are being throttled. You'll need to configure two things: @@ -311,7 +318,7 @@ server { ### NEW STUFF STARTS HERE ### - location /.well-known/webfinger { + location ~ /.well-known/(webfinger|host-meta)$ { proxy_set_header Host $host; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; diff --git a/example/config.yaml b/example/config.yaml index e662159f6..60680fd25 100644 --- a/example/config.yaml +++ b/example/config.yaml @@ -55,6 +55,11 @@ host: "localhost" # to "gts.example.org/.well-known/webfinger" so that GtS can handle them properly. # # You should also redirect requests at "example.org/.well-known/nodeinfo" in the same way. +# +# You should also redirect requests at "example.org/.well-known/host-meta" in the same way. This endpoint +# is used by a number of clients to discover the API endpoint to use when the host and account domain are +# different. +# # An empty string (ie., not set) means that the same value as 'host' will be used. # # DO NOT change this after your server has already run once, or you will break things! diff --git a/internal/api/util/mime.go b/internal/api/util/mime.go index 30f0f0d6e..cfdc3b08b 100644 --- a/internal/api/util/mime.go +++ b/internal/api/util/mime.go @@ -25,6 +25,7 @@ const ( AppJSON MIME = `application/json` AppXML MIME = `application/xml` + AppXMLXRD MIME = `application/xrd+xml` AppRSSXML MIME = `application/rss+xml` AppActivityJSON MIME = `application/activity+json` AppActivityLDJSON MIME = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` diff --git a/internal/api/util/negotiate.go b/internal/api/util/negotiate.go index 3a5f21775..06a202815 100644 --- a/internal/api/util/negotiate.go +++ b/internal/api/util/negotiate.go @@ -58,6 +58,11 @@ AppActivityLDJSON, } +var HostMetaHeaders = []MIME{ + AppXMLXRD, + AppXML, +} + // NegotiateAccept takes the *gin.Context from an incoming request, and a // slice of Offers, and performs content negotiation for the given request // with the given content-type offers. It will return a string representation diff --git a/internal/api/wellknown.go b/internal/api/wellknown.go index 7edbb4d7d..a837667fb 100644 --- a/internal/api/wellknown.go +++ b/internal/api/wellknown.go @@ -20,6 +20,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/hostmeta" "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/nodeinfo" "github.com/superseriousbusiness/gotosocial/internal/api/wellknown/webfinger" "github.com/superseriousbusiness/gotosocial/internal/middleware" @@ -30,6 +31,7 @@ type WellKnown struct { nodeInfo *nodeinfo.Module webfinger *webfinger.Module + hostMeta *hostmeta.Module } func (w *WellKnown) Route(r router.Router, m ...gin.HandlerFunc) { @@ -45,11 +47,13 @@ func (w *WellKnown) Route(r router.Router, m ...gin.HandlerFunc) { w.nodeInfo.Route(wellKnownGroup.Handle) w.webfinger.Route(wellKnownGroup.Handle) + w.hostMeta.Route(wellKnownGroup.Handle) } func NewWellKnown(p *processing.Processor) *WellKnown { return &WellKnown{ nodeInfo: nodeinfo.New(p), webfinger: webfinger.New(p), + hostMeta: hostmeta.New(p), } } diff --git a/internal/api/wellknown/hostmeta/hostmeta.go b/internal/api/wellknown/hostmeta/hostmeta.go new file mode 100644 index 000000000..17eb748ec --- /dev/null +++ b/internal/api/wellknown/hostmeta/hostmeta.go @@ -0,0 +1,45 @@ +/* + 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 . +*/ + +package hostmeta + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/superseriousbusiness/gotosocial/internal/processing" +) + +const ( + HostMetaContentType = "application/xrd+xml" + HostMetaPath = "/host-meta" +) + +type Module struct { + processor *processing.Processor +} + +func New(processor *processing.Processor) *Module { + return &Module{ + processor: processor, + } +} + +func (m *Module) Route(attachHandler func(method string, path string, f ...gin.HandlerFunc) gin.IRoutes) { + attachHandler(http.MethodGet, HostMetaPath, m.HostMetaGETHandler) +} diff --git a/internal/api/wellknown/hostmeta/hostmetaget.go b/internal/api/wellknown/hostmeta/hostmetaget.go new file mode 100644 index 000000000..f45b2cf9c --- /dev/null +++ b/internal/api/wellknown/hostmeta/hostmetaget.go @@ -0,0 +1,73 @@ +/* + 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 . +*/ + +package hostmeta + +import ( + "bytes" + "encoding/xml" + "net/http" + + "github.com/gin-gonic/gin" + apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util" + "github.com/superseriousbusiness/gotosocial/internal/gtserror" +) + +// HostMetaGETHandler swagger:operation GET /.well-known/host-meta hostMetaGet +// +// Returns a compliant hostmeta response to web host metadata queries. +// +// See: https://www.rfc-editor.org/rfc/rfc6415.html +// +// --- +// tags: +// - .well-known +// +// produces: +// - application/xrd+xml" +// +// responses: +// '200': +// schema: +// "$ref": "#/definitions/hostmeta" +func (m *Module) HostMetaGETHandler(c *gin.Context) { + if _, err := apiutil.NegotiateAccept(c, apiutil.HostMetaHeaders...); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorNotAcceptable(err, err.Error()), m.processor.InstanceGetV1) + return + } + + hostMeta := m.processor.Fedi().HostMetaGet() + + // this setup with a separate buffer we encode into is used because + // xml.Marshal does not emit xml.Header by itself + var buf bytes.Buffer + + // Preallocate buffer of reasonable length. + buf.Grow(len(xml.Header) + 64) + + // No need to check for error on write to buffer. + _, _ = buf.WriteString(xml.Header) + + // Encode host-meta as XML to in-memory buffer. + if err := xml.NewEncoder(&buf).Encode(hostMeta); err != nil { + apiutil.ErrorHandler(c, gtserror.NewErrorInternalError(err), m.processor.InstanceGetV1) + return + } + + c.Data(http.StatusOK, HostMetaContentType, buf.Bytes()) +} diff --git a/internal/processing/fedi/wellknown.go b/internal/processing/fedi/wellknown.go index 6f113ac5d..7be75649b 100644 --- a/internal/processing/fedi/wellknown.go +++ b/internal/processing/fedi/wellknown.go @@ -28,6 +28,10 @@ ) const ( + hostMetaXMLNS = "http://docs.oasis-open.org/ns/xri/xrd-1.0" + hostMetaRel = "lrdd" + hostMetaType = "application/xrd+xml" + hostMetaTemplate = ".well-known/webfinger?resource={uri}" nodeInfoVersion = "2.0" nodeInfoSoftwareName = "gotosocial" nodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/" + nodeInfoVersion @@ -96,6 +100,22 @@ func (p *Processor) NodeInfoGet(ctx context.Context) (*apimodel.Nodeinfo, gtserr }, nil } +// HostMetaGet returns a host-meta struct in response to a host-meta request. +func (p *Processor) HostMetaGet() *apimodel.HostMeta { + protocol := config.GetProtocol() + host := config.GetHost() + return &apimodel.HostMeta{ + XMLNS: hostMetaXMLNS, + Link: []apimodel.Link{ + { + Rel: hostMetaRel, + Type: hostMetaType, + Template: fmt.Sprintf("%s://%s/%s", protocol, host, hostMetaTemplate), + }, + }, + } +} + // WebfingerGet handles the GET for a webfinger resource. Most commonly, it will be used for returning account lookups. func (p *Processor) WebfingerGet(ctx context.Context, requestedUsername string) (*apimodel.WellKnownResponse, gtserror.WithCode) { // Get the local account the request is referring to.