Compare commits
59 Commits
85bd71cff3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d745fa08b | |||
| b2a27cad72 | |||
| 617b68f43e | |||
| 4b4bc9792b | |||
| aaab500049 | |||
| fbb80c622f | |||
| 48b6585eba | |||
| 3747f02ed8 | |||
| b9cc7f30e8 | |||
| 5b9aaf5e24 | |||
| e3032c8233 | |||
| cf85380ddb | |||
| 8630855374 | |||
| 315bb39f44 | |||
| cb123537d5 | |||
| 9abc268315 | |||
| 8f09aa959b | |||
| 0341ed8219 | |||
| 6028d3fe76 | |||
| f802efb6bd | |||
| 183e77ea61 | |||
| 3af531fdcc | |||
| f264bd5604 | |||
| 9b971daf28 | |||
| 352f8bb4ce | |||
| b8eba55999 | |||
| 55e63eee5b | |||
| 80234fd5ba | |||
| 25eb0de1e7 | |||
| fe2f418e35 | |||
| 2cbe46dc1a | |||
| de14a0e68d | |||
| 40ce4e81a8 | |||
| e628ec7ecf | |||
| 6f9f92e68a | |||
| 996aa10866 | |||
| 412e5d2fac | |||
| c0b87be246 | |||
| 2fb1e36b06 | |||
| be42e60731 | |||
| cb40a0689b | |||
| f4d8b151ec | |||
| 308a72e1b5 | |||
| fb54cc73f0 | |||
| e834da3727 | |||
| 493758f56f | |||
| 3692168346 | |||
| 2d4ecc40cb | |||
| f9d3f0dd5b | |||
| ebdd56013d | |||
| 3012878c94 | |||
| 5181aed0b8 | |||
| 720b3d9d53 | |||
| 731a3ce2cb | |||
| e390ca6047 | |||
| 12aef17cc0 | |||
| c4ef20f513 | |||
| d0a24a60ed | |||
| 3af3f6bb7e |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
# Artificats
|
||||
/rio
|
||||
|
||||
# Testing stuff
|
||||
*.json
|
||||
15
.woodpecker.yml
Normal file
15
.woodpecker.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
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"
|
||||
10
Containerfile
Normal file
10
Containerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
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"]
|
||||
98
README.md
98
README.md
@@ -1,6 +1,102 @@
|
||||
# rio
|
||||
|
||||
A (somewhat) Github Pages compatible Webserver using the Gitea API.
|
||||
A webserver similat to Github Pages using the Gitea API. Supports running in HTTP-only
|
||||
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
|
||||
|
||||
|
||||
343
acme.go
343
acme.go
@@ -1,343 +0,0 @@
|
||||
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.Debugf("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
|
||||
}
|
||||
|
||||
log.Debugf("TLS ServerName: %s", info.ServerName)
|
||||
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
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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
|
||||
}
|
||||
442
cmd/rio/main.go
Normal file
442
cmd/rio/main.go
Normal file
@@ -0,0 +1,442 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
16
cmd/rio/utils.go
Normal file
16
cmd/rio/utils.go
Normal file
@@ -0,0 +1,16 @@
|
||||
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
64
dns.go
@@ -1,64 +0,0 @@
|
||||
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
251
go.mod
@@ -1,29 +1,246 @@
|
||||
module paptutuwawa/rio
|
||||
module git.polynom.me/rio
|
||||
|
||||
go 1.20
|
||||
go 1.24.7
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.17.0
|
||||
github.com/go-acme/lego/v4 v4.14.2
|
||||
code.gitea.io/sdk/gitea v0.22.1
|
||||
github.com/go-acme/lego/v4 v4.28.0
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/urfave/cli/v2 v2.27.1
|
||||
github.com/urfave/cli/v2 v2.27.7
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v4 v4.2.1 // indirect
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // 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/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-jose/go-jose/v3 v3.0.0 // indirect
|
||||
github.com/hashicorp/go-version v1.5.0 // indirect
|
||||
github.com/miekg/dns v1.1.55 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // 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/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect
|
||||
golang.org/x/crypto v0.10.0 // indirect
|
||||
golang.org/x/mod v0.11.0 // indirect
|
||||
golang.org/x/net v0.11.0 // indirect
|
||||
golang.org/x/sys v0.9.0 // indirect
|
||||
golang.org/x/text v0.10.0 // indirect
|
||||
golang.org/x/tools v0.10.0 // indirect
|
||||
github.com/sacloud/api-client-go v0.3.3 // indirect
|
||||
github.com/sacloud/go-http v0.1.9 // indirect
|
||||
github.com/sacloud/iaas-api-go v1.20.0 // indirect
|
||||
github.com/sacloud/packages-go v0.0.11 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/scaleway/scaleway-sdk-go v1.0.0-beta.35 // 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
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package acme
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
136
internal/certificates/certificate.go
Normal file
136
internal/certificates/certificate.go
Normal file
@@ -0,0 +1,136 @@
|
||||
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
|
||||
}
|
||||
124
internal/certificates/store.go
Normal file
124
internal/certificates/store.go
Normal file
@@ -0,0 +1,124 @@
|
||||
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
|
||||
}
|
||||
6
internal/constants/const.go
Normal file
6
internal/constants/const.go
Normal file
@@ -0,0 +1,6 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
// The branch to serve.
|
||||
PagesBranch = "pages"
|
||||
)
|
||||
45
internal/context/context.go
Normal file
45
internal/context/context.go
Normal file
@@ -0,0 +1,45 @@
|
||||
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
|
||||
}
|
||||
40
internal/context/info.go
Normal file
40
internal/context/info.go
Normal file
@@ -0,0 +1,40 @@
|
||||
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)
|
||||
}
|
||||
39
internal/context/path.go
Normal file
39
internal/context/path.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
24
internal/context/user.go
Normal file
24
internal/context/user.go
Normal file
@@ -0,0 +1,24 @@
|
||||
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)
|
||||
}
|
||||
118
internal/dns/dns.go
Normal file
118
internal/dns/dns.go
Normal file
@@ -0,0 +1,118 @@
|
||||
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
|
||||
}
|
||||
32
internal/dns/dns_test.go
Normal file
32
internal/dns/dns_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
15
internal/dns/username.go
Normal file
15
internal/dns/username.go
Normal file
@@ -0,0 +1,15 @@
|
||||
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, ".")
|
||||
}
|
||||
23
internal/dns/username_test.go
Normal file
23
internal/dns/username_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
130
internal/gitea/client.go
Normal file
130
internal/gitea/client.go
Normal file
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
99
internal/metrics/metrics.go
Normal file
99
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,99 @@
|
||||
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
|
||||
}
|
||||
32
internal/metrics/metrics_test.go
Normal file
32
internal/metrics/metrics_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
130
internal/pages/pages.go
Normal file
130
internal/pages/pages.go
Normal file
@@ -0,0 +1,130 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
223
internal/repo/repo.go
Normal file
223
internal/repo/repo.go
Normal file
@@ -0,0 +1,223 @@
|
||||
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
|
||||
}
|
||||
550
internal/repo/repo_test.go
Normal file
550
internal/repo/repo_test.go
Normal file
@@ -0,0 +1,550 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
187
internal/server/tls.go
Normal file
187
internal/server/tls.go
Normal file
@@ -0,0 +1,187 @@
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
67
internal/server/tls_test.go
Normal file
67
internal/server/tls_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
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
249
main.go
@@ -1,249 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
119
pages.go
119
pages.go
@@ -1,119 +0,0 @@
|
||||
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 {
|
||||
log.Debugf("Returning %s from cache", path)
|
||||
content = entry.(PageContentCache).Content
|
||||
mimeType = entry.(PageContentCache).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
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
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.WriteHeader(200)
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Write(content)
|
||||
}
|
||||
131
repo.go
131
repo.go
@@ -1,131 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user