chore: Restructure

This commit is contained in:
2024-01-01 14:19:19 +01:00
parent 85bd71cff3
commit 3af3f6bb7e
14 changed files with 457 additions and 441 deletions

125
internal/acme/account.go Normal file
View File

@@ -0,0 +1,125 @@
package acme
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"io/ioutil"
"github.com/go-acme/lego/v4/acme"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/registration"
)
type AcmeAccount struct {
Email string
Registration *registration.Resource
Key crypto.PrivateKey
}
func (a *AcmeAccount) GetEmail() string {
return a.Email
}
func (a *AcmeAccount) GetPrivateKey() crypto.PrivateKey {
return a.Key
}
func (a *AcmeAccount) GetRegistration() *registration.Resource {
return a.Registration
}
func (a *AcmeAccount) FlushToDisk(storage string) {
data := map[string]interface{}{
"email": a.Email,
"reg": a.Registration,
"private_key_encoded": base64.StdEncoding.EncodeToString(
certcrypto.PEMEncode(a.Key),
),
}
raw, _ := json.Marshal(data)
ioutil.WriteFile(storage, raw, 0600)
}
func ClientFromFile(storage, acmeServer string) (*lego.Client, error) {
file, err := ioutil.ReadFile(storage)
if err != nil {
return nil, err
}
var data map[string]interface{}
_ = json.Unmarshal(file, &data)
reg, _ := data["reg"].(map[string]interface{})
accountRaw := reg["body"].(map[string]interface{})
contact := accountRaw["contact"].([]interface{})
contacts := make([]string, 0)
for _, v := range contact {
contacts = append(contacts, v.(string))
}
registration := registration.Resource{
URI: reg["uri"].(string),
Body: acme.Account{
Status: accountRaw["status"].(string),
Contact: contacts,
TermsOfServiceAgreed: true,
//Orders: accountRaw["orders"].(string),
},
}
pkEncoded, err := base64.StdEncoding.DecodeString(data["private_key_encoded"].(string))
pk, err := certcrypto.ParsePEMPrivateKey(pkEncoded)
account := AcmeAccount{
Email: data["email"].(string),
Key: pk.(*ecdsa.PrivateKey),
Registration: &registration,
}
config := lego.NewConfig(&account)
config.CADirURL = acmeServer
config.Certificate.KeyType = certcrypto.RSA2048
client, err := lego.NewClient(config)
if err != nil {
return nil, err
}
return client, nil
}
func GenerateNewAccount(email, storage, acmeServer string) (*lego.Client, error) {
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
account := AcmeAccount{
Email: email,
Key: privateKey,
}
config := lego.NewConfig(&account)
config.CADirURL = acmeServer
config.Certificate.KeyType = certcrypto.RSA2048
client, err := lego.NewClient(config)
if err != nil {
return nil, err
}
// Register it
req, err := client.Registration.Register(
registration.RegisterOptions{
TermsOfServiceAgreed: true,
},
)
if err != nil {
return nil, err
}
account.Registration = req
account.FlushToDisk(storage)
return client, nil
}

View File

@@ -0,0 +1,109 @@
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 ObtainNewCertificate(domains []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,
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,
}
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,
Domain: pagesDomain,
NotAfter: notAfter,
PrivateKeyEncoded: base64.StdEncoding.EncodeToString(certcrypto.PEMEncode(key)),
Certificate: outBytes,
IssuerCertificate: outBytes,
CertificateUrl: "localhost",
}, nil
}

View File

@@ -0,0 +1,114 @@
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 {
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"`
}
// 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 domain name to 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.Domain] = 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.Domain] = cert
}
cache.Certificates = certs
return cache, nil
}

77
internal/dns/dns.go Normal file
View File

@@ -0,0 +1,77 @@
package dns
import (
"errors"
"net"
"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="
)
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 {
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
}

124
internal/pages/pages.go Normal file
View File

@@ -0,0 +1,124 @@
package pages
import (
"fmt"
"io"
"mime"
"net/http"
"strings"
"time"
"github.com/patrickmn/go-cache"
log "github.com/sirupsen/logrus"
)
const (
// The branch name on which files must reside.
PagesBranch = "pages"
)
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)
}

134
internal/repo/repo.go Normal file
View File

@@ -0,0 +1,134 @@
package repo
import (
"errors"
"strings"
"time"
"git.polynom.me/rio/internal/dns"
"git.polynom.me/rio/internal/pages"
"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,
pages.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 := dns.LookupRepoTXT(lookupDomain)
if err != nil && repoLookup != "" {
log.Infof(
"TXT lookup for %s resulted in choosing repository %s",
lookupDomain,
repoLookup,
)
reponame = repoLookup
} else if cname != "" {
// Allow naming the repository "example.org" (But give the TXT record preference)
reponame = cname
}
log.Debugf("Trying repository %s/%s", username, reponame)
repo, err := lookupRepositoryAndCache(
username,
reponame,
host,
domain,
path,
cname,
giteaClient,
)
return repo, path, err
}

129
internal/server/tls.go Normal file
View File

@@ -0,0 +1,129 @@
package server
import (
"crypto/tls"
"strings"
"sync"
"git.polynom.me/rio/internal/certificates"
"git.polynom.me/rio/internal/dns"
"github.com/go-acme/lego/v4/lego"
log "github.com/sirupsen/logrus"
)
var (
// 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)
}
func MakeTlsConfig(pagesDomain, cachePath string, cache *certificates.CertificatesCache, 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, _ := dns.LookupCNAME(info.ServerName)
if !strings.HasSuffix(cname, pagesDomain) {
log.Warnf("Got ServerName for Domain %s that we're not responsible for", info.ServerName)
return cache.FallbackCertificate.TlsCertificate, nil
}
}
// If we want to access <user>.<pages domain>, then we can just
// use a wildcard certificate.
domain := info.ServerName
/*if strings.HasSuffix(info.ServerName, pagesDomain) {
domain = "*." + pagesDomain
}*/
cert, found := cache.Certificates[info.ServerName]
if found {
if cert.IsValid() {
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 cache.FallbackCertificate.TlsCertificate, nil
}
defer unlockDomain(domain)
// Request new certificate
log.Debugf("Obtaining new certificate for %s...", domain)
cert, err := certificates.ObtainNewCertificate(
[]string{domain},
acmeClient,
)
if err != nil {
log.Errorf(
"Failed to get certificate for %s: %v",
domain,
err,
)
return cache.FallbackCertificate.TlsCertificate, nil
}
// Add to cache and flush
cache.AddCert(cert, cachePath)
return cert.TlsCertificate, nil
}
log.Debugf("TLS ServerName: %s", info.ServerName)
return cache.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,
},
}
}