Compare commits

..

2 Commits

34 changed files with 987 additions and 5652 deletions

5
.gitignore vendored
View File

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

View File

@@ -1,15 +0,0 @@
steps:
build:
image: "golang:1.21.5-alpine"
commands:
- go build ./cmd/rio
- go test $(go list ./... | grep -v /vendor/)
when:
- 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

@@ -1,102 +1,6 @@
# rio # rio
A webserver similat to Github Pages using the Gitea API. Supports running in HTTP-only A (somewhat) Github Pages compatible Webserver using the Gitea API.
mode and running in HTTPS mode with automatic certificates using ACME HTTP01 challenges.
## Setup
### DNS
Assuming your pages domain is `pages.example.org`, you should add a wildcard
DNS record (if your provider supports it) to allow clients to resolve any
`*.pages.example.org` record:
```
; Example for an authoritative Bind nameserver
*.pages 3600 IN A <IPv4>
*.pages 3600 IN AAAA <IPv6>
```
If you have setup a CAA record to lock ACME certificates to a certain
CA, make sure you allow certificates using the HTTP01 challenge type from the
ACME CA configured in rio.
### rio
To run rio, you can use environment variables or commandline flags. To see them,
run `rio --help` or look at the [main file](./cmd/rio/main.go).
If you run with `--acme-disable` (or `ACME_DISABLE=1`), then rio will not set up
an HTTPS server and run with HTTP only on the configured host and port. In this
configuration, only `--gitea-url` (`GITEA_URL`) and `--pages-domain` (`PAEGS_DOMAIN`)
are required.
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
will be created using the provider specified by `--acme-dns-provider` (`ACME_DNS_PROVIDER`). For a provider list and each provider's options, see
the [lego documentation](https://go-acme.github.io/lego/dns/). 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
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
[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
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.
## Usage
### Plain Domains
When a user tries to access `<user>.pages.example.org/some-repo/files.html`, rio will
first see if the repository `some-repo` exists for the user `<user>` on the configured
Gitea server. If it does, then rio will try to proxy the file `files.html` from
the repository `<user>/some-repo`. If that repository does not exist, then
rio will try the repository `<user>/<user>.pages.example.org` next and look for the
file `some-repo/files.html`.
Note that the files MUST reside in the repository's `pages` branch. Otherwise, rio
will not find the files.
### CNAME Domains
If you don't want to use `<user>.pages.example.org` and instead prefer to use your own
domain (`cooldomain.rio`), you can add a CNAME record on your domain, redirecting to
the pages domain:
```
; Example for Bind
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
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
If you have multiple repositories with pages (and you use a CNAME), you can additionally
add a TXT record to tell rio what repository to look for. For example, the following
TXT record will tell rio to use the repository `example-repository` whenever
`example-repository.rio` is accessed:
```
; Example for Bind
_rio-pages.example-repository.rio. 3600 IN TXT "repo=example-repository"
```
For this to work, it is important that the record's value starts with `repo=`.
## License ## License

View File

@@ -1,4 +1,4 @@
package acme package main
import ( import (
"crypto" "crypto"

342
acme.go Normal file
View File

@@ -0,0 +1,342 @@
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/json"
"encoding/pem"
"io/ioutil"
"math/big"
"net/http"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
log "github.com/sirupsen/logrus"
)
const (
AcmeChallengePathPrefix = "/.well-known/acme-challenge/"
)
var (
// Well-known -> Challenge solution
runningChallenges = make(map[string]string)
Certificates = CertificatesCache{
Certificates: make(map[string]CertificateWrapper),
}
// To access requestingDomains, first acquire the lock.
requestingLock = sync.Mutex{}
// Domain -> _. Check if domain is a key here to see if we're already requeting
// a certificate for it.
requestingDomains = make(map[string]bool)
)
func lockIfUnlockedDomain(domain string) bool {
requestingLock.Lock()
defer requestingLock.Unlock()
_, found := requestingDomains[domain]
if !found {
requestingDomains[domain] = true
}
return found
}
func unlockDomain(domain string) {
requestingLock.Lock()
defer requestingLock.Unlock()
delete(requestingDomains, domain)
}
type CertificateWrapper struct {
TlsCertificate *tls.Certificate `json:"-"`
Domain string `json:"domain"`
NotAfter time.Time `json:"not_after"`
PrivateKeyEncoded string `json:"private_key"`
Certificate []byte `json:"certificate"`
IssuerCertificate []byte `json:"issuer_certificate"`
CertificateUrl string `json:"certificate_url"`
}
func (c *CertificateWrapper) GetPrivateKey() *rsa.PrivateKey {
data, _ := base64.StdEncoding.DecodeString(c.PrivateKeyEncoded)
pk, _ := certcrypto.ParsePEMPrivateKey(data)
return pk.(*rsa.PrivateKey)
}
type CertificatesCache struct {
FallbackCertificate *CertificateWrapper
Certificates map[string]CertificateWrapper
}
type CertificatesStore struct {
FallbackCertificate CertificateWrapper `json:"fallback"`
Certificates []CertificateWrapper `json:"certificates"`
}
func (c *CertificatesCache) toStoreData() string {
certs := make([]CertificateWrapper, 0)
for _, cert := range c.Certificates {
certs = append(certs, cert)
}
result, err := json.Marshal(CertificatesStore{
FallbackCertificate: *c.FallbackCertificate,
Certificates: certs,
})
if err != nil {
log.Errorf("Failed to Marshal cache: %v", err)
}
return string(result)
}
func (c *CertificateWrapper) initTlsCertificate() {
pk, _ := base64.StdEncoding.DecodeString(c.PrivateKeyEncoded)
tlsCert, _ := tls.X509KeyPair(
c.Certificate,
pk,
)
c.TlsCertificate = &tlsCert
}
func CertificateFromStoreData(rawJson string) CertificatesCache {
var store CertificatesStore
_ = json.Unmarshal([]byte(rawJson), &store)
store.FallbackCertificate.initTlsCertificate()
cache := CertificatesCache{
FallbackCertificate: &store.FallbackCertificate,
}
certs := make(map[string]CertificateWrapper)
for _, cert := range store.Certificates {
cert.initTlsCertificate()
certs[cert.Domain] = cert
}
cache.Certificates = certs
return cache
}
func LoadCertificateStoreFromFile(path string) error {
content, err := ioutil.ReadFile(path)
if err != nil {
return err
}
Certificates = CertificateFromStoreData(string(content))
return nil
}
func FlushCertificateStoreToFile(path string) {
data := Certificates.toStoreData()
ioutil.WriteFile(path, []byte(data), 0600)
}
func InitialiseFallbackCert(pagesDomain string) error {
cert, err := fallbackCert(pagesDomain)
Certificates.FallbackCertificate = cert
return err
}
func fallbackCert(pagesDomain string) (*CertificateWrapper, error) {
key, err := certcrypto.GeneratePrivateKey(certcrypto.RSA2048)
if err != nil {
return nil, err
}
notAfter := time.Now().Add(time.Hour * 24 * 7)
cert := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: pagesDomain,
Organization: []string{"Pages Server"},
},
NotAfter: notAfter,
NotBefore: time.Now(),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(
rand.Reader,
&cert,
&cert,
&key.(*rsa.PrivateKey).PublicKey,
key,
)
if err != nil {
return nil, err
}
out := &bytes.Buffer{}
err = pem.Encode(out, &pem.Block{
Bytes: certBytes,
Type: "CERTIFICATE",
})
if err != nil {
return nil, err
}
outBytes := out.Bytes()
res := &certificate.Resource{
PrivateKey: certcrypto.PEMEncode(key),
Certificate: outBytes,
IssuerCertificate: outBytes,
Domain: pagesDomain,
}
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
if err != nil {
return nil, err
}
return &CertificateWrapper{
TlsCertificate: &tlsCertificate,
Domain: pagesDomain,
NotAfter: notAfter,
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(certcrypto.PEMEncode(key)),
Certificate: outBytes,
IssuerCertificate: outBytes,
CertificateUrl: "localhost",
}, nil
}
func isCertStillValid(cert CertificateWrapper) bool {
return time.Now().Compare(cert.NotAfter) <= -1
}
func makeTlsConfig(pagesDomain, path string, acmeClient *lego.Client) *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
// Validate that we should even care about this domain
if !strings.HasSuffix(info.ServerName, pagesDomain) {
// Note: We do not check err here because err != nil
// always implies that cname == "", which does not have
// pagesDomain as a suffix.
cname, _ := lookupCNAME(info.ServerName)
if !strings.HasSuffix(cname, pagesDomain) {
log.Warnf("Got ServerName for Domain %s that we're not responsible for", info.ServerName)
return Certificates.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
}*/
cert, found := Certificates.Certificates[info.ServerName]
if found {
if isCertStillValid(cert) {
return cert.TlsCertificate, nil
} else {
// If we're already working on the domain,
// return the old certificate
if lockIfUnlockedDomain(domain) {
return cert.TlsCertificate, nil
}
defer unlockDomain(domain)
// TODO: Renew
log.Debugf("Certificate for %s expired, renewing", domain)
}
} else {
// Don't request if we're already requesting.
if lockIfUnlockedDomain(domain) {
return Certificates.FallbackCertificate.TlsCertificate, nil
}
defer unlockDomain(domain)
// Request new certificate
log.Infof("Obtaining new certificate for %s...", domain)
err := ObtainNewCertificate(
[]string{domain},
path,
acmeClient,
)
if err != nil {
log.Errorf(
"Failed to get certificate for %s: %v",
domain,
err,
)
return Certificates.FallbackCertificate.TlsCertificate, nil
}
cert, _ = Certificates.Certificates[domain]
return cert.TlsCertificate, nil
}
return Certificates.FallbackCertificate.TlsCertificate, nil
},
NextProtos: []string{
"http/0.9",
"http/1.0",
"http/1.1",
"h2",
"h2c",
},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
}
}
func addChallenge(id, token string) {
runningChallenges[id] = token
}
func removeChallenge(id string) {
delete(runningChallenges, id)
}
func getChallenge(id string) string {
if value, found := runningChallenges[id]; found {
return value
}
return ""
}
func handleLetsEncryptChallenge(w http.ResponseWriter, req *http.Request) bool {
if !strings.HasPrefix(req.URL.Path, AcmeChallengePathPrefix) {
return false
}
log.Debug("Handling ACME challenge path")
id := strings.TrimPrefix(req.URL.Path, AcmeChallengePathPrefix)
challenge := getChallenge(id)
if id == "" {
w.WriteHeader(404)
return true
}
w.WriteHeader(200)
w.Write([]byte(challenge))
removeChallenge(id)
return true
}

41
certificate.go Normal file
View File

@@ -0,0 +1,41 @@
package main
import (
"crypto/tls"
"encoding/base64"
"time"
// "github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
)
func ObtainNewCertificate(domains []string, path string, acmeClient *lego.Client) error {
req := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
cert, err := acmeClient.Certificate.Obtain(req)
if err != nil {
return err
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return err
}
wrapper := CertificateWrapper{
TlsCertificate: &tlsCert,
Domain: cert.Domain,
//NotAfter: tlsCert.Leaf.NotAfter,
NotAfter: time.Now().Add(time.Hour * 24 * 60),
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(cert.PrivateKey),
Certificate: cert.Certificate,
IssuerCertificate: cert.IssuerCertificate,
CertificateUrl: cert.CertURL,
}
Certificates.Certificates[cert.Domain] = wrapper
FlushCertificateStoreToFile(path)
return nil
}

View File

@@ -1,442 +0,0 @@
package main
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"git.polynom.me/rio/internal/acme"
"git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/context"
"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/repo"
"git.polynom.me/rio/internal/server"
"code.gitea.io/sdk/gitea"
legodns "github.com/go-acme/lego/v4/providers/dns"
log "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
func handleSubdomain(ctx *context.GlobalContext, domain, cname, path string, req *http.Request, w http.ResponseWriter) {
username := ""
if cname != "" {
// 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 /
if 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(
username,
domain,
cname,
path,
ctx,
)
if err != nil {
log.Errorf("Failed to get repo: %s", err)
w.WriteHeader(404)
return
}
c := &context.Context{
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 {
return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Server", "rio")
// Is the direct domain requested?
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")
handleSubdomain(ctx, req.Host, "", req.URL.Path, req, w)
return
}
cname, err := dns.LookupCNAME(req.Host)
if err != nil {
log.Warningf("CNAME request failed, we don't handle %s", req.Host)
w.WriteHeader(400)
return
}
log.Debugf("Got CNAME %s", cname)
// Is a direct subdomain requested after CNAME lookup?
// 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)
handleSubdomain(ctx, req.Host, cname, req.URL.Path, req, w)
return
}
log.Errorf("Not handling %s", req.Host)
w.WriteHeader(404)
}
}
// 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 {
giteaUrl := ctx.String("gitea-url")
domain := ctx.String("pages-domain")
certsFile := ctx.String("certs-file")
acmeEmail := ctx.String("acme-email")
acmeServer := ctx.String("acme-server")
acmeFile := ctx.String("acme-file")
acmeDnsProvider := ctx.String("acme-dns-provider")
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
if ctx.Bool("debug") {
log.SetLevel(log.DebugLevel)
} else {
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
httpClient := http.Client{Timeout: 10 * time.Second}
giteaApiClient, err := gitea.NewClient(
giteaUrl,
gitea.SetHTTPClient(&httpClient),
gitea.SetToken(token),
gitea.SetUserAgent("rio"),
)
if err != nil {
return err
}
giteaClient := riogitea.NewGiteaClient(giteaUrl, token, giteaApiClient)
// Listen on the port
addr := ctx.String("listen-host") + ":" + ctx.String("listen-port")
listener, err := net.Listen("tcp", addr)
if err != nil {
errMsg := fmt.Errorf("Failed to create listener: %v", err)
fmt.Println(errMsg.Error())
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 acmeEmail == "" || acmeFile == "" || certsFile == "" || acmeDnsProvider == "" {
return errors.New("The options acme-dns-provider, acme-file, acme-email, and certs-file are required")
}
cache, err := certificates.CertificateCacheFromFile(certsFile)
if err != nil {
log.Debugf("Generating cert")
fallback, err := certificates.MakeFallbackCertificate(domain)
if err != nil {
log.Fatalf("Failed to generate fallback certificate: %v", err)
return err
}
cache.FallbackCertificate = fallback
cache.Certificates = make(map[string]certificates.CertificateWrapper)
cache.FlushToDisk(certsFile)
log.Debug("Certificate wrote to disk")
} else {
log.Debug("Certificate store read from disk")
}
// Create an ACME client, if we failed to load one
acmeClient, err := acme.ClientFromFile(acmeFile, acmeServer)
if err != nil {
log.Warn("Failed to load ACME client data from disk. Generating new account")
acmeClient, err = acme.GenerateNewAccount(acmeEmail, acmeFile, acmeServer)
if err != nil {
log.Fatalf("Failed to generate new ACME client: %v", err)
return err
}
log.Info("ACME account registered")
} else {
log.Info("ACME client data read from disk")
}
// Set up the DNS01 challenge solver
provider, err := legodns.NewDNSChallengeProviderByName(acmeDnsProvider)
if err != nil {
log.Fatalf("Failed to create DNS01 challenge provider: %v", err)
return err
}
err = acmeClient.Challenge.SetDNS01Provider(provider)
if err != nil {
log.Fatalf("Failed to setup DNS01 challenge solver: %v", err)
return err
}
tlsConfig := server.MakeTlsConfig(
domain,
certsFile,
&cache,
acmeClient,
globalCtx,
)
listener = tls.NewListener(listener, tlsConfig)
}
var waitGroup sync.WaitGroup
servers := 2
if acmeDisable {
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
}
func main() {
app := &cli.App{
Action: runServer,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "gitea-url",
Usage: "The (HTTPS) URL to the serving Gitea instance",
EnvVars: []string{"GITEA_URL"},
Required: true,
},
&cli.StringFlag{
Name: "listen-host",
Usage: "The host to listen on",
EnvVars: []string{"HOST"},
Value: "127.0.0.1",
},
&cli.StringFlag{
Name: "listen-port",
Usage: "The port to listen on",
EnvVars: []string{"PORT"},
Value: "8888",
},
&cli.StringFlag{
Name: "http-host",
Usage: "The host to have unencrypted HTTP listen on",
EnvVars: []string{"HTTP_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: "",
},
&cli.StringFlag{
Name: "pages-domain",
Usage: "The domain on which the server is reachable",
EnvVars: []string{"PAGES_DOMAIN"},
Required: true,
},
&cli.StringFlag{
Name: "certs-file",
Usage: "File to store certificates in",
EnvVars: []string{"CERTS_FILE"},
Value: "",
},
&cli.StringFlag{
Name: "acme-file",
Usage: "File to store ACME configuration in",
EnvVars: []string{"ACME_FILE"},
Value: "",
},
&cli.StringFlag{
Name: "acme-email",
Usage: "Email to use for an ACME account",
EnvVars: []string{"ACME_EMAIL"},
Value: "",
},
&cli.StringFlag{
Name: "acme-server",
Usage: "CA Directory to use",
EnvVars: []string{"ACME_SERVER"},
Value: "https://acme-staging-v02.api.letsencrypt.org/directory",
},
&cli.BoolFlag{
Name: "acme-disable",
Usage: "Whether to disable automatic ACME certificates",
EnvVars: []string{"ACME_DISABLE"},
},
&cli.BoolFlag{
Name: "debug",
Usage: "Whether to enable debug logging",
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"},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatalf("Failed to run app: %s", err)
}
}

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
}

64
dns.go Normal file
View File

@@ -0,0 +1,64 @@
package main
import (
"errors"
"net"
"strings"
"time"
"github.com/patrickmn/go-cache"
)
const (
TxtRepoKey = "repo="
)
var (
cnameCache = cache.New(1*time.Hour, 1*time.Hour)
txtRepoCache = cache.New(1*time.Hour, 1*time.Hour)
)
func lookupRepoTXT(domain string) (string, error) {
repoLookup, found := txtRepoCache.Get(domain)
if found {
return repoLookup.(string), nil
}
txts, err := net.LookupTXT("_rio-pages." + domain)
if err != nil {
return "", err
}
repo := ""
for _, txt := range txts {
if !strings.HasPrefix(txt, TxtRepoKey) {
continue
}
repo = strings.TrimPrefix(txt, TxtRepoKey)
break
}
txtRepoCache.Set(domain, repo, cache.DefaultExpiration)
return repo, nil
}
func lookupCNAME(domain string) (string, error) {
cname, found := cnameCache.Get(domain)
if found {
if cname == "" {
return "", errors.New("Previous request failure")
}
return cname.(string), nil
}
cname, err := net.LookupCNAME(domain)
if err == nil {
cnameCache.Set(domain, cname, cache.DefaultExpiration)
return cname.(string), nil
}
cnameCache.Set(domain, "", cache.DefaultExpiration)
return "", err
}

251
go.mod
View File

@@ -1,246 +1,29 @@
module git.polynom.me/rio module paptutuwawa/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.0
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.5.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.10.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.9.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect golang.org/x/text v0.10.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
) )

2826
go.sum

File diff suppressed because it is too large Load Diff

View File

@@ -1,136 +0,0 @@
package certificates
import (
"crypto/rsa"
"crypto/tls"
"encoding/base64"
"encoding/pem"
"time"
"bytes"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"math/big"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
)
func RenewCertificate(old *CertificateWrapper, acmeClient *lego.Client) (CertificateWrapper, error) {
pk, _ := base64.StdEncoding.DecodeString(old.PrivateKeyEncoded)
res := certificate.Resource{
PrivateKey: pk,
Certificate: old.Certificate,
CSR: old.CSR,
}
new, err := acmeClient.Certificate.Renew(res, true, false, "")
if err != nil {
return CertificateWrapper{}, err
}
// Convert the new certificate into a wrapper struct
tlsCert, err := tls.X509KeyPair(new.Certificate, new.PrivateKey)
if err != nil {
return CertificateWrapper{}, err
}
wrapper := CertificateWrapper{
TlsCertificate: &tlsCert,
DomainKey: old.DomainKey,
NotAfter: time.Now().Add(time.Hour * 24 * 60),
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(new.PrivateKey),
Certificate: new.Certificate,
CSR: new.CSR,
}
return wrapper, nil
}
func ObtainNewCertificate(domains []string, domainKey string, acmeClient *lego.Client) (CertificateWrapper, error) {
req := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
cert, err := acmeClient.Certificate.Obtain(req)
if err != nil {
return CertificateWrapper{}, err
}
tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.PrivateKey)
if err != nil {
return CertificateWrapper{}, err
}
wrapper := CertificateWrapper{
TlsCertificate: &tlsCert,
DomainKey: domainKey,
//NotAfter: tlsCert.Leaf.NotAfter,
NotAfter: time.Now().Add(time.Hour * 24 * 60),
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(cert.PrivateKey),
Certificate: cert.Certificate,
CSR: cert.CSR,
}
return wrapper, nil
}
// Generate a fallback certificate for the domain.
func MakeFallbackCertificate(pagesDomain string) (*CertificateWrapper, error) {
key, err := certcrypto.GeneratePrivateKey(certcrypto.RSA2048)
if err != nil {
return nil, err
}
notAfter := time.Now().Add(time.Hour * 24 * 7)
cert := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{
CommonName: pagesDomain,
Organization: []string{"Pages Server"},
},
NotAfter: notAfter,
NotBefore: time.Now(),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
certBytes, err := x509.CreateCertificate(
rand.Reader,
&cert,
&cert,
&key.(*rsa.PrivateKey).PublicKey,
key,
)
if err != nil {
return nil, err
}
out := &bytes.Buffer{}
err = pem.Encode(out, &pem.Block{
Bytes: certBytes,
Type: "CERTIFICATE",
})
if err != nil {
return nil, err
}
outBytes := out.Bytes()
res := &certificate.Resource{
PrivateKey: certcrypto.PEMEncode(key),
Certificate: outBytes,
IssuerCertificate: outBytes,
Domain: pagesDomain,
}
tlsCertificate, err := tls.X509KeyPair(res.Certificate, res.PrivateKey)
if err != nil {
return nil, err
}
return &CertificateWrapper{
TlsCertificate: &tlsCertificate,
DomainKey: "*." + pagesDomain,
NotAfter: notAfter,
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(certcrypto.PEMEncode(key)),
Certificate: outBytes,
CSR: []byte{},
}, nil
}

View File

@@ -1,124 +0,0 @@
package certificates
import (
"crypto/rsa"
"crypto/tls"
"encoding/base64"
"encoding/json"
"io/ioutil"
"time"
"github.com/go-acme/lego/v4/certcrypto"
log "github.com/sirupsen/logrus"
)
// A convenience wrapper around a TLS certificate
type CertificateWrapper struct {
// The parsed TLS certificate we can pass to the tls listener
TlsCertificate *tls.Certificate `json:"-"`
// Key identifying for which domain(s) this certificate is valid.
DomainKey string `json:"domain"`
// 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.
type CertificatesCache struct {
// The certificate to use as a fallback if all else fails.
FallbackCertificate *CertificateWrapper
// Mapping of a domain's domain key to the certificate.
Certificates map[string]CertificateWrapper
}
// Internal type to let encoding JSON handle the bulk of the work.
type certificatesStore struct {
FallbackCertificate CertificateWrapper `json:"fallback"`
Certificates []CertificateWrapper `json:"certificates"`
}
// Decodes the private key of the certificate wrapper.
func (c *CertificateWrapper) GetPrivateKey() *rsa.PrivateKey {
data, _ := base64.StdEncoding.DecodeString(c.PrivateKeyEncoded)
pk, _ := certcrypto.ParsePEMPrivateKey(data)
return pk.(*rsa.PrivateKey)
}
// Populate the certificate's TlsCertificate field.
func (c *CertificateWrapper) initTlsCertificate() {
pk, _ := base64.StdEncoding.DecodeString(c.PrivateKeyEncoded)
tlsCert, _ := tls.X509KeyPair(
c.Certificate,
pk,
)
c.TlsCertificate = &tlsCert
}
// Checks if the certificate is still valid now.
func (c *CertificateWrapper) IsValid() bool {
return time.Now().Compare(c.NotAfter) <= -1
}
// Serializes the certificate cache to a JSON string for writing to a file.
func (c *CertificatesCache) toStoreData() []byte {
certs := make([]CertificateWrapper, 0)
for _, cert := range c.Certificates {
certs = append(certs, cert)
}
result, err := json.Marshal(certificatesStore{
FallbackCertificate: *c.FallbackCertificate,
Certificates: certs,
})
if err != nil {
log.Errorf("Failed to Marshal cache: %v", err)
}
return result
}
// Saves the cache to disk.
func (c *CertificatesCache) FlushToDisk(path string) {
ioutil.WriteFile(path, c.toStoreData(), 0600)
}
func (c *CertificatesCache) AddCert(cert CertificateWrapper, path string) {
c.Certificates[cert.DomainKey] = cert
c.FlushToDisk(path)
}
// Load the certificate cache from the file.
func CertificateCacheFromFile(path string) (CertificatesCache, error) {
content, err := ioutil.ReadFile(path)
if err != nil {
return CertificatesCache{}, err
}
var store certificatesStore
_ = json.Unmarshal(content, &store)
store.FallbackCertificate.initTlsCertificate()
cache := CertificatesCache{
FallbackCertificate: &store.FallbackCertificate,
}
certs := make(map[string]CertificateWrapper)
for _, cert := range store.Certificates {
cert.initTlsCertificate()
certs[cert.DomainKey] = cert
}
cache.Certificates = certs
return cache, nil
}

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,118 +0,0 @@
package dns
import (
"net"
"errors"
"strings"
"time"
"github.com/patrickmn/go-cache"
)
const (
// TXT record name that lookupRepoTXT will try to lookup.
TxtRepoRecord = "_rio-pages."
// The key that the TXT record will have to start with, e.g.
// "repo=some-random-repo".
TxtRepoKey = "repo="
TxtCNAMERecord = "_rio-cname."
TxtCNAMEKey = "cname="
)
var (
// Cache for CNAME resolution results.
cnameCache = cache.New(1*time.Hour, 1*time.Hour)
// Cache for TXT resolution results.
txtRepoCache = cache.New(1*time.Hour, 1*time.Hour)
)
// Query the domain for the a repository redirect.
// Returns the new repository name or "", if we could not
// resolve a repository redirect.
func LookupRepoTXT(domain string) (string, error) {
repoLookup, found := txtRepoCache.Get(domain)
if found {
return repoLookup.(string), nil
}
txts, err := net.LookupTXT("_rio-pages." + domain)
if err != nil {
return "", err
}
repo := ""
for _, txt := range txts {
if !strings.HasPrefix(txt, TxtRepoKey) {
continue
}
repo = strings.TrimPrefix(txt, TxtRepoKey)
break
}
txtRepoCache.Set(domain, repo, cache.DefaultExpiration)
return repo, nil
}
// Query the domain for a CNAME record. Returns the resolved
// CNAME or "", if no CNAME could be queried.
func LookupCNAME(domain string) (string, error) {
cname, found := cnameCache.Get(domain)
if found {
return cname.(string), nil
}
cname, err := lookupCNAME(domain)
if err == nil {
cnameCache.Set(domain, cname, cache.DefaultExpiration)
return cname.(string), nil
}
altCname, err := lookupCNAMETxt(domain)
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
}

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,130 +0,0 @@
package pages
import (
"mime"
"net/http"
"strconv"
"strings"
"time"
"git.polynom.me/rio/internal/constants"
"git.polynom.me/rio/internal/context"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
var (
pageCache = cache.New(6*time.Hour, 1*time.Hour)
)
type PageContentCache struct {
Content []byte
mimeType string
RequestedAt time.Time
}
func makePageContentCacheEntry(username, path string) string {
return username + ":" + path
}
func addHeaders(repoInfo *context.RepositoryInformation, contentType string, contentLength int, w http.ResponseWriter) {
// Always set a content type
if strings.Trim(contentType, " ") == "" {
w.Header().Set("Content-Type", "application/octet-stream")
} 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
path := context.Path
if path[:1] == "/" {
path = path[1:]
}
key := makePageContentCacheEntry(context.Username, path)
entry, found := pageCache.Get(key)
var content []byte
var mimeType string
var err error
var since *time.Time = nil
if found {
log.Debugf("Returning %s from cache", path)
content = entry.(PageContentCache).Content
mimeType = entry.(PageContentCache).mimeType
sinceRaw := entry.(PageContentCache).RequestedAt
since = &sinceRaw
}
content, changed, err := context.Global.Gitea.GetFile(
context.Username,
context.Reponame,
constants.PagesBranch,
path,
since,
)
repoInfo := context.Global.Cache.GetRepositoryInformation(
context.Username,
context.Reponame,
)
if err != nil {
if !found {
log.Errorf("Failed to get file %s/%s/%s (%s)", context.Username, context.Reponame, path, err)
addHeaders(repoInfo, "text/html", 0, context.Writer)
context.Writer.WriteHeader(404)
} else {
log.Debugf("Request failed but page %s is cached in memory", path)
addHeaders(repoInfo, mimeType, len(content), context.Writer)
context.Writer.WriteHeader(200)
context.Writer.Write(content)
}
return
}
if found && !changed {
log.Debugf("Page %s is unchanged and cached in memory", path)
addHeaders(repoInfo, mimeType, len(content), context.Writer)
context.Writer.WriteHeader(200)
context.Writer.Write(content)
return
}
pathParts := strings.Split(path, ".")
ext := pathParts[len(pathParts)-1]
mimeType = mime.TypeByExtension("." + ext)
now := time.Now()
pageCache.Set(
key,
PageContentCache{
content,
mimeType,
now,
},
cache.DefaultExpiration,
)
log.Debugf("Page %s requested from Gitea and cached in memory at %v", path, now)
addHeaders(repoInfo, mimeType, len(content), context.Writer)
context.Writer.WriteHeader(200)
context.Writer.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,223 +0,0 @@
package repo
//go:generate mockgen -destination mock_repo_test.go -package repo code.gitea.io/sdk/gitea Client
import (
"encoding/json"
"errors"
"slices"
"strings"
"git.polynom.me/rio/internal/constants"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/gitea"
log "github.com/sirupsen/logrus"
)
var (
ForbiddenHeaders = []string{
"content-length",
"content-type",
"date",
"location",
"strict-transport-security",
"set-cookie",
}
)
func lookupRepositoryAndCache(username, reponame, branchName, host, domain, path, cname string, ctx *context.GlobalContext) (*gitea.Repository, error) {
log.Debugf("CNAME: %s", cname)
log.Debugf("Looking up repository %s/%s", username, reponame)
repo, err := ctx.Gitea.GetRepository(username, reponame)
if err != nil {
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
if cname != "" {
log.Debug("Checking CNAME")
repoInfo := GetRepositoryInformation(username, reponame, ctx)
if repoInfo == nil {
log.Warn("Repository does not contain a rio.json file")
return nil, errors.New("No CNAME available in repository")
}
log.Debugf("CNAME Content: \"%s\"", repoInfo.CNAME)
if repoInfo.CNAME != host {
log.Warnf("CNAME mismatch: Repo '%s', Host '%s'", repoInfo.CNAME, host)
return nil, errors.New("CNAME mismatch")
}
}
// Cache data
ctx.Cache.SetRepositoryPath(
domain,
path,
context.RepositoryPathInformation{
Repository: repo,
Path: path,
},
)
return &repo, nil
}
// host is the domain name we're accessed from. cname is the domain that host is pointing
// 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
// Guess the repository
entry := ctx.Cache.GetRepositoryPath(domain, path)
if entry != nil {
return &entry.Repository, entry.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, "/")
log.Debugf("reponame='%s' len(pathParts)='%d'", reponame, len(pathParts))
if reponame == "" && len(pathParts) > 1 {
log.Debugf("Trying repository %s", pathParts[0])
modifiedPath := strings.Join(pathParts[1:], "/")
repo, err := lookupRepositoryAndCache(
username,
pathParts[0],
constants.PagesBranch,
host,
domain,
modifiedPath,
cname,
ctx,
)
if err == nil {
return repo, modifiedPath, nil
}
}
if reponame == "" {
reponame = domain
}
log.Debugf("Trying repository %s/%s", username, reponame)
repo, err := lookupRepositoryAndCache(
username,
reponame,
constants.PagesBranch,
host,
domain,
path,
cname,
ctx,
)
return repo, path, err
}
// 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
// usernames.
func CanRequestCertificate(username string, ctx *context.GlobalContext) bool {
found := ctx.Cache.GetUser(username)
if found {
return true
}
hasUser := ctx.Gitea.HasUser(username)
if hasUser {
ctx.Cache.SetUser(username)
}
return hasUser
}
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 +0,0 @@
package repo
import (
"errors"
"strings"
"testing"
"time"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/gitea"
log "github.com/sirupsen/logrus"
)
func TestHeaderFilter(t *testing.T) {
map1 := filterHeaders(
map[string]interface{}{
"Content-Type": "hallo",
"content-Type": "welt",
"content-type": "uwu",
"CONTENT-TYPE": "lol",
"Content-Security-Policy": "none",
},
)
if len(map1) != 1 {
t.Fatalf("filterHeaders allowed %d != 1 headers", len(map1))
}
for key := range map1 {
if strings.ToLower(key) == "content-type" {
t.Fatalf("filterHeaders allowed Content-Type")
}
}
}
func TestPickingCorrectRepositoryDefault(t *testing.T) {
// Test that we default to the <username>.<pages domain> repository, if we have only
// one path component.
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 != "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) {
// Test that we return the default repository when the first path component does
// not correspong to an existing repository.
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 == "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

@@ -1,187 +0,0 @@
package server
import (
"crypto/tls"
"errors"
"strings"
"sync"
"git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/context"
"git.polynom.me/rio/internal/dns"
"git.polynom.me/rio/internal/repo"
"github.com/go-acme/lego/v4/lego"
log "github.com/sirupsen/logrus"
)
var (
// To access requestingDomains, first acquire the lock.
domainsLock = sync.Mutex{}
// Domain -> _. Check if domain is a key here to see if we're already requesting
// or renewing a certificate for that domain.
workingDomains = make(map[string]bool)
)
func lockIfUnlockedDomain(domain string) bool {
domainsLock.Lock()
defer domainsLock.Unlock()
_, found := workingDomains[domain]
if !found {
workingDomains[domain] = true
}
return found
}
func unlockDomain(domain string) {
domainsLock.Lock()
defer domainsLock.Unlock()
delete(workingDomains, domain)
}
func buildDomainList(domain, pagesDomain string) []string {
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{
GetCertificate: func(info *tls.ClientHelloInfo) (*tls.Certificate, error) {
// Validate that we should even care about this domain
isPagesDomain := info.ServerName == pagesDomain
username, err := getUsername(info.ServerName, pagesDomain)
if err != nil {
log.Warnf("Failed to get username for %s: %v", info.ServerName, err)
return cache.FallbackCertificate.TlsCertificate, nil
}
// Find the correct certificate
domainKey := getDomainKey(info.ServerName, pagesDomain)
cert, found := cache.Certificates[domainKey]
if found {
if cert.IsValid() {
return cert.TlsCertificate, nil
} else {
if !isPagesDomain && !repo.CanRequestCertificate(username, ctx) {
log.Warnf(
"Cannot renew certificate for %s because CanRequestCertificate(%s) returned false",
info.ServerName,
username,
)
return cert.TlsCertificate, nil
}
// If we're already working on the domain,
// return the old certificate
if lockIfUnlockedDomain(domainKey) {
return cert.TlsCertificate, nil
}
defer unlockDomain(domainKey)
// Renew the certificate
log.Infof("Certificate for %s expired, renewing", info.ServerName)
newCert, err := certificates.RenewCertificate(&cert, acmeClient)
if err != nil {
log.Errorf("Failed to renew certificate for %s: %v", info.ServerName, err)
return cert.TlsCertificate, nil
}
log.Info("Successfully renewed certificate!")
cache.AddCert(newCert, cachePath)
return newCert.TlsCertificate, nil
}
} else {
if !isPagesDomain && !repo.CanRequestCertificate(username, ctx) {
log.Warnf(
"Cannot request certificate for %s because CanRequestCertificate(%s) returned false",
info.ServerName,
username,
)
return cache.FallbackCertificate.TlsCertificate, nil
}
// Don't request if we're already requesting.
key := getDomainKey(info.ServerName, pagesDomain)
if lockIfUnlockedDomain(domainKey) {
return cache.FallbackCertificate.TlsCertificate, nil
}
defer unlockDomain(key)
// Request new certificate
log.Infof("Obtaining new certificate for %s...", info.ServerName)
cert, err := certificates.ObtainNewCertificate(
buildDomainList(info.ServerName, pagesDomain),
domainKey,
acmeClient,
)
if err != nil {
log.Errorf(
"Failed to get certificate for %s: %v",
info.ServerName,
err,
)
return cache.FallbackCertificate.TlsCertificate, nil
}
// Add to cache and flush
log.Info("Successfully obtained new certificate!")
cache.AddCert(cert, cachePath)
return cert.TlsCertificate, nil
}
},
NextProtos: []string{
"http/0.9",
"http/1.0",
"http/1.1",
"h2",
"h2c",
},
MinVersion: tls.VersionTLS12,
CipherSuites: []uint16{
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
},
}
}

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")
}
}

249
main.go Normal file
View File

@@ -0,0 +1,249 @@
package main
import (
"crypto/tls"
"errors"
"fmt"
"net"
"net/http"
"os"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"github.com/go-acme/lego/v4/challenge/http01"
log "github.com/sirupsen/logrus"
"github.com/urfave/cli/v2"
)
const (
PagesBranch = "pages"
)
func handleSubdomain(domain string, cname string, path, giteaUrl string, giteaClient *gitea.Client, w http.ResponseWriter) {
hostParts := strings.Split(domain, ".")
username := hostParts[0]
// Strip the leading /
if path[:1] == "/" {
path = path[1:]
}
repo, path, err := RepoFromPath(
username,
domain,
cname,
path,
giteaClient,
)
if err != nil {
log.Errorf("Failed to get repo: %s", err)
w.WriteHeader(404)
return
}
serveFile(username, repo.Name, path, giteaUrl, w)
}
func Handler(pagesDomain, giteaUrl string, giteaClient *gitea.Client) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Server", "rio")
if strings.HasSuffix(req.Host, pagesDomain) {
if handleLetsEncryptChallenge(w, req) {
return
}
log.Debug("Domain can be directly handled")
handleSubdomain(req.Host, "", req.URL.Path, giteaUrl, giteaClient, w)
return
}
cname, err := lookupCNAME(req.Host)
if err != nil {
log.Warningf("CNAME request failed, we don't handle %s", req.Host)
w.WriteHeader(400)
return
}
log.Debugf("Got CNAME %s", cname)
if strings.HasSuffix(cname, pagesDomain) {
log.Debugf("%s is alias of %s", req.Host, cname)
if handleLetsEncryptChallenge(w, req) {
return
}
log.Debugf("Domain can be handled after a CNAME query")
handleSubdomain(cname, cname, req.URL.Path, giteaUrl, giteaClient, w)
return
}
log.Errorf("Not handling %s", req.Host)
w.WriteHeader(404)
}
}
func runServer(ctx *cli.Context) error {
log.SetLevel(log.DebugLevel)
giteaUrl := ctx.String("gitea-url")
domain := ctx.String("pages-domain")
certsFile := ctx.String("certs-file")
acmeEmail := ctx.String("acme-email")
acmeServer := ctx.String("acme-server")
acmeFile := ctx.String("acme-file")
acmeHost := ctx.String("acme-host")
acmePort := ctx.String("acme-port")
acmeDisable := ctx.Bool("acme-disable")
// Setup the Gitea stuff
httpClient := http.Client{Timeout: 10 * time.Second}
client, err := gitea.NewClient(
giteaUrl,
gitea.SetHTTPClient(&httpClient),
gitea.SetToken(""),
gitea.SetUserAgent("rio"),
)
// Listen on the port
addr := ctx.String("listen-host") + ":" + ctx.String("listen-port")
listener, err := net.Listen("tcp", addr)
if err != nil {
fmt.Errorf("Failed to create listener: %v", err)
return err
}
if !acmeDisable {
if acmeEmail == "" || acmeFile == "" || certsFile == "" {
return errors.New("The options acme-file, acme-email, and certs-file are required")
}
err := LoadCertificateStoreFromFile(certsFile)
if err != nil {
log.Debugf("Generating cert")
err := InitialiseFallbackCert(domain)
if err != nil {
log.Fatalf("Failed to generate fallback certificate: %v", err)
return err
}
FlushCertificateStoreToFile(certsFile)
log.Debug("Certificate wrote to disk")
} else {
log.Debug("Certificate store read from disk")
}
// Create an ACME client, if we failed to load one
acmeClient, err := ClientFromFile(acmeFile, acmeServer)
if err != nil {
log.Warn("Failed to load ACME client data from disk. Generating new account")
acmeClient, err = GenerateNewAccount(acmeEmail, acmeFile, acmeServer)
if err != nil {
log.Fatalf("Failed to generate new ACME client: %v", err)
return err
}
log.Info("ACME account registered")
} else {
log.Info("ACME client data read from disk")
}
// Set up the HTTP01 listener
err = acmeClient.Challenge.SetHTTP01Provider(
http01.NewProviderServer(acmeHost, acmePort),
)
if err != nil {
log.Fatalf("Failed to setup HTTP01 challenge listener: %v", err)
return err
}
tlsConfig := makeTlsConfig(
domain,
certsFile,
acmeClient,
)
listener = tls.NewListener(listener, tlsConfig)
}
if err := http.Serve(listener, Handler(domain, giteaUrl, client)); err != nil {
fmt.Printf("Listening failed")
return err
}
return nil
}
func main() {
app := &cli.App{
Action: runServer,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "gitea-url",
Usage: "The (HTTPS) URL to the serving Gitea instance",
EnvVars: []string{"GITEA_URL"},
Required: true,
},
&cli.StringFlag{
Name: "listen-host",
Usage: "The host to listen on",
EnvVars: []string{"HOST"},
Value: "127.0.0.1",
},
&cli.StringFlag{
Name: "listen-port",
Usage: "The port to listen on",
EnvVars: []string{"PORT"},
Value: "8888",
},
&cli.StringFlag{
Name: "acme-host",
Usage: "The host to bind to for ACME challenges",
EnvVars: []string{"ACME_HOST"},
Value: "",
},
&cli.StringFlag{
Name: "acme-port",
Usage: "The port to listen on for ACME challenges",
EnvVars: []string{"ACME_PORT"},
Value: "8889",
},
&cli.StringFlag{
Name: "pages-domain",
Usage: "The domain on which the server is reachable",
EnvVars: []string{"PAGES_DOMAIN"},
Required: true,
},
&cli.StringFlag{
Name: "certs-file",
Usage: "File to store certificates in",
EnvVars: []string{"CERTS_FILE"},
Value: "",
},
&cli.StringFlag{
Name: "acme-file",
Usage: "File to store ACME configuration in",
EnvVars: []string{"ACME_FILE"},
Value: "",
},
&cli.StringFlag{
Name: "acme-email",
Usage: "Email to use for an ACME account",
EnvVars: []string{"ACME_EMAIL"},
Value: "",
},
&cli.StringFlag{
Name: "acme-server",
Usage: "CA Directory to use",
EnvVars: []string{"ACME_SERVER"},
Value: "https://acme-staging-v02.api.letsencrypt.org/directory",
},
&cli.BoolFlag{
Name: "acme-disable",
Usage: "Whether to disable automatic ACME certificates",
EnvVars: []string{"ACME_DISABLE"},
},
},
}
if err := app.Run(os.Args); err != nil {
log.Fatalf("Failed to run app: %s", err)
}
}

127
pages.go Normal file
View File

@@ -0,0 +1,127 @@
package main
import (
"fmt"
"io"
"mime"
"net/http"
"strings"
"time"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
var (
pageCache = cache.New(6*time.Hour, 1*time.Hour)
)
type PageContentCache struct {
Content []byte
mimeType string
RequestedAt time.Time
}
func makePageContentCacheEntry(username, path string) string {
return username + ":" + path
}
func serveFile(username, reponame, path, giteaUrl string, w http.ResponseWriter) {
// Provide a default
if path == "" {
path = "/index.html"
}
// Strip away a starting / as it messes with Gitea
if path[:1] == "/" {
path = path[1:]
}
key := makePageContentCacheEntry(username, path)
entry, found := pageCache.Get(key)
var content []byte
var mimeType string
var err error
if found {
content = entry.(PageContentCache).Content
mimeType = entry.(PageContentCache).mimeType
log.Debugf("Found page in cache with mimeType '%s'", mimeType)
}
// 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,
reponame,
path,
PagesBranch,
)
client := &http.Client{}
req, err := http.NewRequest("GET", apiUrl, nil)
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 !found {
log.Errorf("Failed to get file %s/%s/%s (%s)", username, reponame, path, err)
w.WriteHeader(404)
} else {
log.Debugf("Request failed but page %s is cached in memory", path)
w.WriteHeader(200)
w.Header().Set("Content-Type", mimeType)
w.Write(content)
}
return
}
defer resp.Body.Close()
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)
w.WriteHeader(200)
w.Header().Set("Content-Type", mimeType)
w.Write(content)
return
}
// Handle 404s early.
// TODO: Maybe allow delivering a 404.html instead?
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
}
pathParts := strings.Split(path, ".")
ext := pathParts[len(pathParts)-1]
mimeType = mime.TypeByExtension("." + ext)
log.Debugf("Returning MIME type '%s' for extension '%s", mimeType, ext)
now := time.Now()
pageCache.Set(
key,
PageContentCache{
content,
mimeType,
now,
},
cache.DefaultExpiration,
)
log.Debugf("Page %s requested from Gitea and cached in memory at %v", path, now)
w.Header().Set("Content-Type", mimeType)
w.WriteHeader(200)
w.Write(content)
}

131
repo.go Normal file
View File

@@ -0,0 +1,131 @@
package main
import (
"errors"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
var (
pathCache = cache.New(1*time.Hour, 1*time.Hour)
)
type PageCacheEntry struct {
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)
repo, _, err := giteaClient.GetRepo(username, reponame)
if err != nil {
return nil, err
}
// Check if the CNAME file matches
if cname != "" {
file, _, err := giteaClient.GetFile(
username,
repo.Name,
PagesBranch,
"CNAME",
false,
)
if err != nil {
log.Errorf("Could not verify CNAME of %s/%s: %v\n", username, repo.Name, err)
return nil, err
}
cnameContent := strings.Trim(
string(file[:]),
"\n",
)
if cnameContent != cname {
return nil, errors.New("CNAME mismatch")
}
}
// Cache data
pathCache.Set(
makePageCacheKey(domain, path),
PageCacheEntry{
repo,
path,
},
cache.DefaultExpiration,
)
return repo, nil
}
func RepoFromPath(username, host, cname, path string, giteaClient *gitea.Client) (*gitea.Repository, string, error) {
domain := host
// Guess the repository
key := makePageCacheKey(domain, path)
entry, found := pathCache.Get(key)
if found {
pageEntry := entry.(PageCacheEntry)
return pageEntry.Repository, pageEntry.Path, nil
}
pathParts := strings.Split(path, "/")
if len(pathParts) > 1 {
log.Debugf("Trying repository %s", pathParts[0])
modifiedPath := strings.Join(pathParts[1:], "/")
repo, err := lookupRepositoryAndCache(
username,
pathParts[0],
host,
domain,
modifiedPath,
cname,
giteaClient,
)
if err == nil {
return repo, modifiedPath, nil
}
}
// Allow specifying the repository name in the TXT record
reponame := domain
lookupDomain := domain
if cname != "" {
lookupDomain = cname
}
repoLookup, err := 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)
repo, err := lookupRepositoryAndCache(
username,
reponame,
host,
domain,
path,
cname,
giteaClient,
)
return repo, path, err
}