Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
SHELL := bash
NAME := webfinger
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
include ../../.make/default.mk
include ../../.make/go.mk
include ../../.make/release.mk
include ../../.make/docs.mk
+128
View File
@@ -0,0 +1,128 @@
# Webfinger
The webfinger service provides an RFC7033 WebFinger lookup of КуСфера resources, relevant for a given user account at the /.well-known/webfinger enpoint.
1. An [OpenID Connect Discovery](#openid-connect-discovery) for the IdP, based on the КуСфера URL.
2. An [Authenticated Instance Discovery](#authenticated-instance-discovery), based on the user account.
These two request are only needed for discovery.
## OpenID Connect Discovery
Clients can make an unauthenticated `GET https://drive.qsfera.test/.well-known/webfinger?resource=https%3A%2F%2Fdrive.qsfera.test` request to discover the OpenID Connect Issuer in the `http://openid.net/specs/connect/1.0/issuer` relation:
```json
{
"subject": "https://drive.qsfera.test",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://sso.example.org/cas/oidc/"
}
]
}
```
Here, the `resource` takes the instance domain URI, but an `acct:` URI works as well.
## Authenticated Instance Discovery
When using OpenID connect to authenticate requests, clients can look up the qsfera instances a user has access to.
* Authentication is necessary to prevent leaking information about existing users.
* Basic auth is not supported.
The default configuration will simply return the `OC_URL` and direct clients to that domain:
```json
{
"subject": "acct:alan@drive.qsfera.test",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://sso.example.org/cas/oidc/"
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://abc.drive.example.org",
"titles": {
"en": "КуСфера Instance"
}
}
]
}
```
## Configure Different Instances Based on OpenidConnect UserInfo Claims
A more complex example for configuring different instances could look like this:
```yaml
webfinger:
instances:
- claim: email
regex: alan@example\.org
href: "https://{{.preferred_username}}.cloud.qsfera.test"
title:
"en": "КуСфера Instance for Alan"
"de": "КуСфера Instanz für Alan"
break: true
- claim: "email"
regex: mary@example\.org
href: "https://{{.preferred_username}}.cloud.qsfera.test"
title:
"en": "КуСфера Instance for Mary"
"de": "КуСфера Instanz für Mary"
break: false
- claim: "email"
regex: .+@example\.org
href: "https://example-org.cloud.qsfera.test"
title:
"en": "КуСфера Instance for example.org"
"de": "КуСфера Instanz für example.org"
break: true
- claim: "email"
regex: .+@example\.com
href: "https://example-com.cloud.qsfera.test"
title:
"en": "КуСфера Instance for example.com"
"de": "КуСфера Instanz für example.com"
break: true
- claim: "email"
regex: .+@.+\..+
href: "https://cloud.qsfera.test"
title:
"en": "КуСфера Instance"
"de": "КуСфера Instanz"
break: true
```
Now, an authenticated webfinger request for `acct:me@example.org` (when logged in as mary) would return two instances, based on her `email` claim, the regex matches and break flags:
```json
{
"subject": "acct:mary@example.org",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://sso.example.org/cas/oidc/"
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://mary.cloud.qsfera.test",
"titles": {
"en": "КуСфера Instance for Mary",
"de": "КуСфера Instanz für Mary"
}
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://xyz.drive.example.org",
"titles": {
"en": "КуСфера Instance for example.org",
"de": "КуСфера Instanz für example.org"
}
}
]
}
```
+137
View File
@@ -0,0 +1,137 @@
# TODO
Currently, clients need to make subsequent calls to:
* /status.php to check if the instance is in maintenance mode or if the version is supported
* /config.json to get the available apps for КуСфера web to determine which routes require authentication
* /themes/qsfera/theme.json for theming info
* /.well-known/openid-configuration, auth2 token and oidc userinfo endpoints to authenticate the user
* /ocs/v1.php/cloud/user to get the username, eg. alan ... again? it contains the oc10 user id (mary, not the uuid)
* /ocs/v1.php/cloud/capabilities to fetch instance capabilites
* /ocs/v1.php/cloud/users/alan to fetch the quota which could come from graph and actually is now tied to the spaces, not to users
* /graph/v1.0/me?%24expand=memberOf to fetch the user id and the groups the user is a member of
We need a way to pass oidc claims from the proxy, which does the authentication to the webfinger service, preferably by minting them into the internal reva token.
* Currently, we use machine auth so we can autoprovision an account if it does not exist. We should use revas oidc auth and, when autoprovisioning is enabled, retry the authentication after provisioning the account. This would allow us to use a `roles` claim to decide which roles to use and eg. a `school` claim to determine a specific instance. We may use https://github.com/PerimeterX/marshmallow to parse the RegisteredClaims and get the custom claims as a separate map.
For now, webfinger can only match users based on a regex and produce a list of instances based on that.
Here are some Ideas which need to be discussed with all client teams in the future:
## Implement a Backend Lookup
We could use ldap, the graph service or a reva based authentication to look up more properties that can be used to determine which instances to list. The initial implementation works on oidc claims and does not work with basic auth.
## Replace status.php with Properties
The /.well-known/webfinger enpdoint allows us to not only get rid of some of these calls, e.g. by embedding status.php info:
```json
{
"subject": "https://drive.qsfera.test",
"properties": {
"http://webfinger.qsfera/prop/maintenance": "false",
"http://webfinger.qsfera/prop/version": "10.11.0.6"
},
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://idp.qsfera.test"
}
]
}
```
## Introduce Dedicated КуСфера web Endpoint
It also allows us to move some services out of a sharded deployment. We could e.g. introduce a relation for a common КуСфера web endpoint to not exponse the different instances in the browser bar:
```json
{
"subject": "acct:alan@drive.qsfera.test",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://idp.qsfera.test"
},
{
"rel": "http://webfinger.qsfera/rel/web",
"href": "https://drive.qsfera.test"
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://abc.drive.qsfera.test",
"titles": {
"en": "Readable Instance Name"
}
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://xyz.drive.qsfera.test",
"titles": {
"en": "Readable Other Instance Name"
}
}
]
}
```
## Dedicated КуСфера web Endpoint
We could also omit the `http://webfinger.qsfera/rel/server-instance` relation and go straight for a graph service with e.g. `rel=http://libregraph.org/rel/graph`:
```json
{
"subject": "acct:alan@drive.qsfera.test",
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://idp.qsfera.test"
},
{
"rel": "http://webfinger.qsfera/rel/web",
"href": "https://drive.qsfera.test"
},
{
"rel": "http://libregraph.org/rel/graph",
"href": "https://abc.drive.qsfera.test/graph/v1.0",
"titles": {
"en": "Readable Instance Name"
}
}
]
}
```
In theory the graph endpoint would allow discovering drives on any domain. But there is a lot more work to be done here.
## Subject Properties
We could also embed subject metadata, however since apps like КуСфера web also need the groups a user is member of a dedicated call to the libregraph api is probably better. In any case, we could return properties for the subject:
```json
{
"subject": "acct:alan@drive.qsfera.test",
"properties": {
"http://libregraph.org/prop/user/id": "b1f74ec4-dd7e-11ef-a543-03775734d0f7",
"http://libregraph.org/prop/user/onPremisesSamAccountName": "alan",
"http://libregraph.org/prop/user/mail": "alan@example.org",
"http://libregraph.org/prop/user/displayName": "Alan Turing",
},
"links": [
{
"rel": "http://openid.net/specs/connect/1.0/issuer",
"href": "https://idp.qsfera.test"
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://abc.drive.qsfera.test",
"titles": {
"en": "Readable Instance Name"
}
},
{
"rel": "http://webfinger.qsfera/rel/server-instance",
"href": "https://xyz.drive.qsfera.test",
"titles": {
"en": "Readable Other Instance Name"
}
},
]
}
```
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
resp, err := http.Get(
fmt.Sprintf(
"http://%s/healthz",
cfg.Debug.Addr,
),
)
if err != nil {
logger.Fatal().
Err(err).
Msg("Failed to request health check")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Fatal().
Int("code", resp.StatusCode).
Msg("Health seems to be in bad state")
}
logger.Debug().
Int("code", resp.StatusCode).
Msg("Health got a good state")
return nil
},
}
}
@@ -0,0 +1,36 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
// infos about this service
Health(cfg),
Version(cfg),
}
}
// Execute is the entry point for the qsfera webfinger command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "webfinger",
Short: "Serve webfinger API for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
@@ -0,0 +1,136 @@
package command
import (
"context"
"fmt"
"os/signal"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/config/parser"
"github.com/qsfera/server/services/webfinger/pkg/metrics"
"github.com/qsfera/server/services/webfinger/pkg/relations"
"github.com/qsfera/server/services/webfinger/pkg/server/debug"
"github.com/qsfera/server/services/webfinger/pkg/server/http"
"github.com/qsfera/server/services/webfinger/pkg/service/v0"
"github.com/spf13/cobra"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
m := metrics.New(metrics.Logger(logger))
m.BuildInfo.WithLabelValues(version.GetString()).Set(1)
gr := runner.NewGroup()
{
relationProviders, err := getRelationProviders(cfg)
if err != nil {
logger.Error().Err(err).Msg("relation provider init")
return err
}
svc, err := service.New(
service.Logger(logger),
service.Config(cfg),
service.WithRelationProviders(relationProviders),
)
if err != nil {
logger.Error().Err(err).Msg("handler init")
return err
}
svc = service.NewInstrument(svc, m)
svc = service.NewLogging(svc, logger) // this logs service specific data
svc = service.NewTracing(svc, traceProvider)
server, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Service(svc),
http.TraceProvider(traceProvider),
)
if err != nil {
logger.Info().
Err(err).
Str("server", "http").
Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
debugServer, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
func getRelationProviders(cfg *config.Config) (map[string]service.RelationProvider, error) {
rels := map[string]service.RelationProvider{}
for _, relationURI := range cfg.Relations {
switch relationURI {
case relations.OpenIDConnectRel:
rels[relationURI] = relations.OpenIDDiscovery(cfg.IDP, cfg.OIDCClientConfigs)
case relations.QsferaInstanceRel:
var err error
rels[relationURI], err = relations.QsferaInstance(cfg.Instances, cfg.QsferaURL)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unknown relation '%s'", relationURI)
}
}
return rels, nil
}
@@ -0,0 +1,49 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/spf13/cobra"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/qsfera/server/services/webfinger/pkg/config"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
if err != nil {
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
return err
}
if len(services) == 0 {
fmt.Println("No running " + cfg.Service.Name + " service found.")
return nil
}
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
@@ -0,0 +1,53 @@
package config
import (
"context"
"github.com/qsfera/server/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;WEBFINGER_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
Instances []Instance `yaml:"instances"`
Relations []string `yaml:"relations" env:"WEBFINGER_RELATIONS" desc:"A list of relation URIs or registered relation types to add to webfinger responses. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
IDP string `yaml:"idp" env:"OC_URL;OC_OIDC_ISSUER;WEBFINGER_OIDC_ISSUER" desc:"The identity provider href for the openid-discovery relation." introductionVersion:"1.0.0"`
AndroidClientID string `yaml:"android_client_id" env:"OC_OIDC_CLIENT_ID;WEBFINGER_ANDROID_OIDC_CLIENT_ID" desc:"The OIDC client ID for Android app." introductionVersion:"6.0.0"`
AndroidClientScopes []string `yaml:"android_client_scopes" env:"OC_OIDC_CLIENT_SCOPES;WEBFINGER_ANDROID_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the Android app should request." introductionVersion:"6.0.0"`
DesktopClientID string `yaml:"desktop_client_id" env:"OC_OIDC_CLIENT_ID;WEBFINGER_DESKTOP_OIDC_CLIENT_ID" desc:"The OIDC client ID for the КуСфера desktop application." introductionVersion:"6.0.0"`
DesktopClientScopes []string `yaml:"desktop_client_scopes" env:"OC_OIDC_CLIENT_SCOPES;WEBFINGER_DESKTOP_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the КуСфера desktop application should request." introductionVersion:"6.0.0"`
IOSClientID string `yaml:"ios_client_id" env:"OC_OIDC_CLIENT_ID;WEBFINGER_IOS_OIDC_CLIENT_ID" desc:"The OIDC client ID for the IOS app." introductionVersion:"6.0.0"`
IOSClientScopes []string `yaml:"ios_client_scopes" env:"OC_OIDC_CLIENT_SCOPES;WEBFINGER_IOS_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the IOS app should request." introductionVersion:"6.0.0"`
// The WEB_OIDC_CLIENT_ID is kept for backware compatibility with the old settings from the `web` service and can be removed in a future release.
WebClientID string `yaml:"web_client_id" env:"OC_OIDC_CLIENT_ID;WEB_OIDC_CLIENT_ID;WEBFINGER_WEB_OIDC_CLIENT_ID" desc:"The OIDC client ID for the КуСфера web client. The 'WEB_OIDC_CLIENT_ID' setting is only here for backwards compatibility and will be remove in a future release." introductionVersion:"6.0.0"`
// The WEB_OIDC_SCOPE is kept for backware compatibility with the old settings from the `web` service and can be removed in a future release.
WebClientScopes []string `yaml:"web_client_scopes" env:"OC_OIDC_CLIENT_SCOPES;WEB_OIDC_SCOPE;WEBFINGER_WEB_OIDC_CLIENT_SCOPES" desc:"The OIDC client scopes the КуСфера web client should request. The 'WEB_OIDC_SCOPE' setting is only here for backwards compatibility and will be remove in a future release." introductionVersion:"6.0.0"`
QsferaURL string `yaml:"qsfera_url" env:"OC_URL;WEBFINGER_QSFERA_SERVER_INSTANCE_URL" desc:"The URL for the legacy server instance relation. It defaults to the OC_URL but can be overridden to support some reverse proxy corner cases. To shard the deployment, multiple instances can be configured in the configuration file." introductionVersion:"1.0.0"`
Insecure bool `yaml:"insecure" env:"OC_INSECURE;WEBFINGER_INSECURE" desc:"Allow insecure connections to the WEBFINGER service." introductionVersion:"1.0.0"`
OIDCClientConfigs map[string]OIDCClientConfig `yaml:"-"`
Context context.Context `yaml:"-"`
}
// Instance to use with a matching rule and titles
type Instance struct {
Claim string `yaml:"claim"`
Regex string `yaml:"regex"`
Href string `yaml:"href"`
Titles map[string]string `yaml:"titles"`
Break bool `yaml:"break"`
}
type OIDCClientConfig struct {
ClientID string
Scopes []string
}
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"WEBFINGER_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"WEBFINGER_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"WEBFINGER_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"WEBFINGER_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,113 @@
package defaults
import (
"strings"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/relations"
)
var (
nativeAppScopes = []string{"openid", "profile", "email", "offline_access"}
webAppScopes = []string{"openid", "profile", "email"}
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9279",
Token: "",
Pprof: false,
Zpages: false,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9275",
Root: "/",
Namespace: "qsfera.web",
CORS: config.CORS{
AllowedOrigins: []string{"https://localhost:9200"},
AllowCredentials: false,
},
},
Service: config.Service{
Name: "webfinger",
},
QsferaURL: "https://localhost:9200",
Relations: []string{relations.OpenIDConnectRel, relations.QsferaInstanceRel},
Instances: []config.Instance{
{
Claim: "sub",
Regex: ".+",
Href: "{{.OC_URL}}",
Titles: map[string]string{
"en": "КуСфера Instance",
},
},
},
IDP: "https://localhost:9200",
Insecure: false,
AndroidClientID: "QsferaAndroid",
AndroidClientScopes: nativeAppScopes,
DesktopClientID: "QsferaDesktop",
DesktopClientScopes: nativeAppScopes,
IOSClientID: "QsferaIOS",
IOSClientScopes: nativeAppScopes,
WebClientID: "web",
WebClientScopes: webAppScopes,
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
if (cfg.Commons != nil && cfg.Commons.QsferaURL != "") &&
(cfg.HTTP.CORS.AllowedOrigins == nil ||
len(cfg.HTTP.CORS.AllowedOrigins) == 1 &&
cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") {
cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.QsferaURL}
}
}
// Sanitize sanitized the configuration
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
cfg.OIDCClientConfigs = map[string]config.OIDCClientConfig{
"android": {
ClientID: cfg.AndroidClientID,
Scopes: cfg.AndroidClientScopes,
},
"desktop": {
ClientID: cfg.DesktopClientID,
Scopes: cfg.DesktopClientScopes,
},
"ios": {
ClientID: cfg.IOSClientID,
Scopes: cfg.IOSClientScopes,
},
"web": {
ClientID: cfg.WebClientID,
Scopes: cfg.WebClientScopes,
},
}
}
@@ -0,0 +1,20 @@
package config
import "github.com/qsfera/server/pkg/shared"
// CORS defines the available cors configuration.
type CORS struct {
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;WEBFINGER_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;WEBFINGER_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;WEBFINGER_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;WEBFINGER_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"1.0.0"`
}
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"WEBFINGER_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
Root string `yaml:"root" env:"WEBFINGER_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
CORS CORS `yaml:"cors"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
}
@@ -0,0 +1,37 @@
package parser
import (
"errors"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(_ *config.Config) error {
return nil
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,81 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "webfinger"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
BuildInfo *prometheus.GaugeVec
Counter *prometheus.CounterVec
Latency *prometheus.SummaryVec
Duration *prometheus.HistogramVec
}
// New initializes the available metrics.
func New(opts ...Option) *Metrics {
options := newOptions(opts...)
m := &Metrics{
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build information",
}, []string{"version"}),
Counter: prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "webfinger_total",
Help: "How many webfinger requests processed",
}, []string{}),
Latency: prometheus.NewSummaryVec(prometheus.SummaryOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "webfinger_latency_microseconds",
Help: "Webfinger request latencies in microseconds",
}, []string{}),
Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "webfinger_duration_seconds",
Help: "Webfinger request time in seconds",
}, []string{}),
}
if err := prometheus.Register(m.BuildInfo); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "BuildInfo").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Counter); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "counter").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Latency); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "latency").
Msg("Failed to register prometheus metric")
}
if err := prometheus.Register(m.Duration); err != nil {
options.Logger.Error().
Err(err).
Str("metric", "duration").
Msg("Failed to register prometheus metric")
}
return m
}
@@ -0,0 +1,31 @@
package metrics
import (
"github.com/qsfera/server/pkg/log"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
@@ -0,0 +1,50 @@
package relations
import (
"context"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/service/v0"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
)
const (
OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer"
clientIDProp = "http://qsfera.eu/ns/oidc/client_id"
scopesProp = "http://qsfera.eu/ns/oidc/scopes"
legacyClientIDProp = "http://opencloud.eu/ns/oidc/client_id"
legacyScopesProp = "http://opencloud.eu/ns/oidc/scopes"
)
type openIDDiscovery struct {
Href string
OIDCClients map[string]config.OIDCClientConfig
}
// OpenIDDiscovery adds the Openid Connect issuer relation
func OpenIDDiscovery(href string, clients map[string]config.OIDCClientConfig) service.RelationProvider {
return &openIDDiscovery{
Href: href,
OIDCClients: clients,
}
}
func (l *openIDDiscovery) Add(_ context.Context, platform string, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
jrd.Links = append(jrd.Links, webfinger.Link{
Rel: OpenIDConnectRel,
Href: l.Href,
})
if platform != "" {
if clientConfig, ok := l.OIDCClients[platform]; ok {
jrd.Properties = make(map[string]any)
jrd.Properties[clientIDProp] = clientConfig.ClientID
jrd.Properties[scopesProp] = clientConfig.Scopes
jrd.Properties[legacyClientIDProp] = clientConfig.ClientID
jrd.Properties[legacyScopesProp] = clientConfig.Scopes
}
}
}
@@ -0,0 +1,59 @@
package relations
import (
"context"
"testing"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
)
func TestOpenidDiscovery(t *testing.T) {
clients := map[string]config.OIDCClientConfig{
"web": {
ClientID: "web",
Scopes: []string{"openid", "profile", "email"},
},
"test": {
ClientID: "test",
Scopes: []string{"test"},
},
}
provider := OpenIDDiscovery("http://issuer.url", clients)
jrd := webfinger.JSONResourceDescriptor{}
provider.Add(context.Background(), "", &jrd)
if len(jrd.Links) != 1 {
t.Errorf("provider returned wrong number of links: %v, expected 1", len(jrd.Links))
}
if jrd.Links[0].Href != "http://issuer.url" {
t.Errorf("provider returned wrong issuer link href: %v, expected %v", jrd.Links[0].Href, "http://issuer.url")
}
if jrd.Links[0].Rel != "http://openid.net/specs/connect/1.0/issuer" {
t.Errorf("provider returned wrong openid connect rel: %v, expected %v", jrd.Links[0].Href, OpenIDConnectRel)
}
if len(jrd.Properties) != 0 {
t.Errorf("provider returned properties for empty platform: %v, expected 0", len(jrd.Properties))
}
jrd = webfinger.JSONResourceDescriptor{}
provider.Add(context.Background(), "test", &jrd)
if len(jrd.Properties) != 4 {
t.Errorf("provider returned wrong number of properties for platform test: %v, expected 4", len(jrd.Properties))
}
if jrd.Properties["http://qsfera.eu/ns/oidc/client_id"] != "test" {
t.Errorf("provider returned wrong client_id property: %v, expected %v", jrd.Properties["http://qsfera.eu/ns/oidc/client_id"], "test")
}
if scopes, ok := jrd.Properties["http://qsfera.eu/ns/oidc/scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "test" {
t.Errorf("provider returned wrong scopes property: %v, expected %v", jrd.Properties["http://qsfera.eu/ns/oidc/scopes"], []string{"test"})
}
if jrd.Properties["http://opencloud.eu/ns/oidc/client_id"] != "test" {
t.Errorf("provider returned wrong legacy client_id property: %v, expected %v", jrd.Properties["http://opencloud.eu/ns/oidc/client_id"], "test")
}
if scopes, ok := jrd.Properties["http://opencloud.eu/ns/oidc/scopes"].([]string); !ok || len(scopes) != 1 || scopes[0] != "test" {
t.Errorf("provider returned wrong legacy scopes property: %v, expected %v", jrd.Properties["http://opencloud.eu/ns/oidc/scopes"], []string{"test"})
}
}
@@ -0,0 +1,88 @@
package relations
import (
"context"
"net/url"
"regexp"
"strings"
"text/template"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/service/v0"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
)
const (
QsferaInstanceRel = "http://webfinger.qsfera/rel/server-instance"
)
type compiledInstance struct {
config.Instance
compiledRegex *regexp.Regexp
hrefTemplate *template.Template
}
type qsferaInstance struct {
instances []compiledInstance
qsferaURL string
instanceHost string
}
// QsferaInstance adds one or more КуСфера instance relations
func QsferaInstance(instances []config.Instance, qsferaURL string) (service.RelationProvider, error) {
compiledInstances := make([]compiledInstance, 0, len(instances))
var err error
for _, instance := range instances {
compiled := compiledInstance{Instance: instance}
compiled.compiledRegex, err = regexp.Compile(instance.Regex)
if err != nil {
return nil, err
}
compiled.hrefTemplate, err = template.New(instance.Claim + ":" + instance.Regex + ":" + instance.Href).Parse(instance.Href)
if err != nil {
return nil, err
}
compiledInstances = append(compiledInstances, compiled)
}
u, err := url.Parse(qsferaURL)
if err != nil {
return nil, err
}
return &qsferaInstance{
instances: compiledInstances,
qsferaURL: qsferaURL,
instanceHost: u.Host + u.Path,
}, nil
}
func (l *qsferaInstance) Add(ctx context.Context, _ string, jrd *webfinger.JSONResourceDescriptor) {
if jrd == nil {
jrd = &webfinger.JSONResourceDescriptor{}
}
if claims := oidc.FromContext(ctx); claims != nil {
if value, ok := claims[oidc.PreferredUsername].(string); ok {
jrd.Subject = "acct:" + value + "@" + l.instanceHost
} else if value, ok := claims[oidc.Email].(string); ok {
jrd.Subject = "mailto:" + value
}
// allow referencing QSFERA_URL in the template
claims["QSFERA_URL"] = l.qsferaURL
claims["OC_URL"] = l.qsferaURL
for _, instance := range l.instances {
if value, ok := claims[instance.Claim].(string); ok && instance.compiledRegex.MatchString(value) {
var tmplWriter strings.Builder
instance.hrefTemplate.Execute(&tmplWriter, claims)
jrd.Links = append(jrd.Links, webfinger.Link{
Rel: QsferaInstanceRel,
Href: tmplWriter.String(),
Titles: instance.Titles,
})
if instance.Break {
break
}
}
}
}
}
@@ -0,0 +1,65 @@
package relations
import (
"context"
"testing"
"github.com/qsfera/server/pkg/oidc"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
)
func TestQsferaInstanceErr(t *testing.T) {
_, err := QsferaInstance([]config.Instance{}, "http://\n\rinvalid")
if err == nil {
t.Errorf("provider did not err on invalid url: %v", err)
}
_, err = QsferaInstance([]config.Instance{{Regex: "("}}, "http://qsfera.tld")
if err == nil {
t.Errorf("provider did not err on invalid regex: %v", err)
}
_, err = QsferaInstance([]config.Instance{{Href: "{{invalid}}ee"}}, "http://qsfera.tld")
if err == nil {
t.Errorf("provider did not err on invalid href template: %v", err)
}
}
func TestQsferaInstanceAddLink(t *testing.T) {
provider, err := QsferaInstance([]config.Instance{{
Claim: "customclaim",
Regex: ".+@.+\\..+",
Href: "https://{{.otherclaim}}.domain.tld",
Titles: map[string]string{
"foo": "bar",
},
Break: true,
}}, "http://qsfera.tld")
if err != nil {
t.Error(err)
}
ctx := context.Background()
ctx = oidc.NewContext(ctx, map[string]any{
"customclaim": "some@fizz.buzz",
"otherclaim": "someone",
})
jrd := webfinger.JSONResourceDescriptor{}
provider.Add(ctx, "", &jrd)
if len(jrd.Links) != 1 {
t.Errorf("provider returned wrong number of links: %v, expected 1", len(jrd.Links))
}
if jrd.Links[0].Href != "https://someone.domain.tld" {
t.Errorf("provider returned wrong issuer link href: %v, expected %v", jrd.Links[0].Href, "https://someone.domain.tld")
}
if jrd.Links[0].Rel != QsferaInstanceRel {
t.Errorf("provider returned qsfera server instance rel: %v, expected %v", jrd.Links[0].Rel, QsferaInstanceRel)
}
if len(jrd.Links[0].Titles) != 1 {
t.Errorf("provider returned wrong number of titles: %v, expected 1", len(jrd.Links[0].Titles))
}
if jrd.Links[0].Titles["foo"] != "bar" {
t.Errorf("provider returned wrong title: %v, expected bar", len(jrd.Links[0].Titles["foo"]))
}
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webfinger/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,37 @@
package debug
import (
"net/http"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
checkHandler := handlers.NewCheckHandler(
handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)),
)
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(checkHandler),
debug.Ready(checkHandler),
debug.CorsAllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
debug.CorsAllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
debug.CorsAllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
debug.CorsAllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
), nil
}
@@ -0,0 +1,85 @@
package http
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webfinger/pkg/config"
svc "github.com/qsfera/server/services/webfinger/pkg/service/v0"
"github.com/spf13/pflag"
"go.opentelemetry.io/otel/trace"
"go.opentelemetry.io/otel/trace/noop"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Name string
Namespace string
Logger log.Logger
Context context.Context
Config *config.Config
Flags []pflag.Flag
Service svc.Service
TraceProvider trace.TracerProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Service provides a function to set the service option.
func Service(val svc.Service) Option {
return func(o *Options) {
o.Service = val
}
}
// Flags provides a function to set the flags option.
func Flags(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// TraceProvider provides a function to configure the trace provider
func TraceProvider(traceProvider trace.TracerProvider) Option {
return func(o *Options) {
if traceProvider != nil {
o.TraceProvider = traceProvider
} else {
o.TraceProvider = noop.NewTracerProvider()
}
}
}
@@ -0,0 +1,153 @@
package http
import (
"crypto/tls"
"net/http"
"net/url"
"time"
"github.com/go-chi/chi/v5"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/qsfera/server/pkg/cors"
"github.com/qsfera/server/pkg/middleware"
ohttp "github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
serviceErrors "github.com/qsfera/server/services/webfinger/pkg/service/v0"
svc "github.com/qsfera/server/services/webfinger/pkg/service/v0"
"github.com/pkg/errors"
"github.com/riandyrn/otelchi"
"go-micro.dev/v4"
)
// Server initializes the http service and server.
func Server(opts ...Option) (ohttp.Service, error) {
options := newOptions(opts...)
service := options.Service
newService, err := ohttp.NewService(
ohttp.TLSConfig(options.Config.HTTP.TLS),
ohttp.Logger(options.Logger),
ohttp.Namespace(options.Config.HTTP.Namespace),
ohttp.Name(options.Config.Service.Name),
ohttp.Version(version.GetString()),
ohttp.Address(options.Config.HTTP.Addr),
ohttp.Context(options.Context),
ohttp.Flags(options.Flags...),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return ohttp.Service{}, err
}
mux := chi.NewMux()
mux.Use(chimiddleware.RealIP)
mux.Use(chimiddleware.RequestID)
mux.Use(middleware.TraceContext)
mux.Use(middleware.NoCache)
mux.Use(
middleware.Cors(
cors.Logger(options.Logger),
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
))
mux.Use(middleware.Version(
options.Name,
version.String,
))
mux.Use(
otelchi.Middleware(
options.Name,
otelchi.WithChiRoutes(mux),
otelchi.WithTracerProvider(options.TraceProvider),
otelchi.WithPropagators(tracing.GetPropagator()),
),
)
var oidcHTTPClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: options.Config.Insecure, //nolint:gosec
},
DisableKeepAlives: true,
},
Timeout: time.Second * 10,
}
mux.Use(middleware.OidcAuth(
middleware.WithLogger(options.Logger),
middleware.WithOidcIssuer(options.Config.IDP),
middleware.WithHttpClient(*oidcHTTPClient),
))
// this logs http request related data
mux.Use(middleware.Logger(
options.Logger,
))
mux.Route(options.Config.HTTP.Root, func(r chi.Router) {
r.Get("/.well-known/webfinger", WebfingerHandler(service))
})
err = micro.RegisterHandler(newService.Server(), mux)
if err != nil {
options.Logger.Fatal().Err(err).Msg("failed to register the handler")
}
newService.Init()
return newService, nil
}
func WebfingerHandler(service svc.Service) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// A WebFinger URI MUST contain a query component (see Section 3.4 of
// RFC 3986). The query component MUST contain a "resource" parameter
// and MAY contain one or more "rel" parameters.
resource := r.URL.Query().Get("resource")
queryTarget, err := url.Parse(resource)
if resource == "" || err != nil {
// If the "resource" parameter is absent or malformed, the WebFinger
// resource MUST indicate that the request is bad as per Section 10.4.1
// of RFC 2616.
render.Status(r, http.StatusBadRequest)
render.PlainText(w, r, "absent or malformed 'resource' parameter")
return
}
rels := r.URL.Query()["rel"]
platform := r.URL.Query().Get("platform")
jrd, err := service.Webfinger(ctx, queryTarget, rels, platform)
if errors.Is(err, serviceErrors.ErrNotFound) {
// from https://www.rfc-editor.org/rfc/rfc7033#section-4.2
//
// If the "resource" parameter is a value for which the server has no
// information, the server MUST indicate that it was unable to match the
// request as per Section 10.4.5 of RFC 2616.
render.Status(r, http.StatusNotFound)
render.PlainText(w, r, err.Error())
return
}
if err != nil {
render.Status(r, http.StatusInternalServerError)
render.PlainText(w, r, err.Error())
return
}
w.Header().Set("Content-type", "application/jrd+json")
render.Status(r, http.StatusOK)
render.JSON(w, r, jrd)
}
}
@@ -0,0 +1,5 @@
package service
import "errors"
var ErrNotFound = errors.New("query target not found")
@@ -0,0 +1,39 @@
package service
import (
"context"
"net/url"
"github.com/qsfera/server/services/webfinger/pkg/metrics"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
"github.com/prometheus/client_golang/prometheus"
)
// NewInstrument returns a service that instruments metrics.
func NewInstrument(next Service, metrics *metrics.Metrics) Service {
return instrument{
next: next,
metrics: metrics,
}
}
type instrument struct {
next Service
metrics *metrics.Metrics
}
// Webfinger implements the Service interface.
func (i instrument) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error) {
timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
us := v * 1000000
i.metrics.Latency.WithLabelValues().Observe(us)
i.metrics.Duration.WithLabelValues().Observe(v)
}))
defer timer.ObserveDuration()
i.metrics.Counter.WithLabelValues().Inc()
return i.next.Webfinger(ctx, queryTarget, rels, platform)
}
@@ -0,0 +1,32 @@
package service
import (
"context"
"net/url"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
)
// NewLogging returns a service that logs messages.
func NewLogging(next Service, logger log.Logger) Service {
return logging{
next: next,
logger: logger,
}
}
type logging struct {
next Service
logger log.Logger
}
// Webfinger implements the Service interface.
func (l logging) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error) {
l.logger.Debug().
Str("query_target", queryTarget.String()).
Strs("rel", rels).
Msg("Webfinger")
return l.next.Webfinger(ctx, queryTarget, rels, platform)
}
@@ -0,0 +1,47 @@
package service
import (
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webfinger/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Config *config.Config
RelationProviders map[string]RelationProvider
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
func WithRelationProviders(val map[string]RelationProvider) Option {
return func(o *Options) {
o.RelationProviders = val
}
}
@@ -0,0 +1,105 @@
package service
import (
"context"
"net/url"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/webfinger/pkg/config"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
)
// Service defines the extension handlers.
type Service interface {
// Webfinger is the endpoint for retrieving various href relations.
//
// GET /.well-known/webfinger?
// resource=acct%3Acarol%40example.com&
// rel=http%3A%2F%2Fwebfinger.qsfera%rel%2Fserver-instance
// HTTP/1.1
// Host: example.com
//
// The server might respond like this:
//
// HTTP/1.1 200 OK
// Access-Control-Allow-Origin: *
// Content-Type: application/jrd+json
//
// {
// "subject" : "acct:carol@example.com",
// "links" :
// [
// {
// "rel" : "http://webfinger.qsfera/rel/server-instance",
// "href" : "https://instance.example.com",
// "titles": {
// "en": "Readable Instance Name"
// }
// },
// {
// "rel" : "http://webfinger.qsfera/rel/server-instance",
// "href" : "https://otherinstance.example.com",
// "titles": {
// "en": "Other Readable Instance Name"
// }
// }
// ]
// }
Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error)
}
type RelationProvider interface {
Add(ctx context.Context, platform string, jrd *webfinger.JSONResourceDescriptor)
}
// New returns a new instance of Service
func New(opts ...Option) (Service, error) {
options := newOptions(opts...)
// TODO use fallback implementations of InstanceIdLookup and InstanceLookup?
// The InstanceIdLookup may have to happen earlier?
return svc{
log: options.Logger,
config: options.Config,
relationProviders: options.RelationProviders,
}, nil
}
type svc struct {
config *config.Config
log log.Logger
relationProviders map[string]RelationProvider
}
// TODO implement different implementations:
// static one returning the href or a configurable domain
// regex one returning different instances based on the regex that matches
// claim one that reads a claim and then fetches the instance?
// that is actually two interfaces / steps:
// - one that determines the instances/schools id (read from claim, regex match)
// - one that looks up in instance by id (use template, read from json, read from ldap, read from graph)
// Webfinger implements the service interface
func (s svc) Webfinger(ctx context.Context, queryTarget *url.URL, rel []string, platform string) (webfinger.JSONResourceDescriptor, error) {
jrd := webfinger.JSONResourceDescriptor{
Subject: queryTarget.String(),
}
if len(rel) == 0 {
// add all configured relation providers
for _, relation := range s.relationProviders {
relation.Add(ctx, platform, &jrd)
}
} else {
// only add requested relations
for _, r := range rel {
if relation, ok := s.relationProviders[r]; ok {
relation.Add(ctx, platform, &jrd)
}
}
}
return jrd, nil
}
@@ -0,0 +1,38 @@
package service
import (
"context"
"net/url"
"github.com/qsfera/server/services/webfinger/pkg/webfinger"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// NewTracing returns a service that instruments traces.
func NewTracing(next Service, tp trace.TracerProvider) Service {
return tracing{
next: next,
tp: tp,
}
}
type tracing struct {
next Service
tp trace.TracerProvider
}
// Webfinger implements the Service interface.
func (t tracing) Webfinger(ctx context.Context, queryTarget *url.URL, rels []string, platform string) (webfinger.JSONResourceDescriptor, error) {
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
attribute.KeyValue{Key: "query_target", Value: attribute.StringValue(queryTarget.String())},
attribute.KeyValue{Key: "rels", Value: attribute.StringSliceValue(rels)},
),
}
ctx, span := t.tp.Tracer("webfinger").Start(ctx, "Webfinger", spanOpts...)
defer span.End()
return t.next.Webfinger(ctx, queryTarget, rels, platform)
}
@@ -0,0 +1,64 @@
package webfinger
// Link represents a link relation object as per https://www.rfc-editor.org/rfc/rfc7033#section-4.4.4
type Link struct {
// Rel is either a URI or a registered relation type (see RFC 5988)
//
// The "rel" member MUST be present in the link relation object.
Rel string `json:"rel"`
// Type indicates the media type of the target resource
//
// The "type" member is OPTIONAL in the link relation object.
Type string `json:"type,omitempty"`
// Href contains a URI pointing to the target resource.
//
// The "href" member is OPTIONAL in the link relation object.
Href string `json:"href,omitempty"`
// The "properties" object within the link relation object comprises
// zero or more name/value pairs whose names are URIs (referred to as
// "property identifiers") and whose values are strings or null.
//
// Properties are used to convey additional information about the link
// relation. As an example, consider this use of "properties":
//
// "properties" : { "http://webfinger.example/mail/port" : "993" }
//
// The "properties" member is OPTIONAL in the link relation object.
Properties map[string]string `json:"properties,omitempty"`
// Titles comprises zero or more name/value pairs whose
// names are a language tag or the string "und"
//
// Here is an example of the "titles" object:
//
// "titles" :
// {
// "en-us" : "The Magical World of Steve",
// "fr" : "Le Monde Magique de Steve"
// }
//
// The "titles" member is OPTIONAL in the link relation object.
Titles map[string]string `json:"titles,omitempty"`
}
// JSONResourceDescriptor represents a JSON Resource Descriptor (JRD) as per https://www.rfc-editor.org/rfc/rfc7033#section-4.4
type JSONResourceDescriptor struct {
// Subject is a URI that identifies the entity that the JRD describes
//
// The "subject" member SHOULD be present in the JRD.
Subject string `json:"subject,omitempty"`
// Aliases is an array of zero or more URI strings that identify the same
// entity as the "subject" URI.
//
// The "aliases" array is OPTIONAL in the JRD.
Aliases []string `json:"aliases,omitempty"`
// Properties is an object comprising zero or more name/value pairs whose
// names are URIs (referred to as "property identifiers") and whose
// values are strings or null.
//
// The "properties" member is OPTIONAL in the JRD.
Properties map[string]any `json:"properties,omitempty"`
// Links is an array of objects that contain link relation information
//
// The "links" array is OPTIONAL in the JRD.
Links []Link `json:"links,omitempty"`
}