From f84de97aac5865fc17ee28cb1af7f25005ee321b Mon Sep 17 00:00:00 2001 From: Christine Dodrill Date: Fri, 5 Oct 2018 10:02:55 -0700 Subject: import libraries --- localca/doc.go | 10 + localca/localca.go | 127 +++++++++ localca/localca_test.go | 82 ++++++ localca/minica.go | 234 ++++++++++++++++ localca/utils.go | 83 ++++++ namegen/elfs/elfs.go | 506 +++++++++++++++++++++++++++++++++++ namegen/elfs/elfs_test.go | 10 + namegen/tarot/doc.go | 3 + namegen/tarot/namegen.go | 40 +++ namegen/tarot/namegen_test.go | 10 + tun2/backend.go | 78 ++++++ tun2/backend_test.go | 211 +++++++++++++++ tun2/client.go | 171 ++++++++++++ tun2/client_test.go | 21 ++ tun2/connection.go | 162 +++++++++++ tun2/doc.go | 11 + tun2/server.go | 480 +++++++++++++++++++++++++++++++++ tun2/server_test.go | 171 ++++++++++++ tun2/storage_test.go | 100 +++++++ web/tokiponatokens/toki_pona_test.go | 2 +- 20 files changed, 2511 insertions(+), 1 deletion(-) create mode 100644 localca/doc.go create mode 100644 localca/localca.go create mode 100644 localca/localca_test.go create mode 100644 localca/minica.go create mode 100644 localca/utils.go create mode 100644 namegen/elfs/elfs.go create mode 100644 namegen/elfs/elfs_test.go create mode 100644 namegen/tarot/doc.go create mode 100644 namegen/tarot/namegen.go create mode 100644 namegen/tarot/namegen_test.go create mode 100644 tun2/backend.go create mode 100644 tun2/backend_test.go create mode 100644 tun2/client.go create mode 100644 tun2/client_test.go create mode 100644 tun2/connection.go create mode 100644 tun2/doc.go create mode 100644 tun2/server.go create mode 100644 tun2/server_test.go create mode 100644 tun2/storage_test.go diff --git a/localca/doc.go b/localca/doc.go new file mode 100644 index 0000000..fb4e829 --- /dev/null +++ b/localca/doc.go @@ -0,0 +1,10 @@ +// Package localca uses an autocert.Cache to store and generate TLS certificates +// for domains on demand. +// +// This is kind of powerful, and as such it is limited to only generate +// certificates as subdomains of a given domain. +// +// The design and implementation of this is kinda stolen from minica[1]. +// +// [1]: https://github.com/jsha/minica +package localca diff --git a/localca/localca.go b/localca/localca.go new file mode 100644 index 0000000..abc0f35 --- /dev/null +++ b/localca/localca.go @@ -0,0 +1,127 @@ +package localca + +import ( + "context" + "crypto/tls" + "encoding/pem" + "errors" + "strings" + "time" + + "github.com/Xe/ln" + "github.com/Xe/ln/opname" + "golang.org/x/crypto/acme/autocert" +) + +var ( + ErrBadData = errors.New("localca: certificate data is bad") + ErrDomainDoesntHaveSuffix = errors.New("localca: domain doesn't have the given suffix") +) + +// Manager automatically provisions and caches TLS certificates in a given +// autocert Cache. If it cannot fetch a certificate on demand, the certificate +// is dynamically generated with a lifetime of 100 years, which should be good +// enough. +type Manager struct { + Cache autocert.Cache + DomainSuffix string + + *issuer +} + +// New creates a new Manager with the given key filename, certificate filename, +// allowed domain suffix and autocert cache. All given certificates will be +// created if they don't already exist. +func New(keyFile, certFile, suffix string, cache autocert.Cache) (Manager, error) { + iss, err := getIssuer(keyFile, certFile, true) + + if err != nil { + return Manager{}, err + } + + result := Manager{ + DomainSuffix: suffix, + Cache: cache, + issuer: iss, + } + + return result, nil +} + +func (m Manager) GetCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) { + name := hello.ServerName + if !strings.Contains(strings.Trim(name, "."), ".") { + return nil, errors.New("localca: server name component count invalid") + } + + if !strings.HasSuffix(name, m.DomainSuffix) { + return nil, ErrDomainDoesntHaveSuffix + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + ctx = opname.With(ctx, "localca.Manager.GetCertificate") + ctx = ln.WithF(ctx, ln.F{"server_name": name}) + + data, err := m.Cache.Get(ctx, name) + if err != nil && err != autocert.ErrCacheMiss { + return nil, err + } + + if err == autocert.ErrCacheMiss { + data, _, err = m.issuer.sign([]string{name}, nil) + if err != nil { + return nil, err + } + err = m.Cache.Put(ctx, name, data) + if err != nil { + return nil, err + } + } + + cert, err := loadCertificate(name, data) + if err != nil { + return nil, err + } + + ln.Log(ctx, ln.Info("returned cert successfully")) + + return cert, nil +} + +func loadCertificate(name string, data []byte) (*tls.Certificate, error) { + priv, pub := pem.Decode(data) + if priv == nil || !strings.Contains(priv.Type, "PRIVATE") { + return nil, ErrBadData + } + privKey, err := parsePrivateKey(priv.Bytes) + if err != nil { + return nil, err + } + + // public + var pubDER [][]byte + for len(pub) > 0 { + var b *pem.Block + b, pub = pem.Decode(pub) + if b == nil { + break + } + pubDER = append(pubDER, b.Bytes) + } + if len(pub) > 0 { + return nil, ErrBadData + } + + // verify and create TLS cert + leaf, err := validCert(name, pubDER, privKey, time.Now()) + if err != nil { + return nil, err + } + tlscert := &tls.Certificate{ + Certificate: pubDER, + PrivateKey: privKey, + Leaf: leaf, + } + return tlscert, nil +} diff --git a/localca/localca_test.go b/localca/localca_test.go new file mode 100644 index 0000000..233253d --- /dev/null +++ b/localca/localca_test.go @@ -0,0 +1,82 @@ +package localca + +import ( + "context" + "crypto/tls" + "io" + "io/ioutil" + "os" + "path" + "testing" + "time" + + "golang.org/x/crypto/acme/autocert" +) + +func TestLocalCA(t *testing.T) { + dir, err := ioutil.TempDir("", "localca-test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(dir) + cache := autocert.DirCache(dir) + + keyFile := path.Join(dir, "key.pem") + certFile := path.Join(dir, "cert.pem") + const suffix = "club" + + m, err := New(keyFile, certFile, suffix, cache) + if err != nil { + t.Fatal(err) + } + + t.Run("local", func(t *testing.T) { + _, err = m.GetCertificate(&tls.ClientHelloInfo{ + ServerName: "foo.local.cetacean.club", + }) + if err != nil { + t.Fatal(err) + } + }) + + t.Run("network", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + tc := &tls.Config{ + GetCertificate: m.GetCertificate, + } + + go func() { + lis, err := tls.Listen("tcp", ":9293", tc) + if err != nil { + t.Fatal(err) + } + defer lis.Close() + + for { + select { + case <-ctx.Done(): + return + default: + } + + cli, err := lis.Accept() + if err != nil { + t.Fatal(err) + } + defer cli.Close() + + go io.Copy(cli, cli) + } + }() + + time.Sleep(130 * time.Millisecond) + cli, err := tls.Dial("tcp", "foo.local.cetacean.club:9293", &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + + cli.Write([]byte("butts")) + }) +} diff --git a/localca/minica.go b/localca/minica.go new file mode 100644 index 0000000..d47330b --- /dev/null +++ b/localca/minica.go @@ -0,0 +1,234 @@ +package localca + +import ( + "bytes" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/pem" + "fmt" + "io/ioutil" + "math" + "math/big" + "net" + "os" + "strings" + "time" +) + +type issuer struct { + key crypto.Signer + cert *x509.Certificate +} + +func getIssuer(keyFile, certFile string, autoCreate bool) (*issuer, error) { + keyContents, keyErr := ioutil.ReadFile(keyFile) + certContents, certErr := ioutil.ReadFile(certFile) + if os.IsNotExist(keyErr) && os.IsNotExist(certErr) { + err := makeIssuer(keyFile, certFile) + if err != nil { + return nil, err + } + return getIssuer(keyFile, certFile, false) + } else if keyErr != nil { + return nil, fmt.Errorf("%s (but %s exists)", keyErr, certFile) + } else if certErr != nil { + return nil, fmt.Errorf("%s (but %s exists)", certErr, keyFile) + } + key, err := readPrivateKey(keyContents) + if err != nil { + return nil, fmt.Errorf("reading private key from %s: %s", keyFile, err) + } + + cert, err := readCert(certContents) + if err != nil { + return nil, fmt.Errorf("reading CA certificate from %s: %s", certFile, err) + } + + equal, err := publicKeysEqual(key.Public(), cert.PublicKey) + if err != nil { + return nil, fmt.Errorf("comparing public keys: %s", err) + } else if !equal { + return nil, fmt.Errorf("public key in CA certificate %s doesn't match private key in %s", + certFile, keyFile) + } + return &issuer{key, cert}, nil +} + +func readPrivateKey(keyContents []byte) (crypto.Signer, error) { + block, _ := pem.Decode(keyContents) + if block == nil { + return nil, fmt.Errorf("no PEM found") + } else if block.Type != "RSA PRIVATE KEY" && block.Type != "ECDSA PRIVATE KEY" { + return nil, fmt.Errorf("incorrect PEM type %s", block.Type) + } + return x509.ParsePKCS1PrivateKey(block.Bytes) +} + +func readCert(certContents []byte) (*x509.Certificate, error) { + block, _ := pem.Decode(certContents) + if block == nil { + return nil, fmt.Errorf("no PEM found") + } else if block.Type != "CERTIFICATE" { + return nil, fmt.Errorf("incorrect PEM type %s", block.Type) + } + return x509.ParseCertificate(block.Bytes) +} + +func makeIssuer(keyFile, certFile string) error { + keyData, key, err := makeKey() + if err != nil { + return err + } + ioutil.WriteFile(keyFile, keyData, 0600) + certData, _, err := makeRootCert(key, certFile) + if err != nil { + return err + } + ioutil.WriteFile(certFile, certData, 0600) + return nil +} + +func makeKey() ([]byte, *rsa.PrivateKey, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, err + } + der := x509.MarshalPKCS1PrivateKey(key) + if err != nil { + return nil, nil, err + } + buf := bytes.NewBuffer([]byte{}) + err = pem.Encode(buf, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: der, + }) + if err != nil { + return nil, nil, err + } + return buf.Bytes(), key, nil +} + +func makeRootCert(key crypto.Signer, filename string) ([]byte, *x509.Certificate, error) { + serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + if err != nil { + return nil, nil, err + } + template := &x509.Certificate{ + Subject: pkix.Name{ + CommonName: "localca root ca " + hex.EncodeToString(serial.Bytes()[:3]), + }, + SerialNumber: serial, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(100, 0, 0), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + } + + der, err := x509.CreateCertificate(rand.Reader, template, template, key.Public(), key) + if err != nil { + return nil, nil, err + } + buf := bytes.NewBuffer([]byte{}) + err = pem.Encode(buf, &pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }) + if err != nil { + return nil, nil, err + } + result, err := x509.ParseCertificate(der) + return buf.Bytes(), result, err +} + +func parseIPs(ipAddresses []string) ([]net.IP, error) { + var parsed []net.IP + for _, s := range ipAddresses { + p := net.ParseIP(s) + if p == nil { + return nil, fmt.Errorf("invalid IP address %s", s) + } + parsed = append(parsed, p) + } + return parsed, nil +} + +func publicKeysEqual(a, b interface{}) (bool, error) { + aBytes, err := x509.MarshalPKIXPublicKey(a) + if err != nil { + return false, err + } + bBytes, err := x509.MarshalPKIXPublicKey(b) + if err != nil { + return false, err + } + return bytes.Compare(aBytes, bBytes) == 0, nil +} + +func (iss *issuer) sign(domains []string, ipAddresses []string) ([]byte, *x509.Certificate, error) { + var cn string + if len(domains) > 0 { + cn = domains[0] + } else if len(ipAddresses) > 0 { + cn = ipAddresses[0] + } else { + return nil, nil, fmt.Errorf("must specify at least one domain name or IP address") + } + keyData, key, err := makeKey() + if err != nil { + return nil, nil, err + } + buf := bytes.NewBuffer([]byte{}) + buf.Write(keyData) + + parsedIPs, err := parseIPs(ipAddresses) + if err != nil { + return nil, nil, err + } + serial, err := rand.Int(rand.Reader, big.NewInt(math.MaxInt64)) + if err != nil { + return nil, nil, err + } + template := &x509.Certificate{ + DNSNames: domains, + IPAddresses: parsedIPs, + Subject: pkix.Name{ + CommonName: cn, + }, + SerialNumber: serial, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(90, 0, 0), + + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + IsCA: false, + } + der, err := x509.CreateCertificate(rand.Reader, template, iss.cert, key.Public(), iss.key) + if err != nil { + return nil, nil, err + } + err = pem.Encode(buf, &pem.Block{ + Type: "CERTIFICATE", + Bytes: der, + }) + if err != nil { + return nil, nil, err + } + result, err := x509.ParseCertificate(der) + return buf.Bytes(), result, err +} + +func split(s string) (results []string) { + if len(s) > 0 { + return strings.Split(s, ",") + } + return nil +} diff --git a/localca/utils.go b/localca/utils.go new file mode 100644 index 0000000..efc06f3 --- /dev/null +++ b/localca/utils.go @@ -0,0 +1,83 @@ +package localca + +import ( + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/x509" + "errors" + "time" +) + +// validCert parses a cert chain provided as der argument and verifies the leaf and der[0] +// correspond to the private key, the domain and key type match, and expiration dates +// are valid. It doesn't do any revocation checking. +// +// The returned value is the verified leaf cert. +func validCert(name string, der [][]byte, key crypto.Signer, now time.Time) (leaf *x509.Certificate, err error) { + // parse public part(s) + var n int + for _, b := range der { + n += len(b) + } + pub := make([]byte, n) + n = 0 + for _, b := range der { + n += copy(pub[n:], b) + } + x509Cert, err := x509.ParseCertificates(pub) + if err != nil || len(x509Cert) == 0 { + return nil, errors.New("localca: no public key found") + } + // verify the leaf is not expired and matches the domain name + leaf = x509Cert[0] + if now.Before(leaf.NotBefore) { + return nil, errors.New("localca: certificate is not valid yet") + } + if now.After(leaf.NotAfter) { + return nil, errors.New("localca: expired certificate") + } + if err := leaf.VerifyHostname(name); err != nil { + return nil, err + } + // ensure the leaf corresponds to the private key and matches the certKey type + switch pub := leaf.PublicKey.(type) { + case *rsa.PublicKey: + prv, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("localca: private key type does not match public key type") + } + if pub.N.Cmp(prv.N) != 0 { + return nil, errors.New("localca: private key does not match public key") + } + default: + return nil, errors.New("localca: unknown public key algorithm") + } + return leaf, nil +} + +// Attempt to parse the given private key DER block. OpenSSL 0.9.8 generates +// PKCS#1 private keys by default, while OpenSSL 1.0.0 generates PKCS#8 keys. +// OpenSSL ecparam generates SEC1 EC private keys for ECDSA. We try all three. +// +// Inspired by parsePrivateKey in crypto/tls/tls.go. +func parsePrivateKey(der []byte) (crypto.Signer, error) { + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + switch key := key.(type) { + case *rsa.PrivateKey: + return key, nil + case *ecdsa.PrivateKey: + return key, nil + default: + return nil, errors.New("localca: unknown private key type in PKCS#8 wrapping") + } + } + if key, err := x509.ParseECPrivateKey(der); err == nil { + return key, nil + } + + return nil, errors.New("localca: failed to parse private key") +} diff --git a/namegen/elfs/elfs.go b/namegen/elfs/elfs.go new file mode 100644 index 0000000..8607e4b --- /dev/null +++ b/namegen/elfs/elfs.go @@ -0,0 +1,506 @@ +/* +Package elfs is this project's heroku style name generator. +*/ +package elfs + +import ( + "fmt" + "math/rand" + "strings" +) + +// Names is the name of every Pokemon from Pokemon Vietnamese Crystal. +var Names = []string{ + "SEED", + "GRASS", + "FLOWE", + "SHAD", + "CABR", + "SNAKE", + "GOLD", + "COW", + "GUIKI", + "PEDAL", + "DELAN", + "B-FLY", + "BIDE", + "KEYU", + "FORK", + "LAP", + "PIGE", + "PIJIA", + "CAML", + "LAT", + "BIRD", + "BABOO", + "VIV", + "ABOKE", + "PIKAQ", + "RYE", + "SAN", + "BREAD", + "LIDEL", + "LIDE", + "PIP", + "PIKEX", + "ROK", + "JUGEN", + "PUD", + "BUDE", + "ZHIB", + "GELU", + "GRAS", + "FLOW", + "LAFUL", + "ATH", + "BALA", + "CORN", + "MOLUF", + "DESP", + "DAKED", + "MIMI", + "BOLUX", + "KODA", + "GELUD", + "MONK", + "SUMOY", + "GEDI", + "WENDI", + "NILEM", + "NILE", + "NILEC", + "KEZI", + "YONGL", + "HUDE", + "WANLI", + "GELI", + "GUAIL", + "MADAQ", + "WUCI", + "WUCI", + "MUJEF", + "JELLY", + "SICIB", + "GELU", + "NELUO", + "BOLI", + "JIALE", + "YED", + "YEDE", + "CLO", + "SCARE", + "AOCO", + "DEDE", + "DEDEI", + "BAWU", + "JIUG", + "BADEB", + "BADEB", + "HOLE", + "BALUX", + "GES", + "FANT", + "QUAR", + "YIHE", + "SWAB", + "SLIPP", + "CLU", + "DEPOS", + "BILIY", + "YUANO", + "SOME", + "NO", + "YELA", + "EMPT", + "ZECUN", + "XIAHE", + "BOLEL", + "DEJI", + "MACID", + "XIHON", + "XITO", + "LUCK", + "MENJI", + "GELU", + "DECI", + "XIDE", + "DASAJ", + "DONGN", + "RICUL", + "MINXI", + "BALIY", + "ZENDA", + "LUZEL", + "HELE5", + "0FENB", + "KAIL", + "JIAND", + "CARP", + "JINDE", + "LAPU", + "MUDE", + "YIFU", + "LINLI", + "SANDI", + "HUSI", + "JINC", + "OUMU", + "OUMUX", + "CAP", + "KUIZA", + "PUD", + "TIAO", + "FRMAN", + "CLAU", + "SPARK", + "DRAGO", + "BOLIU", + "GUAIL", + "MIYOU", + "MIY", + "QIAOK", + "BEIL", + "MUKEI", + "RIDED", + "MADAM", + "BAGEP", + "CROC", + "ALIGE", + "OUDAL", + "OUD", + "DADA", + "HEHE", + "YEDEA", + "NUXI", + "NUXIN", + "ROUY", + "ALIAD", + "STICK", + "QIANG", + "LAAND", + "PIQI", + "PI", + "PUPI", + "DEKE", + "DEKEJ", + "NADI", + "NADIO", + "MALI", + "PEA", + "ELECT", + "FLOWE", + "MAL", + "MALI", + "HUSHU", + "NILEE", + "YUZI", + "POPOZ", + "DUZI", + "HEBA", + "XIAN", + "SHAN", + "YEYEA", + "WUY", + "LUO", + "KEFE", + "HULA", + "CROW", + "YADEH", + "MOW", + "ANNAN", + "SUONI", + "KYLI", + "HULU", + "HUDEL", + "YEHE", + "GULAE", + "YEHE", + "BLU", + "GELAN", + "BOAT", + "NIP", + "POIT", + "HELAK", + "XINL", + "BEAR", + "LINB", + "MAGEH", + "MAGEJ", + "WULI", + "YIDE", + "RIVE", + "FISH", + "AOGU", + "DELIE", + "MANTE", + "KONMU", + "DELU", + "HELU", + "HUAN", + "HUMA", + "DONGF", + "JINCA", + "HEDE", + "DEFU", + "LIBY", + "JIAPA", + "MEJI", + "HELE", + "BUHU", + "MILK", + "HABI", + "THUN", + "GARD", + "DON", + "YANGQ", + "SANAQ", + "BANQ", + "LUJ", + "PHIX", + "SIEI", + "EGG", +} + +// Moves is every single move from Pokemon Vietnamese Crystal. +var Moves = []string{ + "ABLE", + "ABNORMA", + "AGAIN", + "AIREXPL", + "ANG", + "ANGER", + "ASAIL", + "ATTACK", + "AURORA", + "AWL", + "BAN", + "BAND", + "BARE", + "BEAT", + "BEATED", + "BELLY", + "BIND", + "BITE", + "BLOC", + "BLOOD", + "BODY", + "BOOK", + "BREATH", + "BUMP", + "CAST", + "CHAM", + "CLAMP", + "CLAP", + "CLAW", + "CLEAR", + "CLI", + "CLIP", + "CLOUD", + "CONTRO", + "CONVY", + "COOLHIT", + "CRASH", + "CRY", + "CUT", + "DESCRI", + "D-FIGHT", + "DIG", + "DITCH", + "DIV", + "DOZ", + "DRE", + "DUL", + "DU-PIN", + "DYE", + "EARTH", + "EDU", + "EG-BOMB", + "EGG", + "ELEGY", + "ELE-HIT", + "EMBODY", + "EMPLI", + "ENGL", + "ERUPT", + "EVENS", + "EXPLOR", + "EYES", + "FALL", + "FAST", + "F-CAR", + "F-DANCE", + "FEARS", + "F-FIGHT", + "FIGHT", + "FIR", + "FIRE", + "FIREHIT", + "FLAME", + "FLAP", + "FLASH", + "FLEW", + "FORCE", + "FRA", + "FREEZE", + "FROG", + "G-BIRD", + "GENKISS", + "GIFT", + "G-KISS", + "G-MOUSE", + "GRADE", + "GROW", + "HAMMER", + "HARD", + "HAT", + "HATE", + "H-BOMB", + "HELL-R", + "HEMP", + "HINT", + "HIT", + "HU", + "HUNT", + "HYPNOSI", + "INHA", + "IRO", + "IRONBAR", + "IR-WING", + "J-GUN", + "KEE", + "KICK", + "KNIF", + "KNIFE", + "KNOCK", + "LEVEL", + "LIGH", + "LIGHHIT", + "LIGHT", + "LIVE", + "L-WALL", + "MAD", + "MAJUS", + "MEL", + "MELO", + "MESS", + "MILK", + "MIMI", + "MISS", + "MIXING", + "MOVE", + "MUD", + "NI-BED", + "NOISY", + "NOONLI", + "NULL", + "N-WAVE", + "PAT", + "PEACE", + "PIN", + "PLAN", + "PLANE", + "POIS", + "POL", + "POWDE", + "POWE", + "POWER", + "PRIZE", + "PROTECT", + "PROUD", + "RAGE", + "RECOR", + "REFLAC", + "REFREC", + "REGR", + "RELIV", + "RENEW", + "R-FIGHT", + "RING", + "RKICK", + "ROCK", + "ROUND", + "RUS", + "RUSH", + "SAND", + "SAW", + "SCISSOR", + "SCRA", + "SCRIPT", + "SEEN", + "SERVER", + "SHADOW", + "SHELL", + "SHINE", + "SHO", + "SIGHT", + "SIN", + "SMALL", + "SMELT", + "SMOK", + "SNAKE", + "SNO", + "SNOW", + "SOU", + "SO-WAVE", + "SPAR", + "SPEC", + "SPID", + "S-PIN", + "SPRA", + "STAM", + "STARE", + "STEA", + "STONE", + "STORM", + "STRU", + "STRUG", + "STUDEN", + "SUBS", + "SUCID", + "SUN-LIG", + "SUNRIS", + "SUPLY", + "S-WAVE", + "TAILS", + "TANGL", + "TASTE", + "TELLI", + "THANK", + "TONKICK", + "TOOTH", + "TORL", + "TRAIN", + "TRIKICK", + "TUNGE", + "VOLT", + "WA-GUN", + "WATCH", + "WAVE", + "W-BOMB", + "WFALL", + "WFING", + "WHIP", + "WHIRL", + "WIND", + "WOLF", + "WOOD", + "WOR", + "YUJA", +} + +func randomMove() string { + return Moves[rand.Intn(len(Moves))] +} + +func randomName() string { + return Names[rand.Intn(len(Names))] +} + +// Next generates a new domain name based on the moves and Pokemon names +// from Pokemon Vietnamese Crystal. +func Next() string { + move1 := randomMove() + move2 := randomMove() + poke := randomName() + return strings.ToLower(fmt.Sprintf("%s-%s-%s", move1, move2, poke)) +} diff --git a/namegen/elfs/elfs_test.go b/namegen/elfs/elfs_test.go new file mode 100644 index 0000000..dd8a2d4 --- /dev/null +++ b/namegen/elfs/elfs_test.go @@ -0,0 +1,10 @@ +package elfs + +import "testing" + +func TestNext(t *testing.T) { + n := Next() + if len(n) == 0 { + t.Fatalf("MakeName had a zero output") + } +} diff --git a/namegen/tarot/doc.go b/namegen/tarot/doc.go new file mode 100644 index 0000000..9fb79cc --- /dev/null +++ b/namegen/tarot/doc.go @@ -0,0 +1,3 @@ +// Package tarot is an automatic name generator. It generates names that could +// pass for tarot cards if you squint hard enough. +package tarot diff --git a/namegen/tarot/namegen.go b/namegen/tarot/namegen.go new file mode 100644 index 0000000..ec18d11 --- /dev/null +++ b/namegen/tarot/namegen.go @@ -0,0 +1,40 @@ +package tarot + +import ( + "fmt" + "math/rand" + "time" +) + +func init() { + rand.Seed(time.Now().Unix()) +} + +// The ranks and suits of this name generator. +var ( + Ranks = []string{ + "one", "two", "three", "four", "five", + "six", "seven", "eight", "nine", "ten", + "ace", "king", "page", "princess", "queen", "jack", + "king", "magus", "prince", "knight", "challenger", + "daughter", "son", "priestess", "shaman", + } + + Suits = []string{ + "clubs", "hearts", "spades", "diamonds", // common playing cards + "swords", "cups", "pentacles", "wands", // tarot + "disks", // thoth tarot + "coins", // karma + "earth", "wind", "water", "air", // classical elements + "aether", "spirits", "nirvana", // new age sounding things + "chakras", "dilutions", "goings", + } +) + +// Next creates a name. +func Next() string { + rank := Ranks[rand.Int()%len(Ranks)] + suit := Suits[rand.Int()%len(Suits)] + + return fmt.Sprintf("%s-of-%s-%d", rank, suit, rand.Int63()%100000) +} diff --git a/namegen/tarot/namegen_test.go b/namegen/tarot/namegen_test.go new file mode 100644 index 0000000..e4f2640 --- /dev/null +++ b/namegen/tarot/namegen_test.go @@ -0,0 +1,10 @@ +package tarot + +import ( + "log" + "testing" +) + +func TestNext(t *testing.T) { + log.Println(Next()) +} diff --git a/tun2/backend.go b/tun2/backend.go new file mode 100644 index 0000000..d94a1a8 --- /dev/null +++ b/tun2/backend.go @@ -0,0 +1,78 @@ +package tun2 + +import "time" + +// Backend is the public state of an individual Connection. +type Backend struct { + ID string + Proto string + User string + Domain string + Phi float32 + Host string + Usable bool +} + +type backendMatcher func(*Connection) bool + +func (s *Server) getBackendsForMatcher(bm backendMatcher) []Backend { + s.connlock.Lock() + defer s.connlock.Unlock() + + var result []Backend + + for _, c := range s.conns { + if !bm(c) { + continue + } + + result = append(result, Backend{ + ID: c.id, + Proto: c.conn.LocalAddr().Network(), + User: c.user, + Domain: c.domain, + Phi: float32(c.detector.Phi(time.Now())), + Host: c.conn.RemoteAddr().String(), + Usable: c.usable, + }) + } + + return result +} + +// KillBackend forcibly disconnects a given backend but doesn't offer a way to +// "ban" it from reconnecting. +func (s *Server) KillBackend(id string) error { + s.connlock.Lock() + defer s.connlock.Unlock() + + for _, c := range s.conns { + if c.id == id { + c.cancel() + return nil + } + } + + return ErrNoSuchBackend +} + +// GetBackendsForDomain fetches all backends connected to this server associated +// to a single public domain name. +func (s *Server) GetBackendsForDomain(domain string) []Backend { + return s.getBackendsForMatcher(func(c *Connection) bool { + return c.domain == domain + }) +} + +// GetBackendsForUser fetches all backends connected to this server owned by a +// given user by username. +func (s *Server) GetBackendsForUser(uname string) []Backend { + return s.getBackendsForMatcher(func(c *Connection) bool { + return c.user == uname + }) +} + +// GetAllBackends fetches every backend connected to this server. +func (s *Server) GetAllBackends() []Backend { + return s.getBackendsForMatcher(func(*Connection) bool { return true }) +} diff --git a/tun2/backend_test.go b/tun2/backend_test.go new file mode 100644 index 0000000..72f4467 --- /dev/null +++ b/tun2/backend_test.go @@ -0,0 +1,211 @@ +package tun2 + +import ( + "bytes" + "context" + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" +) + +func TestBackendAuthV1(t *testing.T) { + st := MockStorage() + + s, err := NewServer(&ServerConfig{ + Storage: st, + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + st.AddRoute(domain, user) + st.AddToken(token, user, []string{"connect"}) + st.AddToken(noPermToken, user, nil) + st.AddToken(otherUserToken, "cadey", []string{"connect"}) + + cases := []struct { + name string + auth Auth + wantErr bool + }{ + { + name: "basic everything should work", + auth: Auth{ + Token: token, + Domain: domain, + }, + wantErr: false, + }, + { + name: "invalid domain", + auth: Auth{ + Token: token, + Domain: "aw.heck", + }, + wantErr: true, + }, + { + name: "invalid token", + auth: Auth{ + Token: "asdfwtweg", + Domain: domain, + }, + wantErr: true, + }, + { + name: "invalid token scopes", + auth: Auth{ + Token: noPermToken, + Domain: domain, + }, + wantErr: true, + }, + { + name: "user token doesn't match domain owner", + auth: Auth{ + Token: otherUserToken, + Domain: domain, + }, + wantErr: true, + }, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + data, err := json.Marshal(cs.auth) + if err != nil { + t.Fatal(err) + } + + _, _, err = s.backendAuthv1(ctx, bytes.NewBuffer(data)) + + if cs.wantErr && err == nil { + t.Fatalf("auth did not err as expected") + } + + if !cs.wantErr && err != nil { + t.Fatalf("unexpected auth err: %v", err) + } + }) + } +} + +func TestBackendRouting(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + st := MockStorage() + + st.AddRoute(domain, user) + st.AddToken(token, user, []string{"connect"}) + + s, err := NewServer(&ServerConfig{ + Storage: st, + }) + if err != nil { + t.Fatal(err) + } + defer s.Close() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + + go s.Listen(l) + + cases := []struct { + name string + wantStatusCode int + handler http.HandlerFunc + }{ + { + name: "200 everything's okay", + wantStatusCode: http.StatusOK, + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "HTTP 200, everything is okay :)", http.StatusOK) + }), + }, + { + name: "500 internal error", + wantStatusCode: http.StatusInternalServerError, + handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "HTTP 500, the world is on fire", http.StatusInternalServerError) + }), + }, + } + + for _, cs := range cases { + t.Run(cs.name, func(t *testing.T) { + ts := httptest.NewServer(cs.handler) + defer ts.Close() + + cc := &ClientConfig{ + ConnType: "tcp", + ServerAddr: l.Addr().String(), + Token: token, + BackendURL: ts.URL, + Domain: domain, + + forceTCPClear: true, + } + + c, err := NewClient(cc) + if err != nil { + t.Fatal(err) + } + + go c.Connect(ctx) // TODO: fix the client library so this ends up actually getting cleaned up + + time.Sleep(125 * time.Millisecond) + + req, err := http.NewRequest("GET", "http://cetacean.club/", nil) + if err != nil { + t.Fatal(err) + } + + resp, err := s.RoundTrip(req) + if err != nil { + t.Fatalf("error in doing round trip: %v", err) + } + + if cs.wantStatusCode != resp.StatusCode { + resp.Write(os.Stdout) + t.Fatalf("got status %d instead of %d", resp.StatusCode, cs.wantStatusCode) + } + }) + } +} + +func setupTestServer() (*Server, *mockStorage, net.Listener, error) { + st := MockStorage() + + st.AddRoute(domain, user) + st.AddToken(token, user, []string{"connect"}) + + s, err := NewServer(&ServerConfig{ + Storage: st, + }) + if err != nil { + return nil, nil, nil, err + } + defer s.Close() + + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, nil, nil, err + } + + go s.Listen(l) + + return s, st, l, nil +} diff --git a/tun2/client.go b/tun2/client.go new file mode 100644 index 0000000..16a513d --- /dev/null +++ b/tun2/client.go @@ -0,0 +1,171 @@ +package tun2 + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httputil" + "net/url" + + "github.com/Xe/ln" + "github.com/Xe/ln/opname" + kcp "github.com/xtaci/kcp-go" + "github.com/xtaci/smux" +) + +// Client connects to a remote tun2 server and sets up authentication before routing +// individual HTTP requests to discrete streams that are reverse proxied to the eventual +// backend. +type Client struct { + cfg *ClientConfig +} + +// ClientConfig configures client with settings that the user provides. +type ClientConfig struct { + TLSConfig *tls.Config + ConnType string + ServerAddr string + Token string + Domain string + BackendURL string + + // internal use only + forceTCPClear bool +} + +// NewClient constructs an instance of Client with a given ClientConfig. +func NewClient(cfg *ClientConfig) (*Client, error) { + if cfg == nil { + return nil, errors.New("tun2: client config needed") + } + + c := &Client{ + cfg: cfg, + } + + return c, nil +} + +// Connect dials the remote server and negotiates a client session with its +// configured server address. This will then continuously proxy incoming HTTP +// requests to the backend HTTP server. +// +// This is a blocking function. +func (c *Client) Connect(ctx context.Context) error { + ctx = opname.With(ctx, "tun2.Client.connect") + return c.connect(ctx, c.cfg.ServerAddr) +} + +func closeLater(ctx context.Context, clo io.Closer) { + <-ctx.Done() + clo.Close() +} + +func (c *Client) connect(ctx context.Context, serverAddr string) error { + target, err := url.Parse(c.cfg.BackendURL) + if err != nil { + return err + } + + s := &http.Server{ + Handler: httputil.NewSingleHostReverseProxy(target), + } + go closeLater(ctx, s) + + f := ln.F{ + "server_addr": serverAddr, + "conn_type": c.cfg.ConnType, + } + + var conn net.Conn + + switch c.cfg.ConnType { + case "tcp": + if c.cfg.forceTCPClear { + ln.Log(ctx, f, ln.Info("connecting over plain TCP")) + conn, err = net.Dial("tcp", serverAddr) + } else { + conn, err = tls.Dial("tcp", serverAddr, c.cfg.TLSConfig) + } + + if err != nil { + return err + } + + case "kcp": + kc, err := kcp.Dial(serverAddr) + if err != nil { + return err + } + defer kc.Close() + + serverHost, _, _ := net.SplitHostPort(serverAddr) + + tc := c.cfg.TLSConfig.Clone() + tc.ServerName = serverHost + conn = tls.Client(kc, tc) + } + go closeLater(ctx, conn) + + ln.Log(ctx, f, ln.Info("connected")) + + session, err := smux.Client(conn, smux.DefaultConfig()) + if err != nil { + return err + } + go closeLater(ctx, session) + + controlStream, err := session.AcceptStream() + if err != nil { + return err + } + go closeLater(ctx, controlStream) + + authData, err := json.Marshal(&Auth{ + Token: c.cfg.Token, + Domain: c.cfg.Domain, + }) + if err != nil { + return err + } + + _, err = controlStream.Write(authData) + if err != nil { + return err + } + + err = s.Serve(&smuxListener{ + conn: conn, + session: session, + }) + if err != nil { + return err + } + + if err := ctx.Err(); err != nil { + ln.Error(ctx, err, f, ln.Info("context error")) + } + return nil +} + +// smuxListener wraps a smux session as a net.Listener. +type smuxListener struct { + conn net.Conn + session *smux.Session +} + +func (sl *smuxListener) Accept() (net.Conn, error) { + return sl.session.AcceptStream() +} + +func (sl *smuxListener) Addr() net.Addr { + return sl.conn.LocalAddr() +} + +func (sl *smuxListener) Close() error { + return sl.session.Close() +} diff --git a/tun2/client_test.go b/tun2/client_test.go new file mode 100644 index 0000000..d3127a7 --- /dev/null +++ b/tun2/client_test.go @@ -0,0 +1,21 @@ +package tun2 + +import ( + "net" + "testing" +) + +func TestNewClientNullConfig(t *testing.T) { + _, err := NewClient(nil) + if err == nil { + t.Fatalf("expected NewClient(nil) to fail, got non-failure") + } +} + +func TestSmuxListenerIsNetListener(t *testing.T) { + var sl interface{} = &smuxListener{} + _, ok := sl.(net.Listener) + if !ok { + t.Fatalf("smuxListener does not implement net.Listener") + } +} diff --git a/tun2/connection.go b/tun2/connection.go new file mode 100644 index 0000000..805a365 --- /dev/null +++ b/tun2/connection.go @@ -0,0 +1,162 @@ +package tun2 + +import ( + "bufio" + "context" + "expvar" + "net" + "net/http" + "sync" + "time" + + "github.com/Xe/ln" + "github.com/Xe/ln/opname" + failure "github.com/dgryski/go-failure" + "github.com/pkg/errors" + "github.com/xtaci/smux" +) + +// Connection is a single active client -> server connection and session +// containing many streams over TCP+TLS or KCP+TLS. Every stream beyond the +// control stream is assumed to be passed to the underlying backend server. +// +// All Connection methods assume this is locked externally. +type Connection struct { + id string + conn net.Conn + session *smux.Session + controlStream *smux.Stream + user string + domain string + cf context.CancelFunc + detector *failure.Detector + Auth *Auth + usable bool + + sync.Mutex + counter *expvar.Int +} + +func (c *Connection) cancel() { + c.cf() + c.usable = false +} + +// F logs key->value pairs as an ln.Fer +func (c *Connection) F() ln.F { + return map[string]interface{}{ + "id": c.id, + "remote": c.conn.RemoteAddr(), + "local": c.conn.LocalAddr(), + "kind": c.conn.LocalAddr().Network(), + "user": c.user, + "domain": c.domain, + } +} + +// Ping ends a "ping" to the client. If the client doesn't respond or the connection +// dies, then the connection needs to be cleaned up. +func (c *Connection) Ping() error { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + ctx = opname.With(ctx, "tun2.Connection.Ping") + ctx = ln.WithF(ctx, ln.F{"timeout": time.Second}) + + req, err := http.NewRequest("GET", "http://backend/health", nil) + if err != nil { + panic(err) + } + req = req.WithContext(ctx) + + _, err = c.RoundTrip(req) + if err != nil { + ln.Error(ctx, err, c, ln.Action("pinging the backend")) + return err + } + + c.detector.Ping(time.Now()) + + return nil +} + +// OpenStream creates a new stream (connection) to the backend server. +func (c *Connection) OpenStream(ctx context.Context) (net.Conn, error) { + ctx = opname.With(ctx, "OpenStream") + if !c.usable { + return nil, ErrNoSuchBackend + } + ctx = ln.WithF(ctx, ln.F{"timeout": time.Second}) + + err := c.conn.SetDeadline(time.Now().Add(time.Second)) + if err != nil { + ln.Error(ctx, err, c) + return nil, err + } + + stream, err := c.session.OpenStream() + if err != nil { + ln.Error(ctx, err, c) + return nil, err + } + + return stream, c.conn.SetDeadline(time.Time{}) +} + +// Close destroys resouces specific to the connection. +func (c *Connection) Close() error { + err := c.controlStream.Close() + if err != nil { + return err + } + + err = c.session.Close() + if err != nil { + return err + } + + err = c.conn.Close() + if err != nil { + return err + } + + return nil +} + +// Connection-specific errors +var ( + ErrCantOpenSessionStream = errors.New("tun2: connection can't open session stream") + ErrCantWriteRequest = errors.New("tun2: connection stream can't write request") + ErrCantReadResponse = errors.New("tun2: connection stream can't read response") +) + +// RoundTrip forwards a HTTP request to the remote backend and then returns the +// response, if any. +func (c *Connection) RoundTrip(req *http.Request) (*http.Response, error) { + ctx := req.Context() + ctx = opname.With(ctx, "tun2.Connection.RoundTrip") + stream, err := c.OpenStream(ctx) + if err != nil { + return nil, errors.Wrap(err, ErrCantOpenSessionStream.Error()) + } + + go func() { + <-req.Context().Done() + stream.Close() + }() + + err = req.Write(stream) + if err != nil { + return nil, errors.Wrap(err, ErrCantWriteRequest.Error()) + } + + buf := bufio.NewReader(stream) + + resp, err := http.ReadResponse(buf, req) + if err != nil { + return nil, errors.Wrap(err, ErrCantReadResponse.Error()) + } + + c.counter.Add(1) + + return resp, nil +} diff --git a/tun2/doc.go b/tun2/doc.go new file mode 100644 index 0000000..e1a1fa5 --- /dev/null +++ b/tun2/doc.go @@ -0,0 +1,11 @@ +/* +Package tun2 tunnels HTTP requests over existing, long-lived connections using +smux[1] and optionally kcp[2] to enable more reliable transport. + +Currently this only works on a per-domain basis, but it is designed to be +flexible enough to support path-based routing as an addition in the future. + +[1]: https://github.com/xtaci/smux +[2]: https://github.com/xtaci/kcp-go +*/ +package tun2 diff --git a/tun2/server.go b/tun2/server.go new file mode 100644 index 0000000..20e5b4e --- /dev/null +++ b/tun2/server.go @@ -0,0 +1,480 @@ +package tun2 + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "expvar" + "fmt" + "io" + "io/ioutil" + "math/rand" + "net" + "net/http" + "os" + "sync" + "time" + + "github.com/Xe/ln" + "github.com/Xe/ln/opname" + failure "github.com/dgryski/go-failure" + "github.com/pborman/uuid" + cmap "github.com/streamrail/concurrent-map" + "github.com/xtaci/smux" +) + +// Error values +var ( + ErrNoSuchBackend = errors.New("tun2: there is no such backend") + ErrAuthMismatch = errors.New("tun2: authenication doesn't match database records") + ErrCantRemoveWhatDoesntExist = errors.New("tun2: this connection does not exist, cannot remove it") +) + +// gen502Page creates the page that is shown when a backend is not connected to a given route. +func gen502Page(req *http.Request) *http.Response { + template := `no backends connected

