Compare commits

..

1 Commits

Author SHA1 Message Date
b2761a3a89 feat: Add CI 2024-01-01 20:11:00 +01:00
27 changed files with 284 additions and 4710 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
# Artificats # Artificats
/rio rio
# Testing stuff # Testing stuff
*.json *.json

View File

@@ -1,15 +1,7 @@
steps: steps:
build: build: golang/1.21.5-alpine
image: "golang:1.21.5-alpine" commands:
commands: - go get
- go build ./cmd/rio - go build ./cmd/rio
- go test $(go list ./... | grep -v /vendor/) - go fmt $(go list ./... | grep -v /vendor/)
when: - go vet $(go list ./... | grep -v /vendor/)
- path: "**/*.go"
lint:
image: "golang:1.21.5-alpine"
commands:
- go fmt $(go list ./... | grep -v /vendor/)
- go vet $(go list ./... | grep -v /vendor/)
when:
- path: "**/*.go"

View File

@@ -1,10 +0,0 @@
FROM golang:1.25-alpine3.22 AS builder
COPY . /app
WORKDIR /app
RUN go build ./cmd/rio
FROM alpine:3.22
COPY --from=builder /app/rio /usr/local/bin/rio
ENTRYPOINT ["/usr/local/bin/rio"]

View File

