FinalCommander/FinalCommander.go

425 lines
11 KiB
Go

package main
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"flag"
"git.gammaspectra.live/S.O.N.G/FinalCommander/content"
"git.gammaspectra.live/S.O.N.G/FinalCommander/utilities"
"git.gammaspectra.live/S.O.N.G/MakyuuIchaival"
"git.gammaspectra.live/S.O.N.G/MakyuuIchaival/httputils"
"git.gammaspectra.live/S.O.N.G/MakyuuIchaival/tlsutils"
"github.com/cloudflare/circl/sign/ed25519"
"github.com/mroth/weightedrand"
"github.com/multiformats/go-multihash"
"log"
"net/http"
"os"
"path"
"strings"
"time"
)
var publicKey ed25519.PublicKey
var debugOutput = false
var contentServers []*content.Server
var db *content.Database
var redirectListenAddress string
func selectNextContentServer(skip []int) *content.Server {
inSkip := func(i int) bool {
for _, c := range skip {
if i == c {
return true
}
}
return false
}
var choosingList []weightedrand.Choice
for _, c := range contentServers {
if !inSkip(c.Index) && c.GetCheckResult() {
choosingList = append(choosingList, weightedrand.NewChoice(c, c.Weight))
}
}
chooser, err := weightedrand.NewChooser(choosingList...)
if err != nil {
return nil
}
return chooser.Pick().(*content.Server)
}
func setOtherHeaders(ctx httputils.RequestContext) {
ctx.SetResponseHeader("Cache-Control", "no-store")
ctx.SetResponseHeader("Server", "FinalCommander")
ctx.SetResponseHeader("Vary", "Content-Encoding")
ctx.SetResponseHeader("X-Content-Type-Options", "nosniff")
ctx.SetResponseHeader("X-Robots-Tags", "noindex, nofollow, notranslate")
ctx.SetResponseHeader("Referrer-Policy", "origin")
}
func setCORSHeaders(ctx httputils.RequestContext) {
ctx.SetResponseHeader("Access-Control-Allow-Credentials", "true")
ctx.SetResponseHeader("Access-Control-Max-Age", "7200") //Firefox caps this to 86400, Chrome to 7200. Default is 5 seconds (!!!)
ctx.SetResponseHeader("Access-Control-Allow-Methods", "GET,HEAD,OPTIONS")
ctx.SetResponseHeader("Access-Control-Allow-Headers", "DNT,ETag,Origin,Accept,Accept-Language,X-Requested-With,Range")
ctx.SetResponseHeader("Access-Control-Allow-Origin", "*")
ctx.SetResponseHeader("Access-Control-Expose-Headers", "*")
//CORP, COEP, COOP
ctx.SetResponseHeader("Cross-Origin-Embedder-Policy", "require-corp")
ctx.SetResponseHeader("Cross-Origin-Resource-Policy", "cross-origin")
ctx.SetResponseHeader("Cross-Origin-Opener-Policy", "unsafe-none")
}
func handleHexHash(pathElements []string, ctx httputils.RequestContext, host string) {
if len(pathElements) < 3 {
ctx.SetResponseCode(http.StatusBadRequest)
return
}
hashType := strings.ToLower(pathElements[1])
hashString := pathElements[2]
if len(path.Ext(hashString)) > 0 {
hashString = strings.TrimSuffix(hashString, path.Ext(hashString))
}
hash, err := hex.DecodeString(hashString)
if err != nil {
ctx.SetResponseCode(http.StatusBadRequest)
return
}
var mh multihash.Multihash
if hashType == "sha256" && len(hash) == 32 {
mh, _ = multihash.Encode(hash, multihash.SHA2_256)
} else if hashType == "md5" && len(hash) == 16 {
mh, _ = multihash.Encode(hash, multihash.MD5)
} else {
ctx.SetResponseCode(http.StatusNotImplemented)
return
}
key := content.NewHashIdentifierFromMultihash(mh)
var skip []int
entry := getContentEntry(key)
if len(pathElements) > 3 {
if pathElements[3] == "information" {
ctx.SetResponseHeader("Content-Type", "application/json")
ctx.SetResponseCode(http.StatusOK)
if entry != nil {
b, _ := json.Marshal(struct {
Known bool `json:"known"`
CID string `json:"cid"`
AccessTime int64 `json:"accessTime"`
CheckTime int64 `json:"checkTime"`
Invalid []int `json:"invalid,omitempty"`
}{
Known: true,
CID: entry.CID().String(),
AccessTime: entry.AccessTime,
CheckTime: entry.CheckTime,
Invalid: entry.InvalidList,
})
ctx.ServeBytes(b)
} else {
b, _ := json.Marshal(struct {
Known bool `json:"known"`
CID string `json:"cid"`
}{
Known: false,
CID: key.CID().String(),
})
ctx.ServeBytes(b)
}
return
} else if pathElements[3] == "drop" { //Drop key from cache
ctx.SetResponseHeader("Content-Type", "text/plain")
ctx.SetResponseCode(http.StatusOK)
_ = db.RemoveEntry(key)
if entry != nil && !entry.Key.Equals(key) {
_ = db.RemoveEntry(&entry.Key)
}
ctx.ServeBytes([]byte{})
return
}
//TODO: update entries with these instant returns
data, err := MakyuuIchaival.Bech32Encoding.DecodeString(pathElements[3])
if err != nil {
ctx.SetResponseCode(http.StatusBadRequest)
return
}
skip = utilities.DecodeIntegerList(data)
}
if entry != nil {
oldSkip := skip
skip = entry.InvalidList
for _, ci := range oldSkip {
if !entry.InInvalidList(ci) {
skip = append(skip, ci)
}
}
contentServer := selectNextContentServer(skip)
if contentServer == nil {
ctx.SetResponseCode(http.StatusNotFound)
return
}
ctx.DoRedirect(contentServer.GetContentURL(entry, skip)+host, http.StatusFound)
} else {
contentServer := selectNextContentServer(skip)
if contentServer == nil {
ctx.SetResponseCode(http.StatusNotFound)
return
}
//TODO: only trigger this when we don't get a 404
go func() {
var newInvalidList []int
var e *content.Entry
for _, c := range contentServers {
if !c.GetCheckResult() {
continue
}
result, err := c.CheckEntryKey(key)
if result != nil {
if e == nil {
e = &content.Entry{
Key: *result,
Version: 0,
AccessTime: time.Now().UTC().Unix(),
CheckTime: time.Now().UTC().Unix() + 3600*24*3, // Check sooner after addition, not all servers might have it yet
}
}
} else if err == nil {
newInvalidList = append(newInvalidList, c.Index)
}
}
if e != nil {
if !key.IsKey() { //Check for entry already existing
entry := getContentEntry(&e.Key)
if entry != nil {
e = entry
}
//Add alias mapping
_ = db.SetAlias(&content.Alias{
Key: *key,
Identifier: e.Key,
})
}
e.AccessTime = time.Now().UTC().Unix()
e.InvalidList = newInvalidList
_ = db.SetEntry(e)
}
}()
ctx.DoRedirect(contentServer.GetHashURL(mh, skip)+host, http.StatusFound)
}
}
func handle(ctx httputils.RequestContext) {
if len(ctx.GetHost()) > 0 && len(ctx.GetTLSServerName()) > 0 && strings.Split(ctx.GetHost(), ":")[0] != ctx.GetTLSServerName() { //Prevents rebinding / DNS stuff
ctx.SetResponseCode(http.StatusNotFound)
return
}
host := ctx.GetHost()
if host == "" && ctx.GetTLSServerName() != "" {
host = ctx.GetTLSServerName() + redirectListenAddress
}
if host != "" {
host = "/" + host
}
if ctx.IsGet() || ctx.IsHead() {
if debugOutput {
log.Printf("Serve %s", ctx.GetPath())
}
setOtherHeaders(ctx)
setCORSHeaders(ctx)
if ctx.GetPath() == "/status" {
ctx.SetResponseHeader("Content-Type", "application/json")
ctx.SetResponseCode(http.StatusOK)
b, _ := json.MarshalIndent(struct {
PublicKey string `json:"public_key,omitempty"`
Servers []*content.Server `json:"servers" json:"servers,omitempty"`
}{
PublicKey: MakyuuIchaival.Bech32Encoding.EncodeToString(publicKey),
Servers: contentServers,
}, "", " ")
ctx.ServeBytes(b)
return
}
pathElements := strings.Split(ctx.GetPath(), "/")
//TODO: handle ni RFC 6920
handleHexHash(pathElements, ctx, host)
} else if ctx.IsOptions() {
setOtherHeaders(ctx)
setCORSHeaders(ctx)
ctx.SetResponseCode(http.StatusNoContent)
} else {
ctx.SetResponseCode(http.StatusNotImplemented)
}
}
func getContentEntry(key *content.HashIdentifier) *content.Entry {
entry := db.GetEntry(*key)
if entry != nil {
entry.UpdateAccessTime(db)
if entry.NeedsCheck() {
go func() {
b, _ := entry.Encode() //Encode/decode to copy object
e := content.DecodeEntry(entry.Key, b)
e.CheckTime = time.Now().UTC().Unix() + 3600*24*30 //force a check every 30 days as they get fetched
var newInvalidList []int
for _, c := range contentServers {
if !c.GetCheckResult() {
if e.InInvalidList(c.Index) { //Keep old result if down
newInvalidList = append(newInvalidList, c.Index)
}
continue
}
h, err := c.CheckEntryKey(&e.Key)
if h == nil && err == nil {
newInvalidList = append(newInvalidList, c.Index)
}
}
e.InvalidList = newInvalidList
_ = db.SetEntry(e)
}()
}
}
return entry
}
func checkContentServers() {
for _, c := range contentServers {
c.Check()
}
}
func main() {
debugOption := flag.Bool("debug", false, "Enable debug output.")
certificatePath := flag.String("certificate", "", "Path to SSL certificate file.")
keypairPath := flag.String("keypair", "", "Path to SSL key file.")
databasePath := flag.String("dbpath", "database", "Path to key/value database.")
listenAddress := flag.String("listen", ":7777", "address/port to listen on.")
weightedServerList := flag.String("servers", "", "Weighted list of servers to use. All will use HTTPs. Allowed protocols: orbt. Format [protocol=]address:PORT/WEIGHT,[...]")
sniAddressOption := flag.String("sni", "", "Define SNI address if desired. Empty will serve any requests regardless.")
flag.Parse()
var err error
debugOutput = *debugOption
privateKeyEnv := os.Getenv("PRIVATE_KEY")
if privateKeyEnv != "" {
log.Print("PRIVATE_KEY is deprecated. Use PRIVATE_SEED instead with seed")
privateKey, _ := MakyuuIchaival.Bech32Encoding.DecodeString(privateKeyEnv)
log.Printf("Private Ed25519 seed (keep safe!): %s", MakyuuIchaival.Bech32Encoding.EncodeToString(ed25519.PrivateKey(privateKey).Seed()))
return
}
privateSeedEnv := os.Getenv("PRIVATE_SEED")
if privateSeedEnv == "" {
log.Print("No PRIVATE_SEED environment variable specified, generating new identity")
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
log.Printf("Public Ed25519 key (share this): %s", MakyuuIchaival.Bech32Encoding.EncodeToString(publicKey))
log.Printf("Private Ed25519 seed (keep safe!): %s", MakyuuIchaival.Bech32Encoding.EncodeToString(privateKey.Seed()))
return
}
privateSeed, err := MakyuuIchaival.Bech32Encoding.DecodeString(privateSeedEnv)
if err != nil {
log.Fatal(err)
}
if len(privateSeed) != ed25519.SeedSize {
log.Fatal("Wrong Private key length")
}
privateKey := ed25519.NewKeyFromSeed(privateSeed)
publicKey = make([]byte, ed25519.PublicKeySize)
copy(publicKey, privateKey[ed25519.PublicKeySize:])
log.Printf("Loaded Private Ed25519 key, Public %s", MakyuuIchaival.Bech32Encoding.EncodeToString(publicKey))
db, err = content.OpenDatabase(*databasePath)
if err != nil {
log.Fatal(err)
}
defer db.Close()
for i, s := range strings.Split(*weightedServerList, ",") {
cs, err := content.NewContentServerFromArgument(s, i, privateKey)
if err != nil {
log.Fatal(err)
}
contentServers = append(contentServers, cs)
}
checkContentServers()
go func() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
checkContentServers()
}
}()
tlsConfiguration, err := tlsutils.NewTLSConfiguration(*certificatePath, *keypairPath, *sniAddressOption)
if err != nil {
log.Fatal(err)
}
server := &httputils.Server{
ListenAddress: *listenAddress,
TLSConfig: tlsConfiguration,
EnableHTTP2: true,
EnableHTTP3: true,
Handler: handle,
Debug: debugOutput,
}
redirectListenAddress = *listenAddress
server.Serve()
}