no backends connected

Please ensure a backend is running for ${HOST}. This is request ID ${REQ_ID}.

` + + resbody := []byte(os.Expand(template, func(in string) string { + switch in { + case "HOST": + return req.Host + case "REQ_ID": + return req.Header.Get("X-Request-Id") + } + + return "" + })) + reshdr := req.Header + reshdr.Set("Content-Type", "text/html; charset=utf-8") + + resp := &http.Response{ + Status: fmt.Sprintf("%d Bad Gateway", http.StatusBadGateway), + StatusCode: http.StatusBadGateway, + Body: ioutil.NopCloser(bytes.NewBuffer(resbody)), + + Proto: req.Proto, + ProtoMajor: req.ProtoMajor, + ProtoMinor: req.ProtoMinor, + Header: reshdr, + ContentLength: int64(len(resbody)), + Close: true, + Request: req, + } + + return resp +} + +// ServerConfig ... +type ServerConfig struct { + SmuxConf *smux.Config + Storage Storage +} + +// Storage is the minimal subset of features that tun2's Server needs out of a +// persistence layer. +type Storage interface { + HasToken(ctx context.Context, token string) (user string, scopes []string, err error) + HasRoute(ctx context.Context, domain string) (user string, err error) +} + +// Server routes frontend HTTP traffic to backend TCP traffic. +type Server struct { + cfg *ServerConfig + ctx context.Context + cancel context.CancelFunc + + connlock sync.Mutex + conns map[net.Conn]*Connection + + domains cmap.ConcurrentMap +} + +// NewServer creates a new Server instance with a given config, acquiring all +// relevant resources. +func NewServer(cfg *ServerConfig) (*Server, error) { + if cfg == nil { + return nil, errors.New("tun2: config must be specified") + } + + if cfg.SmuxConf == nil { + cfg.SmuxConf = smux.DefaultConfig() + + cfg.SmuxConf.KeepAliveInterval = time.Second + cfg.SmuxConf.KeepAliveTimeout = 15 * time.Second + } + + ctx, cancel := context.WithCancel(context.Background()) + ctx = opname.With(ctx, "tun2.Server") + + server := &Server{ + cfg: cfg, + + conns: map[net.Conn]*Connection{}, + domains: cmap.New(), + ctx: ctx, + cancel: cancel, + } + + go server.phiDetectionLoop(ctx) + + return server, nil +} + +// Close stops the background tasks for this Server. +func (s *Server) Close() { + s.cancel() +} + +// Wait blocks until the server context is cancelled. +func (s *Server) Wait() { + for { + select { + case <-s.ctx.Done(): + return + } + } +} + +// Listen passes this Server a given net.Listener to accept backend connections. +func (s *Server) Listen(l net.Listener) { + ctx := opname.With(s.ctx, "Listen") + + f := ln.F{ + "listener_addr": l.Addr(), + "listener_network": l.Addr().Network(), + } + + for { + select { + case <-ctx.Done(): + return + default: + } + + conn, err := l.Accept() + if err != nil { + ln.Error(ctx, err, f, ln.Action("accept connection")) + continue + } + + ln.Log(ctx, f, ln.Action("new backend client connected"), ln.F{ + "conn_addr": conn.RemoteAddr(), + "conn_network": conn.RemoteAddr().Network(), + }) + + go s.HandleConn(ctx, conn) + } +} + +// phiDetectionLoop is an infinite loop that will run the [phi accrual failure detector] +// for each of the backends connected to the Server. This is fairly experimental and +// may be removed. +// +// [phi accrual failure detector]: https://dspace.jaist.ac.jp/dspace/handle/10119/4784 +func (s *Server) phiDetectionLoop(ctx context.Context) { + ctx = opname.With(ctx, "phiDetectionLoop") + t := time.NewTicker(5 * time.Second) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + now := time.Now() + + s.connlock.Lock() + for _, c := range s.conns { + failureChance := c.detector.Phi(now) + const thresh = 0.9 // the threshold for phi failure detection causing logs + + if failureChance > thresh { + ln.Log(ctx, c, ln.Info("phi failure detection"), ln.F{ + "value": failureChance, + "threshold": thresh, + }) + } + } + s.connlock.Unlock() + } + } +} + +// backendAuthv1 runs a simple backend authentication check. It expects the +// client to write a json-encoded instance of Auth. This is then checked +// for token validity and domain matching. +// +// This returns the user that was authenticated and the domain they identified +// with. +func (s *Server) backendAuthv1(ctx context.Context, st io.Reader) (string, *Auth, error) { + ctx = opname.With(ctx, "backendAuthv1") + f := ln.F{ + "backend_auth_version": 1, + } + + f["stage"] = "json decoding" + + d := json.NewDecoder(st) + var auth Auth + err := d.Decode(&auth) + if err != nil { + ln.Error(ctx, err, f) + return "", nil, err + } + + f["auth_domain"] = auth.Domain + f["stage"] = "checking domain" + + routeUser, err := s.cfg.Storage.HasRoute(ctx, auth.Domain) + if err != nil { + ln.Error(ctx, err, f) + return "", nil, err + } + + f["route_user"] = routeUser + f["stage"] = "checking token" + + tokenUser, scopes, err := s.cfg.Storage.HasToken(ctx, auth.Token) + if err != nil { + ln.Error(ctx, err, f) + return "", nil, err + } + + f["token_user"] = tokenUser + f["stage"] = "checking token scopes" + + ok := false + for _, sc := range scopes { + if sc == "connect" { + ok = true + break + } + } + + if !ok { + ln.Error(ctx, ErrAuthMismatch, f) + return "", nil, ErrAuthMismatch + } + + f["stage"] = "user verification" + + if routeUser != tokenUser { + ln.Error(ctx, ErrAuthMismatch, f) + return "", nil, ErrAuthMismatch + } + + return routeUser, &auth, nil +} + +// HandleConn starts up the needed mechanisms to relay HTTP traffic to/from +// the currently connected backend. +func (s *Server) HandleConn(ctx context.Context, c net.Conn) { + ctx = opname.With(ctx, "HandleConn") + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + defer cancel() + + f := ln.F{ + "local": c.LocalAddr().String(), + "remote": c.RemoteAddr().String(), + } + + session, err := smux.Server(c, s.cfg.SmuxConf) + if err != nil { + ln.Error(ctx, err, f, ln.Action("establish server side of smux")) + + return + } + defer session.Close() + + controlStream, err := session.OpenStream() + if err != nil { + ln.Error(ctx, err, f, ln.Action("opening control stream")) + + return + } + defer controlStream.Close() + + user, auth, err := s.backendAuthv1(ctx, controlStream) + if err != nil { + return + } + + connection := &Connection{ + id: uuid.New(), + conn: c, + session: session, + user: user, + domain: auth.Domain, + cf: cancel, + detector: failure.New(15, 1), + Auth: auth, + } + connection.counter = expvar.NewInt("http.backend." + connection.id + ".hits") + + defer func() { + if r := recover(); r != nil { + ln.Log(ctx, connection, ln.F{"action": "connection handler panic", "err": r}) + } + }() + + ln.Log(ctx, connection, ln.Action("backend successfully connected")) + s.addConn(ctx, connection) + + connection.Lock() + connection.usable = true // XXX set this to true once health checks pass? + connection.Unlock() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + err := connection.Ping() + if err != nil { + connection.cancel() + } + case <-s.ctx.Done(): + ln.Log(ctx, connection, ln.Action("server context finished")) + s.removeConn(ctx, connection) + connection.Close() + + return + case <-ctx.Done(): + ln.Log(ctx, connection, ln.Action("client context finished")) + s.removeConn(ctx, connection) + connection.Close() + + return + } + } +} + +// addConn adds a connection to the pool of backend connections. +func (s *Server) addConn(ctx context.Context, connection *Connection) { + s.connlock.Lock() + s.conns[connection.conn] = connection + s.connlock.Unlock() + + var conns []*Connection + + connection.Lock() + val, ok := s.domains.Get(connection.domain) + connection.Unlock() + if ok { + conns, ok = val.([]*Connection) + if !ok { + conns = nil + + connection.Lock() + s.domains.Remove(connection.domain) + connection.Unlock() + } + } + + conns = append(conns, connection) + + connection.Lock() + s.domains.Set(connection.domain, conns) + connection.Unlock() +} + +// removeConn removes a connection from pool of backend connections. +func (s *Server) removeConn(ctx context.Context, connection *Connection) { + ctx = opname.With(ctx, "removeConn") + s.connlock.Lock() + delete(s.conns, connection.conn) + s.connlock.Unlock() + + auth := connection.Auth + + var conns []*Connection + + val, ok := s.domains.Get(auth.Domain) + if ok { + conns, ok = val.([]*Connection) + if !ok { + ln.Error(ctx, ErrCantRemoveWhatDoesntExist, connection, ln.Info("looking up for disconnect removal")) + + return + } + } + + for i, cntn := range conns { + if cntn.id == connection.id { + conns[i] = conns[len(conns)-1] + conns = conns[:len(conns)-1] + } + } + + if len(conns) != 0 { + s.domains.Set(auth.Domain, conns) + } else { + s.domains.Remove(auth.Domain) + } +} + +// RoundTrip sends a HTTP request to a backend and then returns its response. +func (s *Server) RoundTrip(req *http.Request) (*http.Response, error) { + var conns []*Connection + ctx := req.Context() + ctx = opname.With(ctx, "tun2.Server.RoundTrip") + + f := ln.F{ + "req_remote": req.RemoteAddr, + "req_host": req.Host, + "req_uri": req.RequestURI, + "req_method": req.Method, + "req_content_length": req.ContentLength, + } + + val, ok := s.domains.Get(req.Host) + if ok { + conns, ok = val.([]*Connection) + if !ok { + ln.Error(ctx, ErrNoSuchBackend, f, ln.Action("no backend available")) + + return gen502Page(req), nil + } + } + + var goodConns []*Connection + for _, conn := range conns { + conn.Lock() + if conn.usable { + goodConns = append(goodConns, conn) + } + conn.Unlock() + } + + if len(goodConns) == 0 { + ln.Error(ctx, ErrNoSuchBackend, f, ln.Action("no good backends available")) + + return gen502Page(req), nil + } + + c := goodConns[rand.Intn(len(goodConns))] + + resp, err := c.RoundTrip(req) + if err != nil { + ln.Error(ctx, err, c, f, ln.Action("connection roundtrip")) + + defer c.cancel() + return nil, err + } + + ln.Log(ctx, c, ln.Action("http traffic"), f, ln.F{ + "resp_status_code": resp.StatusCode, + "resp_content_length": resp.ContentLength, + }) + + return resp, nil +} + +// Auth is the authentication info the client passes to the server. +type Auth struct { + Token string `json:"token"` + Domain string `json:"domain"` +} diff --git a/tun2/server_test.go b/tun2/server_test.go new file mode 100644 index 0000000..f9f1107 --- /dev/null +++ b/tun2/server_test.go @@ -0,0 +1,171 @@ +package tun2 + +import ( + "context" + "fmt" + "io/ioutil" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Xe/ln/opname" + "github.com/pborman/uuid" +) + +// testing constants +const ( + user = "shachi" + token = "orcaz r kewl" + noPermToken = "aw heck" + otherUserToken = "even more heck" + domain = "cetacean.club" +) + +func TestNewServerNullConfig(t *testing.T) { + _, err := NewServer(nil) + if err == nil { + t.Fatalf("expected NewServer(nil) to fail, got non-failure") + } +} + +func TestGen502Page(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + req, err := http.NewRequest("GET", "http://cetacean.club", nil) + if err != nil { + t.Fatal(err) + } + + substring := uuid.New() + + req = req.WithContext(ctx) + req.Header.Add("X-Request-Id", substring) + req.Host = "cetacean.club" + + resp := gen502Page(req) + if resp == nil { + t.Fatalf("expected response to be non-nil") + } + + if resp.Body != nil { + defer resp.Body.Close() + data, err := ioutil.ReadAll(resp.Body) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(string(data), substring) { + fmt.Println(string(data)) + t.Fatalf("502 page did not contain needed substring %q", substring) + } + } +} + +func TestConnectionsCloseOnServerContextClose(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, _, l, err := setupTestServer() + if err != nil { + t.Fatal(err) + } + defer s.Close() + defer l.Close() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + for i := range make([]struct{}, 5) { + ctx := opname.With(ctx, fmt.Sprint(i)) + cc := &ClientConfig{ + ConnType: "tcp", + ServerAddr: l.Addr().String(), + Token: token, + BackendURL: ts.URL, + Domain: domain, + + forceTCPClear: true, + } + + c, err := NewClient(cc) + if err != nil { + t.Fatal(err) + } + + go c.Connect(ctx) // TODO: fix the client library so this ends up actually getting cleaned up + } + + s.cancel() + time.Sleep(125 * time.Millisecond) + bes := s.GetAllBackends() + if l := len(bes); l != 0 { + t.Fatalf("expected len(bes) == 0, got: %d", l) + } +} + +func BenchmarkHTTP200(b *testing.B) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + s, _, l, err := setupTestServer() + if err != nil { + b.Fatal(err) + } + defer s.Close() + defer l.Close() + + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + defer ts.Close() + + cc := &ClientConfig{ + ConnType: "tcp", + ServerAddr: l.Addr().String(), + Token: token, + BackendURL: ts.URL, + Domain: domain, + + forceTCPClear: true, + } + + c, err := NewClient(cc) + if err != nil { + b.Fatal(err) + } + + go c.Connect(ctx) // TODO: fix the client library so this ends up actually getting cleaned up + + for { + r := s.GetBackendsForDomain(domain) + if len(r) == 0 { + time.Sleep(125 * time.Millisecond) + continue + } + + break + } + + req, err := http.NewRequest("GET", "http://cetacean.club/", nil) + if err != nil { + b.Fatal(err) + } + + _, err = s.RoundTrip(req) + if err != nil { + b.Fatalf("got error on initial request exchange: %v", err) + } + + for n := 0; n < b.N; n++ { + resp, err := s.RoundTrip(req) + if err != nil { + b.Fatalf("got error on %d: %v", n, err) + } + + if resp.StatusCode != http.StatusOK { + b.Fail() + b.Logf("got %d instead of 200", resp.StatusCode) + } + } +} diff --git a/tun2/storage_test.go b/tun2/storage_test.go new file mode 100644 index 0000000..800dd26 --- /dev/null +++ b/tun2/storage_test.go @@ -0,0 +1,100 @@ +package tun2 + +import ( + "context" + "errors" + "sync" + "testing" +) + +func MockStorage() *mockStorage { + return &mockStorage{ + tokens: make(map[string]mockToken), + domains: make(map[string]string), + } +} + +type mockToken struct { + user string + scopes []string +} + +// mockStorage is a simple mock of the Storage interface suitable for testing. +type mockStorage struct { + sync.Mutex + tokens map[string]mockToken + domains map[string]string +} + +func (ms *mockStorage) AddToken(token, user string, scopes []string) { + ms.Lock() + defer ms.Unlock() + + ms.tokens[token] = mockToken{user: user, scopes: scopes} +} + +func (ms *mockStorage) AddRoute(domain, user string) { + ms.Lock() + defer ms.Unlock() + + ms.domains[domain] = user +} + +func (ms *mockStorage) HasToken(ctx context.Context, token string) (string, []string, error) { + ms.Lock() + defer ms.Unlock() + + tok, ok := ms.tokens[token] + if !ok { + return "", nil, errors.New("no such token") + } + + return tok.user, tok.scopes, nil +} + +func (ms *mockStorage) HasRoute(ctx context.Context, domain string) (string, error) { + ms.Lock() + defer ms.Unlock() + + user, ok := ms.domains[domain] + if !ok { + return "", errors.New("no such route") + } + + return user, nil +} + +func TestMockStorage(t *testing.T) { + ms := MockStorage() + + t.Run("token", func(t *testing.T) { + ms.AddToken(token, user, []string{"connect"}) + + us, sc, err := ms.HasToken(nil, token) + if err != nil { + t.Fatal(err) + } + + if us != user { + t.Fatalf("username was %q, expected %q", us, user) + } + + if sc[0] != "connect" { + t.Fatalf("token expected to only have one scope, connect") + } + }) + + t.Run("domain", func(t *testing.T) { + ms.AddRoute(domain, user) + + us, err := ms.HasRoute(nil, domain) + if err != nil { + t.Fatal(err) + } + + if us != user { + t.Fatalf("username was %q, expected %q", us, user) + } + }) + +} diff --git a/web/tokiponatokens/toki_pona_test.go b/web/tokiponatokens/toki_pona_test.go index e31eb8b..abc8945 100644 --- a/web/tokiponatokens/toki_pona_test.go +++ b/web/tokiponatokens/toki_pona_test.go @@ -3,7 +3,7 @@ package tokiponatokens import "testing" func TestTokenizeTokiPona(t *testing.T) { - _, err := TokenizeTokiPona("https://us-central1-golden-cove-408.cloudfunctions.net/function-1", "mi olin e sina.") + _, err := Tokenize("https://us-central1-golden-cove-408.cloudfunctions.net/function-1", "mi olin e sina.") if err != nil { t.Fatal(err) } -- cgit v1.2.3