@@ -31,15 +31,14 @@ configuration, only `--gitea-url` (`GITEA_URL`) and `--pages-domain` (`PAEGS_DOM
are required. are required.
If you run without `--acme-disable` (default), then rio will set up a HTTPS server If you run without `--acme-disable` (default), then rio will set up a HTTPS server
to listen on the configured host and port. Additionally, a DNS01 challenge solver to listen on the configured host and port. Additionally, a HTTP-only server will be
will be created using the provider specified by `--acme-dns-provider` (`ACME_DNS_PROVIDER`). For a provider list and each provider's options, see set up listening to `${ACME_HOST}:${ACME_PORT}`, responsible for HTTP01 ACME
the [lego documentation](https://go-acme.github.io/lego/dns/). In this mode, `--acme-email` (`ACME_EMAIL`; The email to use to register challenges. In this mode, `--acme-email` (`ACME_EMAIL`; The email to use to register
an ACME account), `--acme-file` (`ACME_FILE`; Path to the file where ACME account data is stored), `--certs-file` (`CERTS_FILE`; Path to the file where certificates an ACME account), `--acme-file` (`ACME_FILE`; Path to the file where ACME account data is stored), `--certs-file` (`CERTS_FILE`; Path to the file where certificates
are stored in) are additionally required. `--acme-server` (`ACME_SERVER`) should also are stored in) are additionally required. `--acme-server` (`ACME_SERVER`) should also
be set to your ACME CA's directory URL as this option defaults to the be set to your ACME CA's directory URL as this option defaults to the
[Let's Encrypt Staging Environment](https://letsencrypt.org/docs/staging-environment/). Note that using this optional implies that you accept your [Let's Encrypt Staging Environment](https://letsencrypt.org/docs/staging-environment/). Note that using this optional implies that you accept your
configured ACME CA's Terms of Service. rio will also spawn an unencrypted HTTP server that is bound to the host specified with `--http-host` (`HTTP_HOST`) and configured ACME CA's Terms of Service.
the port specified with `--http-port` (`HTTP_PORT`). The functionality of this second HTTP server is to upgrade plain HTTP requests to HTTPS requests.
You can also run with `--debug` (`DEBUG_ENABLE=1`) to enable debug logging. You can also run with `--debug` (`DEBUG_ENABLE=1`) to enable debug logging.
@@ -64,26 +63,12 @@ the pages domain:
``` ```
; Example for Bind ; Example for Bind
cooldomain.rio. 3600 IN CNAME <user>.pages.example.org cooldomain.rio. 3600 IN CNAME <user>.pages.example.org
; For Let's Encrypt
_acme-challenge.cooldomain.rio. 3600 IN CNAME cooldomain.rio.pages.example.org
``` ```
This additionally requires your repository to have a file named `CNAME` that contains This additionally requires your repository to have a file named `CNAME` that contains
the domain to use (`cooldomain.rio` in this case). the domain to use (`cooldomain.rio` in this case).
### Alternate CNAME Domains
If there is a situation where you want to use a CNAME redirect on a (sub-) domain,
where having a CNAME is not feasible, you can configure an "alternate CNAME".
This is a special TXT record that contains the "CNAME" you want to specify.
```
; Example for Bind
cooldomain.rio. 3600 IN A <IPv4 of rio>
_rio-cname.cooldomain.rio. 3600 IN TXT "cname=user.pages.example.org"
```
### Repository Redirects ### Repository Redirects
If you have multiple repositories with pages (and you use a CNAME), you can additionally If you have multiple repositories with pages (and you use a CNAME), you can additionally

View File

@@ -7,56 +7,37 @@ import (
"net" "net"
"net/http" "net/http"
"os" "os"
"regexp"
"strings" "strings"
"sync"
"time" "time"
"git.polynom.me/rio/internal/acme" "git.polynom.me/rio/internal/acme"
"git.polynom.me/rio/internal/certificates" "git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/dns" "git.polynom.me/rio/internal/dns"
riogitea "git.polynom.me/rio/internal/gitea"
"git.polynom.me/rio/internal/metrics"
"git.polynom.me/rio/internal/pages" "git.polynom.me/rio/internal/pages"
"git.polynom.me/rio/internal/repo" "git.polynom.me/rio/internal/repo"
"git.polynom.me/rio/internal/server" "git.polynom.me/rio/internal/server"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
legodns "github.com/go-acme/lego/v4/providers/dns" "github.com/go-acme/lego/v4/challenge/http01"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2" "github.com/urfave/cli/v2"
) )
func handleSubdomain(ctx *context.GlobalContext, domain, cname, path string, req *http.Request, w http.ResponseWriter) { func handleSubdomain(domain string, cname string, path, giteaUrl string, giteaClient *gitea.Client, w http.ResponseWriter) {
username := "" hostParts := strings.Split(domain, ".")
if cname != "" { username := hostParts[0]
// If we are accessed via a CNAME, then CNAME contains our <user>.<pages domain> value.
username = dns.ExtractUsername(ctx.PagesDomain, cname)
} else {
// If we are directly accessed, then domain contains our <user>.<pages domain> value.
username = dns.ExtractUsername(ctx.PagesDomain, domain)
}
// Strip the leading / // Strip the leading /
if path[:1] == "/" { if path[:1] == "/" {
path = path[1:] path = path[1:]
} }
// Provide a default file.
switch {
case path == "":
path = "/index.html"
case path[len(path)-1] == '/':
path = path + "index.html"
}
repo, path, err := repo.RepoFromPath( repo, path, err := repo.RepoFromPath(
username, username,
domain, domain,
cname, cname,
path, path,
ctx, giteaClient,
) )
if err != nil { if err != nil {
log.Errorf("Failed to get repo: %s", err) log.Errorf("Failed to get repo: %s", err)
@@ -64,38 +45,16 @@ func handleSubdomain(ctx *context.GlobalContext, domain, cname, path string, req
return return
} }
c := &context.Context{ pages.ServeFile(username, repo.Name, path, giteaUrl, w)
Username: username,
Reponame: repo.Name,
Domain: domain,
Path: path,
Referrer: req.Header.Get("Referer"),
UserAgent: req.Header.Get("User-Agent"),
DNT: req.Header.Get("DNT"),
GPC: req.Header.Get("Sec-GPC"),
Writer: w,
Global: ctx,
}
pages.ServeFile(c)
} }
func Handler(ctx *context.GlobalContext) http.HandlerFunc { func Handler(pagesDomain, giteaUrl string, giteaClient *gitea.Client) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) { return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Server", "rio") w.Header().Set("Server", "rio")
// Is the direct domain requested? if strings.HasSuffix(req.Host, pagesDomain) {
if req.Host == ctx.PagesDomain {
log.Debug("Direct pages domain is requested.")
// TODO: Handle
w.WriteHeader(404)
return
}
// Is a direct subdomain requested?
if strings.HasSuffix(req.Host, ctx.PagesDomain) {
log.Debug("Domain can be directly handled") log.Debug("Domain can be directly handled")
handleSubdomain(ctx, req.Host, "", req.URL.Path, req, w) handleSubdomain(req.Host, "", req.URL.Path, giteaUrl, giteaClient, w)
return return
} }
@@ -107,12 +66,9 @@ func Handler(ctx *context.GlobalContext) http.HandlerFunc {
} }
log.Debugf("Got CNAME %s", cname) log.Debugf("Got CNAME %s", cname)
// Is a direct subdomain requested after CNAME lookup? if strings.HasSuffix(cname, pagesDomain) {
// NOTE: We now require the leading dot because a CNAME to the direct
// pages domain makes no sense.
if strings.HasSuffix(cname, "."+ctx.PagesDomain) {
log.Debugf("%s is alias of %s and can be handled after a CNAME query", req.Host, cname) log.Debugf("%s is alias of %s and can be handled after a CNAME query", req.Host, cname)
handleSubdomain(ctx, req.Host, cname, req.URL.Path, req, w) handleSubdomain(cname, cname, req.URL.Path, giteaUrl, giteaClient, w)
return return
} }
@@ -121,22 +77,6 @@ func Handler(ctx *context.GlobalContext) http.HandlerFunc {
} }
} }
// Handle HTTP redirects to HTTPS.
func httpHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Server", "rio")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
// Upgrade the URL
req.URL.Scheme = "https"
req.URL.Host = req.Host
w.Header().Set("Location", req.URL.String())
// Send the 301
w.WriteHeader(301)
}
}
func runServer(ctx *cli.Context) error { func runServer(ctx *cli.Context) error {
giteaUrl := ctx.String("gitea-url") giteaUrl := ctx.String("gitea-url")
domain := ctx.String("pages-domain") domain := ctx.String("pages-domain")
@@ -144,12 +84,9 @@ func runServer(ctx *cli.Context) error {
acmeEmail := ctx.String("acme-email") acmeEmail := ctx.String("acme-email")
acmeServer := ctx.String("acme-server") acmeServer := ctx.String("acme-server")
acmeFile := ctx.String("acme-file") acmeFile := ctx.String("acme-file")
acmeDnsProvider := ctx.String("acme-dns-provider") acmeHost := ctx.String("acme-host")
acmePort := ctx.String("acme-port")
acmeDisable := ctx.Bool("acme-disable") acmeDisable := ctx.Bool("acme-disable")
defaultCsp := ctx.String("default-csp")
metricsUrl := ctx.String("metrics-url")
metricsBotList := ctx.String("metrics-bot-list")
tokenFile := ctx.String("token-file")
// Init Logging // Init Logging
if ctx.Bool("debug") { if ctx.Bool("debug") {
@@ -158,50 +95,14 @@ func runServer(ctx *cli.Context) error {
log.SetLevel(log.InfoLevel) log.SetLevel(log.InfoLevel)
} }
// Set up the Loki metrics
var lokiConfig metrics.MetricConfig
if metricsUrl == "" {
lokiConfig = metrics.MetricConfig{
Enabled: false,
}
} else {
var patterns []regexp.Regexp
if metricsBotList != "" {
patterns, _ = metrics.ReadBotPatterns(metricsBotList)
} else {
patterns = make([]regexp.Regexp, 0)
}
log.Infof("Read %d bot patterns from disk", len(patterns))
lokiConfig = metrics.MetricConfig{
Enabled: true,
BotUserAgents: &patterns,
Url: metricsUrl,
}
}
// If specified, read in an access token
token := ""
if tokenFile != "" {
t, err := readSecret(tokenFile)
if err != nil {
log.Warnf("Failed to read secret: %v", err)
}
token = t
}
// Setup the Gitea stuff // Setup the Gitea stuff
httpClient := http.Client{Timeout: 10 * time.Second} httpClient := http.Client{Timeout: 10 * time.Second}
giteaApiClient, err := gitea.NewClient( giteaClient, err := gitea.NewClient(
giteaUrl, giteaUrl,
gitea.SetHTTPClient(&httpClient), gitea.SetHTTPClient(&httpClient),
gitea.SetToken(token), gitea.SetToken(""),
gitea.SetUserAgent("rio"), gitea.SetUserAgent("rio"),
) )
if err != nil {
return err
}
giteaClient := riogitea.NewGiteaClient(giteaUrl, token, giteaApiClient)
// Listen on the port // Listen on the port
addr := ctx.String("listen-host") + ":" + ctx.String("listen-port") addr := ctx.String("listen-host") + ":" + ctx.String("listen-port")
@@ -212,23 +113,9 @@ func runServer(ctx *cli.Context) error {
return err return err
} }
// Prepare the context
cacheCtx := context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
UsernameCache: context.MakeUsernameCache(),
}
globalCtx := &context.GlobalContext{
DefaultCSP: defaultCsp,
PagesDomain: domain,
Gitea: &giteaClient,
MetricConfig: &lokiConfig,
Cache: &cacheCtx,
}
if !acmeDisable { if !acmeDisable {
if acmeEmail == "" || acmeFile == "" || certsFile == "" || acmeDnsProvider == "" { if acmeEmail == "" || acmeFile == "" || certsFile == "" {
return errors.New("The options acme-dns-provider, acme-file, acme-email, and certs-file are required") return errors.New("The options acme-file, acme-email, and certs-file are required")
} }
cache, err := certificates.CertificateCacheFromFile(certsFile) cache, err := certificates.CertificateCacheFromFile(certsFile)
@@ -262,15 +149,12 @@ func runServer(ctx *cli.Context) error {
log.Info("ACME client data read from disk") log.Info("ACME client data read from disk")
} }
// Set up the DNS01 challenge solver // Set up the HTTP01 listener
provider, err := legodns.NewDNSChallengeProviderByName(acmeDnsProvider) err = acmeClient.Challenge.SetHTTP01Provider(
http01.NewProviderServer(acmeHost, acmePort),
)
if err != nil { if err != nil {
log.Fatalf("Failed to create DNS01 challenge provider: %v", err) log.Fatalf("Failed to setup HTTP01 challenge listener: %v", err)
return err
}
err = acmeClient.Challenge.SetDNS01Provider(provider)
if err != nil {
log.Fatalf("Failed to setup DNS01 challenge solver: %v", err)
return err return err
} }
@@ -279,53 +163,16 @@ func runServer(ctx *cli.Context) error {
certsFile, certsFile,
&cache, &cache,
acmeClient, acmeClient,
globalCtx, giteaClient,
) )
listener = tls.NewListener(listener, tlsConfig) listener = tls.NewListener(listener, tlsConfig)
} }
var waitGroup sync.WaitGroup if err := http.Serve(listener, Handler(domain, giteaUrl, giteaClient)); err != nil {
servers := 2 fmt.Printf("Listening failed")
if acmeDisable { return err
servers = 1
}
waitGroup.Add(servers)
go func() {
defer waitGroup.Done()
log.Infof("Listening on main HTTP server %s", addr)
if err := http.Serve(listener, Handler(globalCtx)); err != nil {
log.Fatal(fmt.Errorf("Listening failed: %v", err))
}
log.Debug("Listening on main HTTP server done!")
}()
if !acmeDisable {
// Listen on the HTTP port
httpAddr := ctx.String("http-host") + ":" + ctx.String("http-port")
httpListener, err := net.Listen("tcp", httpAddr)
if err != nil {
fmt.Println(
fmt.Errorf("Failed to create HTTP listener: %v", err),
)
return err
}
go func() {
defer waitGroup.Done()
log.Debug("Listening on redirect HTTP server")
if err := http.Serve(httpListener, httpHandler()); err != nil {
log.Fatal(fmt.Errorf("Listening failed: %v", err))
}
log.Debug("Listening on redirect HTTP server done!")
}()
} }
log.Debug("Waiting...")
waitGroup.Wait()
log.Debug("Done...")
return nil return nil
} }
@@ -352,23 +199,17 @@ func main() {
Value: "8888", Value: "8888",
}, },
&cli.StringFlag{ &cli.StringFlag{
Name: "http-host", Name: "acme-host",
Usage: "The host to have unencrypted HTTP listen on", Usage: "The host to bind to for ACME challenges",
EnvVars: []string{"HTTP_HOST"}, EnvVars: []string{"ACME_HOST"},
Value: "127.0.0.1",
},
&cli.StringFlag{
Name: "http-port",
Usage: "The port to have unencrypted HTTP listen on",
EnvVars: []string{"HTTP_PORT"},
Value: "9999",
},
&cli.StringFlag{
Name: "acme-dns-provider",
Usage: "The provider to use for DNS01 challenge solving",
EnvVars: []string{"ACME_DNS_PROVIDER"},
Value: "", Value: "",
}, },
&cli.StringFlag{
Name: "acme-port",
Usage: "The port to listen on for ACME challenges",
EnvVars: []string{"ACME_PORT"},
Value: "8889",
},
&cli.StringFlag{ &cli.StringFlag{
Name: "pages-domain", Name: "pages-domain",
Usage: "The domain on which the server is reachable", Usage: "The domain on which the server is reachable",
@@ -409,30 +250,6 @@ func main() {
Usage: "Whether to enable debug logging", Usage: "Whether to enable debug logging",
EnvVars: []string{"DEBUG_ENABLE"}, EnvVars: []string{"DEBUG_ENABLE"},
}, },
&cli.StringFlag{
Name: "default-csp",
Usage: "The default CSP to include when sending HTTP responses",
Value: "",
EnvVars: []string{"DEFAULT_CSP"},
},
&cli.StringFlag{
Name: "metrics-url",
Usage: "The URL for metric pings",
Value: "",
EnvVars: []string{"METRICS_URL"},
},
&cli.StringFlag{
Name: "metrics-bot-list",
Usage: "File to read a list of regular expressions modelling bot user agents from",
Value: "",
EnvVars: []string{"METRICS_BOT_LIST"},
},
&cli.StringFlag{
Name: "token-file",
Usage: "File containing a access token. Required for serving private repositories",
Value: "",
EnvVars: []string{"TOKEN_FILE"},
},
}, },
} }

View File

@@ -1,16 +0,0 @@
package main
import (
"os"
"strings"
)
// Read a secret file and return its (cleaned) content.
func readSecret(path string) (string, error) {
content, err := os.ReadFile(path)
if err != nil {
return "", err
}
return strings.Trim(string(content), "\n\r "), nil
}

249
go.mod
View File

@@ -1,246 +1,29 @@
module git.polynom.me/rio module git.polynom.me/rio
go 1.24.7 go 1.20
require ( require (
code.gitea.io/sdk/gitea v0.22.1 code.gitea.io/sdk/gitea v0.17.1
github.com/go-acme/lego/v4 v4.28.0 github.com/go-acme/lego/v4 v4.14.2
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/urfave/cli/v2 v2.27.7 github.com/urfave/cli/v2 v2.27.1
) )
require ( require (
cloud.google.com/go/auth v0.17.0 // indirect github.com/cenkalti/backoff/v4 v4.2.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
cloud.google.com/go/compute v1.49.1 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/AdamSLevy/jsonrpc2/v14 v14.1.0 // indirect
github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/dns/armdns v1.2.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/privatedns/armprivatedns v1.3.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resourcegraph/armresourcegraph v0.9.0 // indirect
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
github.com/Azure/go-autorest/autorest v0.11.30 // indirect
github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect
github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect
github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect
github.com/Azure/go-autorest/autorest/to v0.4.1 // indirect
github.com/Azure/go-autorest/logger v0.2.2 // indirect
github.com/Azure/go-autorest/tracing v0.6.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect
github.com/OpenDNS/vegadns2client v0.0.0-20180418235048-a3fa4a771d87 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang v1.2.2 // indirect
github.com/akamai/AkamaiOPEN-edgegrid-golang/v11 v11.1.0 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea v1.3.13 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
github.com/aliyun/credentials-go v1.4.8 // indirect
github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129 // indirect
github.com/aws/aws-sdk-go-v2 v1.39.5 // indirect
github.com/aws/aws-sdk-go-v2/config v1.31.16 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.12 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/service/lightsail v1.50.3 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.59.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.0 // indirect
github.com/aws/smithy-go v1.23.2 // indirect
github.com/aziontech/azionapi-go-sdk v0.143.0 // indirect
github.com/baidubce/bce-sdk-go v0.9.250 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/boombuler/barcode v1.1.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/civo/civogo v0.6.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudflare/cloudflare-go v0.116.0 // indirect
github.com/cpu/goacmedns v0.1.1 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/deepmap/oapi-codegen v1.16.3 // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dnsimple/dnsimple-go v1.7.0 // indirect
github.com/dnsimple/dnsimple-go/v4 v4.0.0 // indirect
github.com/exoscale/egoscale v0.102.4 // indirect
github.com/exoscale/egoscale/v3 v3.1.27 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/ghodss/yaml v1.0.0 // indirect
github.com/go-acme/alidns-20150109/v4 v4.6.1 // indirect
github.com/go-acme/tencentclouddnspod v1.1.25 // indirect
github.com/go-acme/tencentedgdeone v1.1.48 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect github.com/go-jose/go-jose/v3 v3.0.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/hashicorp/go-version v1.6.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/miekg/dns v1.1.55 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.28.0 // indirect
github.com/go-resty/resty/v2 v2.16.5 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/gophercloud/gophercloud v1.14.1 // indirect
github.com/gophercloud/utils v0.0.0-20231010081019-80377eca5d56 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/huaweicloud/huaweicloud-sdk-go-v3 v0.1.174 // indirect
github.com/iij/doapi v0.0.0-20190504054126-0bbf12d6d7df // indirect
github.com/infobloxopen/infoblox-go-client v1.1.1 // indirect
github.com/infobloxopen/infoblox-go-client/v2 v2.11.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213 // indirect
github.com/kolo/xmlrpc v0.0.0-20220921171641-a4b6fa1dd06b // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labbsr0x/bindman-dns-webhook v1.0.2 // indirect
github.com/labbsr0x/goh v1.0.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/linode/linodego v1.60.0 // indirect
github.com/liquidweb/go-lwApi v0.0.5 // indirect
github.com/liquidweb/liquidweb-cli v0.7.0 // indirect
github.com/liquidweb/liquidweb-go v1.6.4 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/mimuret/golang-iij-dpf v0.9.1 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/namedotcom/go v0.0.0-20180403034216-08470befbe04 // indirect
github.com/namedotcom/go/v4 v4.0.2 // indirect
github.com/nrdcg/auroradns v1.1.0 // indirect
github.com/nrdcg/bunny-go v0.1.0 // indirect
github.com/nrdcg/desec v0.11.1 // indirect
github.com/nrdcg/dnspod-go v0.4.0 // indirect
github.com/nrdcg/freemyip v0.3.0 // indirect
github.com/nrdcg/goacmedns v0.2.0 // indirect
github.com/nrdcg/goinwx v0.11.0 // indirect
github.com/nrdcg/mailinabox v0.3.0 // indirect
github.com/nrdcg/namesilo v0.5.0 // indirect
github.com/nrdcg/nodion v0.1.0 // indirect
github.com/nrdcg/oci-go-sdk/common/v1065 v1065.103.0 // indirect
github.com/nrdcg/oci-go-sdk/dns/v1065 v1065.103.0 // indirect
github.com/nrdcg/porkbun v0.4.0 // indirect
github.com/nrdcg/vegadns v0.3.0 // indirect
github.com/nzdjb/go-metaname v1.0.0 // indirect
github.com/onsi/gomega v1.37.0 // indirect
github.com/oracle/oci-go-sdk v24.3.0+incompatible // indirect
github.com/ovh/go-ovh v1.9.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/peterhellberg/link v1.2.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/regfish/regfish-dnsapi-go v0.1.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sacloud/api-client-go v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
github.com/sacloud/go-http v0.1.9 // indirect golang.org/x/crypto v0.17.0 // indirect
github.com/sacloud/iaas-api-go v1.20.0 // indirect golang.org/x/mod v0.11.0 // indirect
github.com/sacloud/packages-go v0.0.11 // indirect golang.org/x/net v0.11.0 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect golang.org/x/sys v0.15.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect golang.org/x/text v0.14.0 // indirect
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 // indirect golang.org/x/tools v0.10.0 // indirect
github.com/selectel/domains-go v1.1.0 // indirect
github.com/selectel/go-selvpcclient/v4 v4.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/smartystreets/go-aws-auth v0.0.0-20180515143844-0c1422d1fdb9 // indirect
github.com/softlayer/softlayer-go v1.2.1 // indirect
github.com/softlayer/xmlrpc v0.0.0-20200409220501-5f089df7cb7e // indirect
github.com/sony/gobreaker v1.0.0 // indirect
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.1.48 // indirect
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dnspod v1.1.25 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/transip/gotransip/v6 v6.26.1 // indirect
github.com/ultradns/ultradns-go-sdk v1.8.1-20250722213956-faef419 // indirect
github.com/vinyldns/go-vinyldns v0.9.16 // indirect
github.com/volcengine/volc-sdk-golang v1.0.225 // indirect
github.com/vultr/govultr/v2 v2.17.2 // indirect
github.com/vultr/govultr/v3 v3.24.0 // indirect
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 // indirect
github.com/yandex-cloud/go-genproto v0.34.0 // indirect
github.com/yandex-cloud/go-sdk v0.27.0 // indirect
github.com/yandex-cloud/go-sdk/services/dns v0.0.16 // indirect
github.com/yandex-cloud/go-sdk/v2 v2.24.0 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opencensus.io v0.24.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/ratelimit v0.3.1 // indirect
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.254.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/ns1/ns1-go.v2 v2.15.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

2774
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -38,7 +38,7 @@ func RenewCertificate(old *CertificateWrapper, acmeClient *lego.Client) (Certifi
} }
wrapper := CertificateWrapper{ wrapper := CertificateWrapper{
TlsCertificate: &tlsCert, TlsCertificate: &tlsCert,
DomainKey: old.DomainKey, Domain: old.Domain,
NotAfter: time.Now().Add(time.Hour * 24 * 60), NotAfter: time.Now().Add(time.Hour * 24 * 60),
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(new.PrivateKey), PrivateKeyEncoded: base64.StdEncoding.EncodeToString(new.PrivateKey),
Certificate: new.Certificate, Certificate: new.Certificate,
@@ -47,7 +47,7 @@ func RenewCertificate(old *CertificateWrapper, acmeClient *lego.Client) (Certifi
return wrapper, nil return wrapper, nil
} }
func ObtainNewCertificate(domains []string, domainKey string, acmeClient *lego.Client) (CertificateWrapper, error) { func ObtainNewCertificate(domains []string, acmeClient *lego.Client) (CertificateWrapper, error) {
req := certificate.ObtainRequest{ req := certificate.ObtainRequest{
Domains: domains, Domains: domains,
Bundle: true, Bundle: true,
@@ -64,7 +64,7 @@ func ObtainNewCertificate(domains []string, domainKey string, acmeClient *lego.C
wrapper := CertificateWrapper{ wrapper := CertificateWrapper{
TlsCertificate: &tlsCert, TlsCertificate: &tlsCert,
DomainKey: domainKey, Domain: cert.Domain,
//NotAfter: tlsCert.Leaf.NotAfter, //NotAfter: tlsCert.Leaf.NotAfter,
NotAfter: time.Now().Add(time.Hour * 24 * 60), NotAfter: time.Now().Add(time.Hour * 24 * 60),
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(cert.PrivateKey), PrivateKeyEncoded: base64.StdEncoding.EncodeToString(cert.PrivateKey),
@@ -127,7 +127,7 @@ func MakeFallbackCertificate(pagesDomain string) (*CertificateWrapper, error) {
} }
return &CertificateWrapper{ return &CertificateWrapper{
TlsCertificate: &tlsCertificate, TlsCertificate: &tlsCertificate,
DomainKey: "*." + pagesDomain, Domain: pagesDomain,
NotAfter: notAfter, NotAfter: notAfter,
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(certcrypto.PEMEncode(key)), PrivateKeyEncoded: base64.StdEncoding.EncodeToString(certcrypto.PEMEncode(key)),
Certificate: outBytes, Certificate: outBytes,

View File

@@ -14,23 +14,12 @@ import (
// A convenience wrapper around a TLS certificate // A convenience wrapper around a TLS certificate
type CertificateWrapper struct { type CertificateWrapper struct {
// The parsed TLS certificate we can pass to the tls listener TlsCertificate *tls.Certificate `json:"-"`
TlsCertificate *tls.Certificate `json:"-"` Domain string `json:"domain"`
NotAfter time.Time `json:"not_after"`
// Key identifying for which domain(s) this certificate is valid. PrivateKeyEncoded string `json:"private_key"`
DomainKey string `json:"domain"` Certificate []byte `json:"certificate"`
CSR []byte `json:"csr"`
// Indicates at which point in time this certificate is no longer valid.
NotAfter time.Time `json:"not_after"`
// The encoded private key.
PrivateKeyEncoded string `json:"private_key"`
// The PEM-encoded certificate.
Certificate []byte `json:"certificate"`
// The CSR provided when we requested the certificate.
CSR []byte `json:"csr"`
} }
// A structure to store all the certificates we know of in. // A structure to store all the certificates we know of in.
@@ -38,7 +27,7 @@ type CertificatesCache struct {
// The certificate to use as a fallback if all else fails. // The certificate to use as a fallback if all else fails.
FallbackCertificate *CertificateWrapper FallbackCertificate *CertificateWrapper
// Mapping of a domain's domain key to the certificate. // Mapping of domain name to certificate.
Certificates map[string]CertificateWrapper Certificates map[string]CertificateWrapper
} }
@@ -94,7 +83,7 @@ func (c *CertificatesCache) FlushToDisk(path string) {
} }
func (c *CertificatesCache) AddCert(cert CertificateWrapper, path string) { func (c *CertificatesCache) AddCert(cert CertificateWrapper, path string) {
c.Certificates[cert.DomainKey] = cert c.Certificates[cert.Domain] = cert
c.FlushToDisk(path) c.FlushToDisk(path)
} }
@@ -116,7 +105,7 @@ func CertificateCacheFromFile(path string) (CertificatesCache, error) {
certs := make(map[string]CertificateWrapper) certs := make(map[string]CertificateWrapper)
for _, cert := range store.Certificates { for _, cert := range store.Certificates {
cert.initTlsCertificate() cert.initTlsCertificate()
certs[cert.DomainKey] = cert certs[cert.Domain] = cert
} }
cache.Certificates = certs cache.Certificates = certs

View File

@@ -1,6 +0,0 @@
package constants
const (
// The branch to serve.
PagesBranch = "pages"
)

View File

@@ -1,45 +0,0 @@
package context
import (
"net/http"
"git.polynom.me/rio/internal/gitea"
"git.polynom.me/rio/internal/metrics"
"github.com/patrickmn/go-cache"
)
type CacheContext struct {
// Cache for general repository information
RepositoryInformationCache cache.Cache
// Cache for path resolutions
RepositoryPathCache cache.Cache
// Cache for username lookups
UsernameCache cache.Cache
}
type GlobalContext struct {
DefaultCSP string
PagesDomain string
Gitea *gitea.GiteaClient
MetricConfig *metrics.MetricConfig
Cache *CacheContext
}
type Context struct {
Username string
Reponame string
Domain string
Path string
// HTTP Stuff
Referrer string
UserAgent string
DNT string
GPC string
Writer http.ResponseWriter
// Pointer to the global context
Global *GlobalContext
}

View File

@@ -1,40 +0,0 @@
package context
import (
"time"
"github.com/patrickmn/go-cache"
)
type RepositoryInformation struct {
// Headers to include in every response
Headers map[string]string
CNAME string
}
func repoInfoKey(owner, name string) string {
return owner + ":" + name
}
func (c *CacheContext) GetRepositoryInformation(owner, repoName string) *RepositoryInformation {
data, found := c.RepositoryInformationCache.Get(repoInfoKey(owner, repoName))
if !found {
return nil
}
typedData := data.(RepositoryInformation)
return &typedData
}
func (c *CacheContext) SetRepositoryInformation(owner, repoName string, info RepositoryInformation) {
c.RepositoryInformationCache.Set(
repoInfoKey(owner, repoName),
info,
cache.DefaultExpiration,
)
}
func MakeRepoInfoCache() cache.Cache {
return *cache.New(24*time.Hour, 12*time.Hour)
}

View File

@@ -1,39 +0,0 @@
package context
import (
"time"
"git.polynom.me/rio/internal/gitea"
"github.com/patrickmn/go-cache"
)
type RepositoryPathInformation struct {
Repository gitea.Repository
Path string
}
func pathInfoKey(domain, path string) string {
return domain + "/" + path
}
func (c *CacheContext) GetRepositoryPath(domain, path string) *RepositoryPathInformation {
data, found := c.RepositoryPathCache.Get(pathInfoKey(domain, path))
if !found {
return nil
}
typedData := data.(RepositoryPathInformation)
return &typedData
}
func (c *CacheContext) SetRepositoryPath(domain, path string, info RepositoryPathInformation) {
c.RepositoryPathCache.Set(
pathInfoKey(domain, path),
info,
cache.DefaultExpiration,
)
}
func MakeRepoPathCache() cache.Cache {
return *cache.New(24*time.Hour, 12*time.Hour)
}

View File

@@ -1,24 +0,0 @@
package context
import (
"time"
"github.com/patrickmn/go-cache"
)
func (c *CacheContext) GetUser(username string) bool {
_, found := c.UsernameCache.Get(username)
return found
}
func (c *CacheContext) SetUser(username string) {
c.UsernameCache.Set(
username,
true,
cache.DefaultExpiration,
)
}
func MakeUsernameCache() cache.Cache {
return *cache.New(24*time.Hour, 12*time.Hour)
}

View File

@@ -1,8 +1,8 @@
package dns package dns
import ( import (
"net"
"errors" "errors"
"net"
"strings" "strings"
"time" "time"
@@ -16,10 +16,6 @@ const (
// The key that the TXT record will have to start with, e.g. // The key that the TXT record will have to start with, e.g.
// "repo=some-random-repo". // "repo=some-random-repo".
TxtRepoKey = "repo=" TxtRepoKey = "repo="
TxtCNAMERecord = "_rio-cname."
TxtCNAMEKey = "cname="
) )
var ( var (
@@ -63,56 +59,19 @@ func LookupRepoTXT(domain string) (string, error) {
func LookupCNAME(domain string) (string, error) { func LookupCNAME(domain string) (string, error) {
cname, found := cnameCache.Get(domain) cname, found := cnameCache.Get(domain)
if found { if found {
if cname == "" {
return "", errors.New("Previous request failure")
}
return cname.(string), nil return cname.(string), nil
} }
cname, err := lookupCNAME(domain) cname, err := net.LookupCNAME(domain)
if err == nil { if err == nil {
cnameCache.Set(domain, cname, cache.DefaultExpiration) cnameCache.Set(domain, cname, cache.DefaultExpiration)
return cname.(string), nil return cname.(string), nil
} }
altCname, err := lookupCNAMETxt(domain) cnameCache.Set(domain, "", cache.DefaultExpiration)
if err == nil {
cnameCache.Set(domain, altCname, cache.DefaultExpiration)
return altCname, nil
}
return "", err
}
// Lookup the CNAME by trying to find a CNAME RR. Contrary to net.LookupCNAME,
// this method fails if we find no CNAME RR.
func lookupCNAME(domain string) (string, error) {
query, err := net.LookupCNAME(domain)
if err == nil {
if query[len(query)-1] == '.' {
query = query[:len(query)-1]
}
// Fail if we have no CNAME RR.
if query == domain {
return "", errors.New("CNAME is equal to domain")
}
return query, nil
}
return "", err
}
// Performs an alternative CNAME lookup by looking for a special TXT record.
func lookupCNAMETxt(domain string) (string, error) {
txts, err := net.LookupTXT(TxtCNAMERecord+domain)
if err == nil {
for _, txt := range txts {
if !strings.HasPrefix(txt, TxtCNAMEKey) {
continue
}
return strings.TrimPrefix(txt, TxtCNAMEKey), nil
}
}
return "", err return "", err
} }

View File

@@ -1,32 +0,0 @@
package dns
import "testing"
func cleanCache() {
cnameCache.Flush()
txtRepoCache.Flush()
}
func TestAltCNAME(t *testing.T) {
defer cleanCache()
res, err := lookupCNAMETxt("moxxy.org")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if res != "moxxy.pages.polynom.me" {
t.Fatalf("Unexpected alt CNAME: %s", res)
}
}
func TestLookupCNAMEWithAlt(t *testing.T) {
defer cleanCache()
res, err := LookupCNAME("moxxy.org")
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if res != "moxxy.pages.polynom.me" {
t.Fatalf("Unexpected alt CNAME: %s", res)
}
}

View File

@@ -1,15 +0,0 @@
package dns
import "strings"
// Extract the username from the domain name @domain that we're processing
// at the moment.
func ExtractUsername(pagesDomain, domain string) string {
suffixlessDomain := strings.TrimSuffix(domain, "."+pagesDomain)
usernameParts := strings.Split(suffixlessDomain, ".")
if len(usernameParts) == 1 {
return usernameParts[0]
}
return strings.Join(usernameParts, ".")
}

View File

@@ -1,23 +0,0 @@
package dns
import "testing"
func TestExtractUsernameSimple(t *testing.T) {
username := ExtractUsername(
"pages.local",
"papatutuwawa.pages.local",
)
if username != "papatutuwawa" {
t.Fatalf("Unexpected username: '%s'", username)
}
}
func TestExtractUsernameDot(t *testing.T) {
username := ExtractUsername(
"pages.local",
"polynom.me.pages.local",
)
if username != "polynom.me" {
t.Fatalf("Unexpected username: '%s'", username)
}
}

View File

@@ -1,130 +0,0 @@
package gitea
import (
"fmt"
"io"
"net/http"
"time"
"code.gitea.io/sdk/gitea"
log "github.com/sirupsen/logrus"
"git.polynom.me/rio/internal/dns"
)
// Returns true if the repository at <username>/<repository> exists, false if it
// does not.
type GetRepositoryMethod func(username, repositoryName string) (Repository, error)
// Returns <file content>, nil if the file exists at path <path> (relative to the repository) in
// <username>/<repository>@<branch> exists. If not, returns "", error.
type GetFileMethod func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error)
// Returns the result of a CNAME lookup for @domain.
type LookupCNAMEMethod func(domain string) (string, error)
// Return the repository the domain should point to by looking up the TXT record
// "_rio-pages.<domain>".
type LookupRepoTXTMethod func(domain string) (string, error)
// Check of the repository <username>/<repositoryName> contains the specified
// branch.
type HasBranchMethod func(username, repositoryName, branchName string) bool
// Check if the specified username exists.
type HasUserMethod func(username string) bool
type Repository struct {
Name string
}
type GiteaClient struct {
Token string
GetRepository GetRepositoryMethod
HasBranch HasBranchMethod
HasUser HasUserMethod
GetFile GetFileMethod
LookupCNAME LookupCNAMEMethod
LookupRepoTXT LookupRepoTXTMethod
}
func NewGiteaClient(giteaUrl string, token string, giteaClient *gitea.Client) GiteaClient {
return GiteaClient{
Token: token,
GetRepository: func(username, repositoryName string) (Repository, error) {
repo, _, err := giteaClient.GetRepo(username, repositoryName)
if err != nil {
return Repository{}, err
}
return Repository{
Name: repo.Name,
}, nil
},
HasBranch: func(username, repositoryName, branchName string) bool {
res, _, err := giteaClient.ListRepoBranches(username, repositoryName, gitea.ListRepoBranchesOptions{})
if err != nil {
return false
}
for _, branch := range res {
if branch.Name == branchName {
return true
}
}
return false
},
HasUser: func(username string) bool {
_, _, err := giteaClient.GetUserInfo(username)
return err == nil
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
// We have to do the raw request manually because the Gitea SDK does not allow
// passing the If-Modfied-Since header.
apiUrl := fmt.Sprintf(
"%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
giteaUrl,
username,
repositoryName,
path,
branch,
)
log.Debugf("GetFile: Requesting '%s'", apiUrl)
client := &http.Client{}
req, err := http.NewRequest("GET", apiUrl, nil)
if since != nil {
sinceFormat := since.Format(time.RFC1123)
req.Header.Add("If-Modified-Since", sinceFormat)
}
// Add authentication, if we have a token
if token != "" {
req.Header.Add("Authorization", "token "+token)
}
resp, err := client.Do(req)
if err != nil {
return []byte{}, true, err
}
defer resp.Body.Close()
content, err := io.ReadAll(resp.Body)
if err != nil {
return []byte{}, true, err
} else if resp.StatusCode == 302 {
return []byte{}, false, nil
} else if resp.StatusCode == 404 {
return []byte{}, false, fmt.Errorf("File does not exist")
} else {
return content, true, err
}
},
LookupCNAME: func(domain string) (string, error) {
return dns.LookupCNAME(domain)
},
LookupRepoTXT: func(domain string) (string, error) {
return dns.LookupRepoTXT(domain)
},
}
}

View File

@@ -1,99 +0,0 @@
package metrics
import (
"encoding/json"
"net/http"
"os"
"regexp"
"strings"
log "github.com/sirupsen/logrus"
)
type MetricConfig struct {
Url string
BotUserAgents *[]regexp.Regexp
Enabled bool
}
// Checks if we should send a metric ping to page-metrics based on the served path.
func (c *MetricConfig) ShouldSendMetrics(path, userAgent, dnt, gpc string) bool {
if !strings.HasSuffix(path, ".html") || !c.Enabled {
return false
}
// Ignore requests where the user have set "Do-Not-Track" or "Do-Not-Sell-My-Data", even though
// there is no user data and we're not selling it.
if dnt == "1" || gpc == "1" {
return false
}
// Filter out bots
for _, pattern := range *c.BotUserAgents {
if pattern.MatchString(userAgent) {
return false
}
}
return true
}
func (c *MetricConfig) SendMetricPing(domain, path, referrer string) {
data := map[string]string{
"domain": domain,
"path": path,
"referer": referrer,
}
jsonData, err := json.Marshal(data)
if err != nil {
log.Errorf("Failed to send metric ping: %v", err)
return
}
log.Debugf("Sending payload %s", string(jsonData))
// Send the ping to the server
go func() {
res, err := http.Post(
c.Url,
"application/json",
strings.NewReader(string(jsonData)),
)
if err != nil {
log.Errorf("Failed to send payload to: %v", err)
return
}
defer res.Body.Close()
if res.StatusCode != 200 {
log.Errorf("Server returned non-200 status code %d", res.StatusCode)
}
}()
}
// Reads a JSON array of bot user agents from disk and parses them
// into regular expressions.
func ReadBotPatterns(file string) ([]regexp.Regexp, error) {
content, err := os.ReadFile(file)
if err != nil {
log.Warnf("Failed to read bot metrics file: %v", err)
return []regexp.Regexp{}, err
}
var payload []string
err = json.Unmarshal(content, &payload)
if err != nil {
log.Warnf("Failed to unmarshal file: %v", err)
return []regexp.Regexp{}, err
}
patterns := make([]regexp.Regexp, 0)
for _, v := range payload {
patterns = append(
patterns,
*regexp.MustCompile(v),
)
}
return patterns, nil
}

View File

@@ -1,32 +0,0 @@
package metrics
import (
"regexp"
"testing"
)
func TestShouldPing(t *testing.T) {
cfg := MetricConfig{
Enabled: true,
Url: "",
BotUserAgents: &[]regexp.Regexp{
*regexp.MustCompile("random-bot/.*"),
},
}
if cfg.ShouldSendMetrics("/index.html", "random-bot/v23.5", "", "") {
t.Fatalf("Accepted bot user-agent")
}
if !cfg.ShouldSendMetrics("/index.html", "Firefox/...", "", "") {
t.Fatalf("Rejected real user-agent")
}
if cfg.ShouldSendMetrics("/index.html", "Firefox/...", "1", "") {
t.Fatalf("Ignored DNT")
}
if cfg.ShouldSendMetrics("/index.html", "Firefox/...", "", "1") {
t.Fatalf("Ignored GPC")
}
}

View File

@@ -1,19 +1,22 @@
package pages package pages
import ( import (
"fmt"
"io"
"mime" "mime"
"net/http" "net/http"
"strconv"
"strings" "strings"
"time" "time"
"git.polynom.me/rio/internal/constants"
"git.polynom.me/rio/internal/context"
"github.com/patrickmn/go-cache" "github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
const (
// The branch name on which files must reside.
PagesBranch = "pages"
)
var ( var (
pageCache = cache.New(6*time.Hour, 1*time.Hour) pageCache = cache.New(6*time.Hour, 1*time.Hour)
) )
@@ -28,78 +31,80 @@ func makePageContentCacheEntry(username, path string) string {
return username + ":" + path return username + ":" + path
} }
func addHeaders(repoInfo *context.RepositoryInformation, contentType string, contentLength int, w http.ResponseWriter) { func ServeFile(username, reponame, path, giteaUrl string, w http.ResponseWriter) {
// Always set a content type // Provide a default
if strings.Trim(contentType, " ") == "" { if path == "" {
w.Header().Set("Content-Type", "application/octet-stream") path = "/index.html"
} else {
w.Header().Set("Content-Type", contentType)
} }
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
w.Header().Set("Content-Length", strconv.Itoa(contentLength))
if repoInfo != nil {
for key, value := range repoInfo.Headers {
w.Header().Set(key, value)
}
}
}
func ServeFile(context *context.Context) {
// Strip away a starting / as it messes with Gitea // Strip away a starting / as it messes with Gitea
path := context.Path
if path[:1] == "/" { if path[:1] == "/" {
path = path[1:] path = path[1:]
} }
key := makePageContentCacheEntry(context.Username, path) key := makePageContentCacheEntry(username, path)
entry, found := pageCache.Get(key) entry, found := pageCache.Get(key)
var content []byte var content []byte
var mimeType string var mimeType string
var err error var err error
var since *time.Time = nil
if found { if found {
log.Debugf("Returning %s from cache", path) log.Debugf("Returning %s from cache", path)
content = entry.(PageContentCache).Content content = entry.(PageContentCache).Content
mimeType = entry.(PageContentCache).mimeType mimeType = entry.(PageContentCache).mimeType
sinceRaw := entry.(PageContentCache).RequestedAt
since = &sinceRaw
} }
content, changed, err := context.Global.Gitea.GetFile( // We have to do the raw request manually because the Gitea SDK does not allow
context.Username, // passing the If-Modfied-Since header.
context.Reponame, apiUrl := fmt.Sprintf(
constants.PagesBranch, "%s/api/v1/repos/%s/%s/raw/%s?ref=%s",
giteaUrl,
username,
reponame,
path, path,
since, PagesBranch,
) )
repoInfo := context.Global.Cache.GetRepositoryInformation( client := &http.Client{}
context.Username, req, err := http.NewRequest("GET", apiUrl, nil)
context.Reponame, if found {
) since := entry.(PageContentCache).RequestedAt.Format(time.RFC1123)
log.Debugf("Found %s in cache. Adding '%s' as If-Modified-Since", key, since)
req.Header.Add("If-Modified-Since", since)
}
resp, err := client.Do(req)
if err != nil { if err != nil {
if !found { if !found {
log.Errorf("Failed to get file %s/%s/%s (%s)", context.Username, context.Reponame, path, err) log.Errorf("Failed to get file %s/%s/%s (%s)", username, reponame, path, err)
addHeaders(repoInfo, "text/html", 0, context.Writer) w.WriteHeader(404)
context.Writer.WriteHeader(404)
} else { } else {
log.Debugf("Request failed but page %s is cached in memory", path) log.Debugf("Request failed but page %s is cached in memory", path)
addHeaders(repoInfo, mimeType, len(content), context.Writer) w.WriteHeader(200)
context.Writer.WriteHeader(200) w.Header().Set("Content-Type", mimeType)
context.Writer.Write(content) w.Write(content)
} }
return return
} }
defer resp.Body.Close()
if found && !changed { log.Debugf("Gitea API request returned %d", resp.StatusCode)
if found && resp.StatusCode == 302 {
log.Debugf("Page %s is unchanged and cached in memory", path) log.Debugf("Page %s is unchanged and cached in memory", path)
addHeaders(repoInfo, mimeType, len(content), context.Writer) w.WriteHeader(200)
context.Writer.WriteHeader(200) w.Header().Set("Content-Type", mimeType)
context.Writer.Write(content) w.Write(content)
return
}
// Correctly propagate 404s.
if resp.StatusCode == 404 {
w.WriteHeader(404)
return
}
content, err = io.ReadAll(resp.Body)
if err != nil {
log.Errorf("Failed to get file %s/%s/%s (%s)", username, reponame, path, err)
w.WriteHeader(404)
return return
} }
@@ -119,12 +124,7 @@ func ServeFile(context *context.Context) {
) )
log.Debugf("Page %s requested from Gitea and cached in memory at %v", path, now) log.Debugf("Page %s requested from Gitea and cached in memory at %v", path, now)
addHeaders(repoInfo, mimeType, len(content), context.Writer) w.Header().Set("Content-Type", mimeType)
context.Writer.WriteHeader(200) w.WriteHeader(200)
context.Writer.Write(content) w.Write(content)
// Tell Loki about if, if desired
if context.Global.MetricConfig.ShouldSendMetrics(path, context.UserAgent, context.DNT, context.GPC) {
context.Global.MetricConfig.SendMetricPing(context.Domain, path, context.Referrer)
}
} }

View File

@@ -1,130 +1,137 @@
package repo package repo
//go:generate mockgen -destination mock_repo_test.go -package repo code.gitea.io/sdk/gitea Client
import ( import (
"encoding/json"
"errors" "errors"
"slices"
"strings" "strings"
"time"
"git.polynom.me/rio/internal/constants" "git.polynom.me/rio/internal/dns"
"git.polynom.me/rio/internal/context" "git.polynom.me/rio/internal/pages"
"git.polynom.me/rio/internal/gitea"
"code.gitea.io/sdk/gitea"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
) )
var ( var (
ForbiddenHeaders = []string{ pathCache = cache.New(1*time.Hour, 1*time.Hour)
"content-length",
"content-type", // Caching the existence of an user
"date", userCache = cache.New(24*time.Hour, 12*time.Hour)
"location",
"strict-transport-security",
"set-cookie",
}
) )
func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path, cname string, ctx *context.GlobalContext) (*gitea.Repository, error) { type PageCacheEntry struct {
log.Debugf("CNAME: %s", cname) Repository *gitea.Repository
Path string
}
func makePageCacheKey(domain, path string) string {
return domain + "/" + path
}
// / Try to find the repository with name @reponame of the user @username. If @cname
// / is not "", then it also verifies that the repository contains a "CNAME" with
// / the value of @cname as its content. @host, @domain, and @path are passed for
// / caching on success.
func lookupRepositoryAndCache(username, reponame, host, domain, path, cname string, giteaClient *gitea.Client) (*gitea.Repository, error) {
log.Debugf("Looking up repository %s/%s", username, reponame) log.Debugf("Looking up repository %s/%s", username, reponame)
repo, err := ctx.Gitea.GetRepository(username, reponame) repo, _, err := giteaClient.GetRepo(username, reponame)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !ctx.Gitea.HasBranch(username, reponame, branchName) {
return nil, errors.New("Specified branch does not exist")
}
// Check if the CNAME file matches // Check if the CNAME file matches
if cname != "" { if cname != "" {
log.Debug("Checking CNAME") file, _, err := giteaClient.GetFile(
repoInfo := GetRepositoryInformation(username, reponame, ctx) username,
if repoInfo == nil { repo.Name,
log.Warn("Repository does not contain a rio.json file") pages.PagesBranch,
return nil, errors.New("No CNAME available in repository") "CNAME",
false,
)
if err != nil {
log.Errorf("Could not verify CNAME of %s/%s: %v\n", username, repo.Name, err)
return nil, err
} }
log.Debugf("CNAME Content: \"%s\"", repoInfo.CNAME) cnameContent := strings.Trim(
if repoInfo.CNAME != host { string(file[:]),
log.Warnf("CNAME mismatch: Repo '%s', Host '%s'", repoInfo.CNAME, host) "\n",
)
if cnameContent != cname {
return nil, errors.New("CNAME mismatch") return nil, errors.New("CNAME mismatch")
} }
} }
// Cache data // Cache data
ctx.Cache.SetRepositoryPath( pathCache.Set(
domain, makePageCacheKey(domain, path),
path, PageCacheEntry{
context.RepositoryPathInformation{ repo,
Repository: repo, path,
Path: path,
}, },
cache.DefaultExpiration,
) )
return &repo, nil return repo, nil
} }
// host is the domain name we're accessed from. cname is the domain that host is pointing func RepoFromPath(username, host, cname, path string, giteaClient *gitea.Client) (*gitea.Repository, string, error) {
// if, if we're accessed via a CNAME. If not, then cname is "".
func RepoFromPath(username, host, cname, path string, ctx *context.GlobalContext) (*gitea.Repository, string, error) {
domain := host domain := host
// Guess the repository // Guess the repository
entry := ctx.Cache.GetRepositoryPath(domain, path) key := makePageCacheKey(domain, path)
if entry != nil { entry, found := pathCache.Get(key)
return &entry.Repository, entry.Path, nil if found {
} pageEntry := entry.(PageCacheEntry)
return pageEntry.Repository, pageEntry.Path, nil
// Allow specifying the repository name in the TXT record
reponame := ""
if cname != "" {
repoLookup, err := ctx.Gitea.LookupRepoTXT(host)
if err == nil && repoLookup != "" {
log.Infof(
"TXT lookup for %s resulted in choosing repository %s",
host,
repoLookup,
)
reponame = repoLookup
}
} }
pathParts := strings.Split(path, "/") pathParts := strings.Split(path, "/")
log.Debugf("reponame='%s' len(pathParts)='%d'", reponame, len(pathParts)) if len(pathParts) > 1 {
if reponame == "" && len(pathParts) > 1 {
log.Debugf("Trying repository %s", pathParts[0]) log.Debugf("Trying repository %s", pathParts[0])
modifiedPath := strings.Join(pathParts[1:], "/") modifiedPath := strings.Join(pathParts[1:], "/")
repo, err := lookupRepositoryAndCache( repo, err := lookupRepositoryAndCache(
username, username,
pathParts[0], pathParts[0],
constants.PagesBranch,
host, host,
domain, domain,
modifiedPath, modifiedPath,
cname, cname,
ctx, giteaClient,
) )
if err == nil { if err == nil {
return repo, modifiedPath, nil return repo, modifiedPath, nil
} }
} }
if reponame == "" { // Allow specifying the repository name in the TXT record
reponame = domain reponame := domain
lookupDomain := domain
if cname != "" {
lookupDomain = cname
} }
repoLookup, err := dns.LookupRepoTXT(lookupDomain)
if err != nil && repoLookup != "" {
log.Infof(
"TXT lookup for %s resulted in choosing repository %s",
lookupDomain,
repoLookup,
)
reponame = repoLookup
} else if cname != "" {
// Allow naming the repository "example.org" (But give the TXT record preference)
reponame = cname
}
log.Debugf("Trying repository %s/%s", username, reponame) log.Debugf("Trying repository %s/%s", username, reponame)
repo, err := lookupRepositoryAndCache( repo, err := lookupRepositoryAndCache(
username, username,
reponame, reponame,
constants.PagesBranch,
host, host,
domain, domain,
path, path,
cname, cname,
ctx, giteaClient,
) )
return repo, path, err return repo, path, err
} }
@@ -132,92 +139,15 @@ func RepoFromPath(username, host, cname, path string, ctx *context.GlobalContext
// Checks if the username exists as an organisation or an user on the Gitea // Checks if the username exists as an organisation or an user on the Gitea
// instance, so that an attacker can't just request certificates for random // instance, so that an attacker can't just request certificates for random
// usernames. // usernames.
func CanRequestCertificate(username string, ctx *context.GlobalContext) bool { func CanRequestCertificate(username string, giteaClient *gitea.Client) bool {
found := ctx.Cache.GetUser(username) if _, found := userCache.Get(username); found {
if found {
return true return true
} }
hasUser := ctx.Gitea.HasUser(username) user, _, err := giteaClient.GetUserInfo(username)
if hasUser { if user != nil && err == nil {
ctx.Cache.SetUser(username) userCache.Set(username, true, cache.DefaultExpiration)
return true
} }
return hasUser return false
}
func filterHeaders(headers map[string]interface{}) map[string]string {
newHeaders := make(map[string]string)
for key, value := range headers {
if slices.Contains[[]string, string](ForbiddenHeaders, strings.ToLower(key)) {
continue
}
switch value.(type) {
case string:
newHeaders[key] = value.(string)
}
}
return newHeaders
}
func GetRepositoryInformation(owner, repoName string, ctx *context.GlobalContext) *context.RepositoryInformation {
res := ctx.Cache.GetRepositoryInformation(owner, repoName)
if res != nil {
return res
}
fetchedConfig, _, err := ctx.Gitea.GetFile(
owner,
repoName,
constants.PagesBranch,
"rio.json",
nil,
)
if err != nil {
log.Errorf("Failed to request rio.json for %s/%s:%v", owner, repoName, err)
return nil
}
var payload map[string]interface{}
err = json.Unmarshal(fetchedConfig, &payload)
if err != nil {
log.Errorf("Failed to unmarshal rio.json for %s/%s:%v", owner, repoName, err)
return nil
}
headers, found := payload["headers"]
if !found {
log.Warnf("Did not find headers key in rio.json for %s/%s", owner, repoName)
headers = make(map[string]interface{})
} else {
switch headers.(type) {
case map[string]interface{}:
// NOOP
default:
log.Warn("headers attribute has invalid data type")
headers = make(map[string]string)
}
}
cname, found := payload["CNAME"]
if found {
switch cname.(type) {
case string:
// NOOP
default:
log.Warnf("CNAME attribute is not a string for %s/%s", owner, repoName)
cname = ""
}
} else {
cname = ""
}
info := context.RepositoryInformation{
Headers: filterHeaders(headers.(map[string]interface{})),
CNAME: cname.(string),
}
ctx.Cache.SetRepositoryInformation(owner, repoName, info)
return &info
} }

View File

@@ -1,550 +1,39 @@
package repo package repo
import ( import (
"errors" "net/http"
"strings"
"testing" "testing"
"time" "time"
"git.polynom.me/rio/internal/context" "code.gitea.io/sdk/gitea"
"git.polynom.me/rio/internal/gitea"
log "github.com/sirupsen/logrus"
) )
func TestHeaderFilter(t *testing.T) { var (
map1 := filterHeaders( giteaClient, _ = gitea.NewClient(
map[string]interface{}{ "https://git.polynom.me",
"Content-Type": "hallo", gitea.SetHTTPClient(&http.Client{Timeout: 10 * time.Second}),
"content-Type": "welt", gitea.SetToken(""),
"content-type": "uwu", gitea.SetUserAgent("rio/testing"),
"CONTENT-TYPE": "lol",
"Content-Security-Policy": "none",
},
) )
)
if len(map1) != 1 { func TestCanRequestCertificatePositiveUser(t *testing.T) {
t.Fatalf("filterHeaders allowed %d != 1 headers", len(map1)) res := CanRequestCertificate("papatutuwawa", giteaClient)
} if !res {
t.Fatalf("User papatutuwawa should be servable")
for key := range map1 {
if strings.ToLower(key) == "content-type" {
t.Fatalf("filterHeaders allowed Content-Type")
}
} }
} }
func TestPickingCorrectRepositoryDefault(t *testing.T) { func TestCanRequestCertificatePositiveOrganisation(t *testing.T) {
// Test that we default to the <username>.<pages domain> repository, if we have only res := CanRequestCertificate("moxxy", giteaClient)
// one path component. if !res {
log.SetLevel(log.DebugLevel) t.Fatalf("Organisation moxxy should be servable")
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" {
t.Fatalf("Called with unknown user %s", username)
}
if repositoryName != "example-user.pages.example.org" {
t.Fatalf("Called with unknown repository %s", repositoryName)
}
return gitea.Repository{}, nil
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
t.Fatal("getFile called")
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
t.Fatal("LookupCNAME called")
return "", nil
},
LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("LookupRepoTXT called")
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "index.html", ctx)
if err != nil {
t.Fatalf("An error occured: %v", err)
}
if res == nil {
t.Fatal("Result is nil")
}
if path != "index.html" {
t.Fatalf("Returned path is invalid: %s", path)
} }
} }
func TestPickingCorrectRepositoryDefaultSubdirectory(t *testing.T) { func TestCanRequestCertificateNegative(t *testing.T) {
// Test that we return the default repository when the first path component does res := CanRequestCertificate("user-who-does-not-exist", giteaClient)
// not correspong to an existing repository. if res {
log.SetLevel(log.DebugLevel) t.Fatalf("User user-who-does-not-exist should not be servable")
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" {
t.Fatalf("Called with unknown user %s", username)
}
if repositoryName == "assets" {
return gitea.Repository{}, errors.New("gitea.Repository does not exist")
} else if repositoryName == "example-user.pages.example.org" {
return gitea.Repository{}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return gitea.Repository{}, nil
}
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
t.Fatal("getFile called")
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
t.Fatal("LookupCNAME called")
return "", nil
},
LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("LookupRepoTXT called")
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "assets/index.css", ctx)
if err != nil {
t.Fatalf("An error occured: %v", err)
}
if res == nil {
t.Fatal("Result is nil")
}
if path != "assets/index.css" {
t.Fatalf("Returned path is invalid: %s", path)
}
}
func TestPickingCorrectRepositorySubdirectoryNoPagesBranch(t *testing.T) {
// Test that we're picking the correct repository when the first path component
// returns a repository without a pages branch.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username != "example-user" {
t.Fatalf("Called with unknown user %s", username)
}
if repositoryName == "blog" {
return gitea.Repository{
Name: "blog",
}, nil
} else if repositoryName == "example-user.pages.example.org" {
return gitea.Repository{
Name: "example-user.pages.example.org",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return gitea.Repository{}, nil
}
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
t.Fatal("getFile called")
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
t.Fatal("LookupCNAME called")
return "", nil
},
LookupRepoTXT: func(domain string) (string, error) {
t.Fatal("LookupRepoTXT called")
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
res, path, err := RepoFromPath("example-user", "example-user.pages.example.org", "", "blog/post1.html", ctx)
if err != nil {
t.Fatalf("An error occured: %v", err)
}
if res == nil {
t.Fatal("Result is nil")
}
if res.Name != "example-user.pages.example.org" {
t.Fatalf("Invalid repository selected: %s", res.Name)
}
if path != "blog/post1.html" {
t.Fatalf("Returned path is invalid: %s", path)
}
}
func TestPickingNoRepositoryInvalidCNAME(t *testing.T) {
// Test that we're not picking a repository if the CNAME validation fails.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" {
return gitea.Repository{
Name: "example-user.pages.example.org",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return gitea.Repository{}, nil
}
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "example-user.pages.example.org" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"some-other-domain.local\"}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
_, _, err := RepoFromPath("example-user", "example-user.pages.example.org", "example-user.local", "index.html", ctx)
if err == nil {
t.Fatal("gitea.Repository returned even though CNAME validation should fail")
}
}
func TestPickingRepositoryValidCNAME(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "example-user.local" {
return gitea.Repository{
Name: "example-user.local",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return gitea.Repository{}, nil
}
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "example-user.local" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "example-user.local" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
repo, _, err := RepoFromPath("example-user", "example-user.local", "example-user.pages.example.org", "index.html", ctx)
if err != nil {
t.Fatalf("Error returned: %v", err)
}
if repo.Name != "example-user.local" {
t.Fatalf("Invalid repository name returned: %s", repo.Name)
}
}
func TestPickingRepositoryValidCNAMEWithTXTLookup(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds
// and the TXT lookup returns something different.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return gitea.Repository{
Name: "some-different-repository",
}, nil
} else {
t.Fatalf("Called with unknown repository %s", repositoryName)
return gitea.Repository{}, nil
}
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
repo, _, err := RepoFromPath("example-user", "example-user.local", "example-user.pages.example.org", "index.html", ctx)
if err != nil {
t.Fatalf("Error returned: %v", err)
}
if repo.Name != "some-different-repository" {
t.Fatalf("Invalid repository name returned: %s", repo.Name)
}
}
func TestPickingRepositoryValidCNAMEWithTXTLookupAndSubdirectory(t *testing.T) {
// Test that we're picking a repository, given a CNAME, if the CNAME validation succeeds
// and the TXT lookup returns something different. Additionally, we now have a subdirectory
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return gitea.Repository{
Name: "some-different-repository",
}, nil
}
return gitea.Repository{}, errors.New("Unknown repository")
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
repo, _, err := RepoFromPath("example-user", "example-user.local", "example-user.pages.example.org", "blog/index.html", ctx)
if err != nil {
t.Fatalf("Error returned: %v", err)
}
if repo.Name != "some-different-repository" {
t.Fatalf("Invalid repository name returned: %s", repo.Name)
}
}
func TestHeaderParsingEmpty(t *testing.T) {
// Test that we are correctly handling a repository with no headers.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return gitea.Repository{
Name: "some-different-repository",
}, nil
}
return gitea.Repository{}, errors.New("Unknown repository")
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\"}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
info := GetRepositoryInformation("example-user", "some-different-repository", ctx)
if info == nil {
t.Fatalf("No repository information returned")
}
if len(info.Headers) > 0 {
t.Fatalf("Headers returned: %v", info.Headers)
}
}
func TestHeaderParsing(t *testing.T) {
// Test that we are correctly handling a repository with no headers.
log.SetLevel(log.DebugLevel)
client := gitea.GiteaClient{
GetRepository: func(username, repositoryName string) (gitea.Repository, error) {
if username == "example-user" && repositoryName == "some-different-repository" {
return gitea.Repository{
Name: "some-different-repository",
}, nil
}
return gitea.Repository{}, errors.New("Unknown repository")
},
HasBranch: func(username, repositoryName, branchName string) bool {
if username == "example-user" && repositoryName == "some-different-repository" && branchName == "pages" {
return true
}
return false
},
GetFile: func(username, repositoryName, branch, path string, since *time.Time) ([]byte, bool, error) {
if username == "example-user" && repositoryName == "some-different-repository" && branch == "pages" && path == "rio.json" {
return []byte("{\"CNAME\": \"example-user.local\", \"headers\": {\"X-Cool-Header\": \"Very nice!\"}}"), true, nil
}
t.Fatalf("Invalid file requested: %s/%s@%s:%s", username, repositoryName, branch, path)
return []byte{}, true, nil
},
LookupCNAME: func(domain string) (string, error) {
return "", errors.New("No CNAME")
},
LookupRepoTXT: func(domain string) (string, error) {
if domain == "example-user.local" {
return "some-different-repository", nil
}
return "", nil
},
}
ctx := &context.GlobalContext{
Gitea: &client,
Cache: &context.CacheContext{
RepositoryInformationCache: context.MakeRepoInfoCache(),
RepositoryPathCache: context.MakeRepoPathCache(),
},
}
info := GetRepositoryInformation("example-user", "some-different-repository", ctx)
if info == nil {
t.Fatalf("No repository information returned")
}
if len(info.Headers) != 1 {
t.Fatalf("len(info.Headers) != 1: %v", info.Headers)
}
header, found := info.Headers["X-Cool-Header"]
if !found {
t.Fatal("Header X-Cool-Header not found")
}
if header != "Very nice!" {
t.Fatalf("Invalid header value for X-Cool-Header: \"%s\"", header)
} }
} }

View File

@@ -2,15 +2,14 @@ package server
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"strings" "strings"
"sync" "sync"
"git.polynom.me/rio/internal/certificates" "git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/dns" "git.polynom.me/rio/internal/dns"
"git.polynom.me/rio/internal/repo" "git.polynom.me/rio/internal/repo"
"code.gitea.io/sdk/gitea"
"github.com/go-acme/lego/v4/lego" "github.com/go-acme/lego/v4/lego"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
@@ -44,66 +43,49 @@ func unlockDomain(domain string) {
delete(workingDomains, domain) delete(workingDomains, domain)
} }
func buildDomainList(domain, pagesDomain string) []string { func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.CertificatesCache, acmeClient *lego.Client, giteaClient *gitea.Client) *tls.Config {
if domain == pagesDomain || strings.HasSuffix(domain, pagesDomain) {
return []string{
pagesDomain,
"*." + pagesDomain,
}
}
return []string{domain}
}
func getDomainKey(domain, pagesDomain string) string {
if domain == pagesDomain || strings.HasSuffix(domain, pagesDomain) {
return "*." + pagesDomain
}
return domain
}
func getUsername(sni, pagesDomain string) (string, error) {
if !strings.HasSuffix(sni, pagesDomain) {
log.Debugf("'%s' is not a subdomain of '%s'", sni, pagesDomain)
// Note: We do not check err here because err != nil
// always implies that cname == "", which does not have
// pagesDomain as a suffix.
query, err := dns.LookupCNAME(sni)
if !strings.HasSuffix(query, pagesDomain) {
log.Warnf("Got ServerName for Domain %s that we're not responsible for. CNAME '%s', err: %v", sni, query, err)
return "", errors.New("CNAME does not resolve to subdomain of pages domain")
}
return dns.ExtractUsername(pagesDomain, query), nil
}
return dns.ExtractUsername(pagesDomain, sni), nil
}
func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.CertificatesCache, acmeClient *lego.Client, ctx *context.GlobalContext) *tls.Config {
return &tls.Config{ return &tls.Config{
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) { GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
// Validate that we should even care about this domain // Validate that we should even care about this domain
isPagesDomain := info.ServerName == pagesDomain cname := ""
username, err := getUsername(info.ServerName, pagesDomain) if !strings.HasSuffix(info.ServerName, pagesDomain) {
if err != nil { // Note: We do not check err here because err != nil
log.Warnf("Failed to get username for %s: %v", info.ServerName, err) // always implies that cname == "", which does not have
return cache.FallbackCertificate.TlsCertificate, nil // pagesDomain as a suffix.
cname, _ = dns.LookupCNAME(info.ServerName)
if !strings.HasSuffix(cname, pagesDomain) {
log.Warnf("Got ServerName for Domain %s that we're not responsible for", info.ServerName)
return cache.FallbackCertificate.TlsCertificate, nil
}
}
// If we want to access <user>.<pages domain>, then we can just
// use a wildcard certificate.
domain := info.ServerName
/*if strings.HasSuffix(info.ServerName, pagesDomain) {
domain = "*." + pagesDomain
}*/
// Figure out a username for later username checks
username := ""
if cname == "" {
// domain ends on pagesDomain
username = strings.Split(domain, ".")[0]
} else {
// cname ends on pagesDomain
username = strings.Split(cname, ".")[0]
} }
// Find the correct certificate // Find the correct certificate
domainKey := getDomainKey(info.ServerName, pagesDomain) cert, found := cache.Certificates[info.ServerName]
cert, found := cache.Certificates[domainKey]
if found { if found {
if cert.IsValid() { if cert.IsValid() {
return cert.TlsCertificate, nil return cert.TlsCertificate, nil
} else { } else {
if !isPagesDomain && !repo.CanRequestCertificate(username, ctx) { if !repo.CanRequestCertificate(username, giteaClient) {
log.Warnf( log.Warnf(
"Cannot renew certificate for %s because CanRequestCertificate(%s) returned false", "Cannot renew certificate for %s because CanRequestCertificate(%s) returned false",
info.ServerName, domain,
username, username,
) )
return cert.TlsCertificate, nil return cert.TlsCertificate, nil
@@ -111,16 +93,16 @@ func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.Certificat
// If we're already working on the domain, // If we're already working on the domain,
// return the old certificate // return the old certificate
if lockIfUnlockedDomain(domainKey) { if lockIfUnlockedDomain(domain) {
return cert.TlsCertificate, nil return cert.TlsCertificate, nil
} }
defer unlockDomain(domainKey) defer unlockDomain(domain)
// Renew the certificate // Renew the certificate
log.Infof("Certificate for %s expired, renewing", info.ServerName) log.Infof("Certificate for %s expired, renewing", domain)
newCert, err := certificates.RenewCertificate(&cert, acmeClient) newCert, err := certificates.RenewCertificate(&cert, acmeClient)
if err != nil { if err != nil {
log.Errorf("Failed to renew certificate for %s: %v", info.ServerName, err) log.Errorf("Failed to renew certificate for %s: %v", domain, err)
return cert.TlsCertificate, nil return cert.TlsCertificate, nil
} }
@@ -129,33 +111,31 @@ func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.Certificat
return newCert.TlsCertificate, nil return newCert.TlsCertificate, nil
} }
} else { } else {
if !isPagesDomain && !repo.CanRequestCertificate(username, ctx) { if !repo.CanRequestCertificate(username, giteaClient) {
log.Warnf( log.Warnf(
"Cannot request certificate for %s because CanRequestCertificate(%s) returned false", "Cannot request certificate for %s because CanRequestCertificate(%s) returned false",
info.ServerName, domain,
username, username,
) )
return cache.FallbackCertificate.TlsCertificate, nil return cache.FallbackCertificate.TlsCertificate, nil
} }
// Don't request if we're already requesting. // Don't request if we're already requesting.
key := getDomainKey(info.ServerName, pagesDomain) if lockIfUnlockedDomain(domain) {
if lockIfUnlockedDomain(domainKey) {
return cache.FallbackCertificate.TlsCertificate, nil return cache.FallbackCertificate.TlsCertificate, nil
} }
defer unlockDomain(key) defer unlockDomain(domain)
// Request new certificate // Request new certificate
log.Infof("Obtaining new certificate for %s...", info.ServerName) log.Infof("Obtaining new certificate for %s...", domain)
cert, err := certificates.ObtainNewCertificate( cert, err := certificates.ObtainNewCertificate(
buildDomainList(info.ServerName, pagesDomain), []string{domain},
domainKey,
acmeClient, acmeClient,
) )
if err != nil { if err != nil {
log.Errorf( log.Errorf(
"Failed to get certificate for %s: %v", "Failed to get certificate for %s: %v",
info.ServerName, domain,
err, err,
) )
return cache.FallbackCertificate.TlsCertificate, nil return cache.FallbackCertificate.TlsCertificate, nil

View File

@@ -1,67 +0,0 @@
package server
import "testing"
const (
pagesDomain = "pages.local"
pagesDomainWildcard = "*.pages.local"
)
func equals(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
func TestDomainListBare(t *testing.T) {
expect := []string{pagesDomain, pagesDomainWildcard}
res := buildDomainList(pagesDomain, pagesDomain)
if !equals(res, expect) {
t.Fatalf("%v != %v", res, expect)
}
}
func TestDomainListSubdomain(t *testing.T) {
expect := []string{pagesDomain, pagesDomainWildcard}
res := buildDomainList("user."+pagesDomain, pagesDomain)
if !equals(res, expect) {
t.Fatalf("%v != %v", res, expect)
}
}
func TestDomainListCNAME(t *testing.T) {
expect := []string{"testdomain.example"}
res := buildDomainList("testdomain.example", pagesDomain)
if !equals(res, expect) {
t.Fatalf("%v != %v", res, expect)
}
}
func TestDomainKeyBare(t *testing.T) {
res := getDomainKey(pagesDomain, pagesDomain)
if res != pagesDomainWildcard {
t.Fatalf("%s != %s", res, pagesDomainWildcard)
}
}
func TestDomainKeySubdomain(t *testing.T) {
res := getDomainKey("user."+pagesDomain, pagesDomain)
if res != pagesDomainWildcard {
t.Fatalf("%s != %s", res, pagesDomainWildcard)
}
}
func TestDomainKeyCNAME(t *testing.T) {
res := getDomainKey("testdomain.example", pagesDomain)
if res != "testdomain.example" {
t.Fatalf("%s != %s", res, "testdomain.example")
}
}