diff options
Diffstat (limited to 'misc')
| -rw-r--r-- | misc/i18n/LICENSE.md | 21 | ||||
| -rw-r--r-- | misc/i18n/README.md | 79 | ||||
| -rw-r--r-- | misc/i18n/doc.go | 2 | ||||
| -rw-r--r-- | misc/i18n/legal.go | 29 | ||||
| -rw-r--r-- | misc/i18n/lingo.go | 132 | ||||
| -rw-r--r-- | misc/i18n/lingo_test.go | 87 | ||||
| -rw-r--r-- | misc/i18n/locale.go | 101 | ||||
| -rw-r--r-- | misc/i18n/locale_test.go | 39 | ||||
| -rw-r--r-- | misc/i18n/translations/de_DE.json | 32 | ||||
| -rw-r--r-- | misc/i18n/translations/en_US.json | 32 | ||||
| -rw-r--r-- | misc/i18n/translations/sr_RS.json | 32 | ||||
| -rw-r--r-- | misc/localca/LICENSE | 21 | ||||
| -rw-r--r-- | misc/localca/doc.go | 10 | ||||
| -rw-r--r-- | misc/localca/legal.go | 27 | ||||
| -rw-r--r-- | misc/localca/localca.go | 121 | ||||
| -rw-r--r-- | misc/localca/localca_test.go | 83 | ||||
| -rw-r--r-- | misc/localca/minica.go | 234 | ||||
| -rw-r--r-- | misc/localca/utils.go | 83 | ||||
| -rw-r--r-- | misc/namegen/elfs/elfs.go | 511 | ||||
| -rw-r--r-- | misc/namegen/elfs/elfs_test.go | 11 | ||||
| -rw-r--r-- | misc/namegen/namegen.go | 36 | ||||
| -rw-r--r-- | misc/namegen/namegen_test.go | 11 | ||||
| -rw-r--r-- | misc/namegen/tarot/doc.go | 3 | ||||
| -rw-r--r-- | misc/namegen/tarot/namegen.go | 40 | ||||
| -rw-r--r-- | misc/namegen/tarot/namegen_test.go | 10 |
25 files changed, 1787 insertions, 0 deletions
diff --git a/misc/i18n/LICENSE.md b/misc/i18n/LICENSE.md new file mode 100644 index 0000000..c72833f --- /dev/null +++ b/misc/i18n/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Dusan Lilic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/misc/i18n/README.md b/misc/i18n/README.md new file mode 100644 index 0000000..c260ea2 --- /dev/null +++ b/misc/i18n/README.md @@ -0,0 +1,79 @@ +lingo +===== + +Very basic Golang library for i18n. There are others that do the job, but this is my take on the problem. + +Features: +--------- +1. Storing messages in JSON files. +2. Support for nested declarations. +2. Detecting language based on Request headers. +3. Very simple to use. + +Usage: +------ + 1. Import Lingo into your project + + ```go + import "github.com/kortem/lingo" + ``` + 1. Create a dir to store translations, and write them in JSON files named [locale].json. For example: + + ``` + en_US.json + sr_RS.json + de.json + ... + ``` + You can write nested JSON too. + ```json + { + "main.title" : "CutleryPlus", + "main.subtitle" : "Knives that put cut in cutlery.", + "menu" : { + "home" : "Home", + "products": { + "self": "Products", + "forks" : "Forks", + "knives" : "Knives", + "spoons" : "Spoons" + }, + } + } + ``` + 2. Initialize a Lingo like this: + + ```go + l := lingo.New("default_locale", "path/to/translations/dir") + ``` + + 3. Get bundle for specific locale via either `string`: + + ```go + t1 := l.TranslationsForLocale("en_US") + t2 := l.TranslationsForLocale("de_DE") + ``` + This way Lingo will return the bundle for specific locale, or default if given is not found. + Alternatively (or primarily), you can get it with `*http.Request`: + + ```go + t := l.TranslationsForRequest(req) + ``` + This way Lingo finds best suited locale via `Accept-Language` header, or if there is no match, returns default. + `Accept-Language` header is set by the browser, so basically it will serve the language the user has set to his browser. + 4. Once you get T instance just fire away! + + ```go + r1 := t1.Value("main.subtitle") + // "Knives that put cut in cutlery." + r1 := t2.Value("main.subtitle") + // "Messer, die legte in Besteck geschnitten." + r3 := t1.Value("menu.products.self") + // "Products" + r5 := t1.Value("error.404", req.URL.Path) + // "Page index.html not found!" + ``` + +Contributions: +----- +I regard this little library as feature-complete, but if you have an idea on how to improve it, feel free to create issues. Also, pull requests are welcome. Enjoy! diff --git a/misc/i18n/doc.go b/misc/i18n/doc.go new file mode 100644 index 0000000..2aa7275 --- /dev/null +++ b/misc/i18n/doc.go @@ -0,0 +1,2 @@ +// Package i18n handles internationalization and the like for Go programs. +package i18n diff --git a/misc/i18n/legal.go b/misc/i18n/legal.go new file mode 100644 index 0000000..7f68dff --- /dev/null +++ b/misc/i18n/legal.go @@ -0,0 +1,29 @@ +package i18n + +import "go4.org/legal" + +func init() { + legal.RegisterLicense(`i18n library of this software copyright: + +The MIT License (MIT) + +Copyright (c) 2014 Dusan Lilic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`) +} diff --git a/misc/i18n/lingo.go b/misc/i18n/lingo.go new file mode 100644 index 0000000..4824c01 --- /dev/null +++ b/misc/i18n/lingo.go @@ -0,0 +1,132 @@ +package i18n + +import ( + "encoding/json" + "io/ioutil" + "log" + "net/http" + "strconv" + "strings" +) + +// L represents Lingo bundle, containing map of all Ts by locale, +// as well as default locale and list of supported locales +type L struct { + bundle map[string]T + deflt string + supported []Locale +} + +func (l *L) exists(locale string) bool { + _, exists := l.bundle[locale] + return exists +} + +// TranslationsForRequest will get the best matched T for given +// Request. If no T is found, returns default T +func (l *L) TranslationsForRequest(r *http.Request) T { + locales := GetLocales(r) + for _, locale := range locales { + t, exists := l.bundle[locales[0].Name()] + if exists { + return t + } + for _, sup := range l.supported { + if locale.Lang == sup.Lang { + return l.bundle[sup.Name()] + } + } + } + return l.bundle[l.deflt] +} + +// TranslationsForLocale will get the T for specific locale. +// If no locale is found, returns default T +func (l *L) TranslationsForLocale(locale string) T { + t, exists := l.bundle[locale] + if exists { + return t + } + return l.bundle[l.deflt] +} + +// T represents translations map for specific locale +type T struct { + transl map[string]interface{} +} + +// Value traverses the translations map and finds translation for +// given key. If no translation is found, returns value of given key. +func (t T) Value(key string, args ...string) string { + if t.exists(key) { + res, ok := t.transl[key].(string) + if ok { + return t.parseArgs(res, args) + } + } + ksplt := strings.Split(key, ".") + for i := range ksplt { + k1 := strings.Join(ksplt[0:i], ".") + k2 := strings.Join(ksplt[i:len(ksplt)], ".") + if t.exists(k1) { + newt := &T{ + transl: t.transl[k1].(map[string]interface{}), + } + return newt.Value(k2, args...) + } + } + return key +} + +// parseArgs replaces the argument placeholders with given arguments +func (t T) parseArgs(value string, args []string) string { + res := value + for i := 0; i < len(args); i++ { + tok := "{" + strconv.Itoa(i) + "}" + res = strings.Replace(res, tok, args[i], -1) + } + return res +} + +// exists checks if value exists for given key +func (t T) exists(key string) bool { + _, ok := t.transl[key] + return ok +} + +// New creates the Lingo bundle. +// Params: +// Default locale, to be used when requested locale +// is not found. +// Path, absolute or relative path to a folder where +// translation .json files are kept +func New(deflt, path string) *L { + files, _ := ioutil.ReadDir(path) + l := &L{ + bundle: make(map[string]T), + deflt: deflt, + supported: make([]Locale, 0), + } + for _, f := range files { + fileName := f.Name() + dat, err := ioutil.ReadFile(path + "/" + fileName) + if err != nil { + log.Printf("Cannot read file %s, file corrupt.", fileName) + log.Printf("Error: %s", err) + continue + } + t := T{ + transl: make(map[string]interface{}), + } + err = json.Unmarshal(dat, &t.transl) + if err != nil { + log.Printf("Cannot read file %s, invalid JSON.", fileName) + log.Printf("Error: %s", err) + continue + } + locale := strings.Split(fileName, ".")[0] + l.supported = append(l.supported, ParseLocale(locale)) + l.bundle[locale] = t + } + return l +} diff --git a/misc/i18n/lingo_test.go b/misc/i18n/lingo_test.go new file mode 100644 index 0000000..ca1f559 --- /dev/null +++ b/misc/i18n/lingo_test.go @@ -0,0 +1,87 @@ +package i18n + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestLingo(t *testing.T) { + l := New("de_DE", "translations") + t1 := l.TranslationsForLocale("en_US") + r1 := t1.Value("main.subtitle") + r1Exp := "Knives that put cut in cutlery." + if r1 != r1Exp { + t.Errorf("Expected \""+r1Exp+"\", got %s", r1) + t.Fail() + } + r2 := t1.Value("home.title") + r2Exp := "Welcome to CutleryPlus!" + if r2 != r2Exp { + t.Errorf("Expected \""+r2Exp+"\", got %s", r2) + t.Fail() + } + r3 := t1.Value("menu.products.self") + r3Exp := "Products" + if r3 != r3Exp { + t.Errorf("Expected \""+r3Exp+"\", got %s", r3) + t.Fail() + } + r4 := t1.Value("menu.non.existant") + r4Exp := "non.existant" + if r4 != r4Exp { + t.Errorf("Expected \""+r4Exp+"\", got %s", r4) + t.Fail() + } + r5 := t1.Value("error.404", "idnex.html") + r5Exp := "Page idnex.html not found!" + if r5 != r5Exp { + t.Errorf("Expected \""+r5Exp+"\", got \"%s\"", r5) + t.Fail() + } +} + +func TestLingoHttp(t *testing.T) { + l := New("en_US", "translations") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expected := r.Header.Get("Expected-Results") + t1 := l.TranslationsForRequest(r) + r1 := t1.Value("error.500") + if r1 != expected { + t.Errorf("Expected \""+expected+"\", got %s", r1) + t.Fail() + } + })) + defer srv.Close() + url, _ := url.Parse(srv.URL) + + req1 := &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Accept-Language": {"sr, en-gb;q=0.8, en;q=0.7"}, + "Expected-Results": {"Greska sa nase strane, pokusajte ponovo."}, + }, + URL: url, + } + req2 := &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Accept-Language": {"en-US, en-gb;q=0.8, en;q=0.7"}, + "Expected-Results": {"Something is wrong on our side, please try again."}, + }, + URL: url, + } + req3 := &http.Request{ + Method: "GET", + Header: map[string][]string{ + "Accept-Language": {"de-at, en-gb;q=0.8, en;q=0.7"}, + "Expected-Results": {"Stimmt etwas nicht auf unserer Seite ist, versuchen Sie es erneut."}, + }, + URL: url, + } + + http.DefaultClient.Do(req1) + http.DefaultClient.Do(req2) + http.DefaultClient.Do(req3) +} diff --git a/misc/i18n/locale.go b/misc/i18n/locale.go new file mode 100644 index 0000000..6b4a793 --- /dev/null +++ b/misc/i18n/locale.go @@ -0,0 +1,101 @@ +package i18n + +import ( + "errors" + "net/http" + "strconv" + "strings" +) + +// Locale is locale value from the Accept-Language header in request +type Locale struct { + Lang, Country string + Qual float64 +} + +// Name returns the locale value in 'lang' or 'lang_country' format +// eg: de_DE, en_US, gb +func (l *Locale) Name() string { + if len(l.Country) > 0 { + return l.Lang + "_" + l.Country + } + return l.Lang +} + +// ParseLocale creates a Locale from a locale string +func ParseLocale(locale string) Locale { + locsplt := strings.Split(locale, "_") + resp := Locale{} + resp.Lang = locsplt[0] + if len(locsplt) > 1 { + resp.Country = locsplt[1] + } + return resp +} + +const ( + acceptLanguage = "Accept-Language" +) + +func supportedLocales(alstr string) []Locale { + locales := make([]Locale, 0) + alstr = strings.Replace(alstr, " ", "", -1) + if alstr == "" { + return locales + } + al := strings.Split(alstr, ",") + for _, lstr := range al { + locales = append(locales, Locale{ + Lang: parseLang(lstr), + Country: parseCountry(lstr), + Qual: parseQual(lstr), + }) + } + return locales +} + +// GetLocales returns supported locales for the given requet +func GetLocales(r *http.Request) []Locale { + return supportedLocales(r.Header.Get(acceptLanguage)) +} + +// GetPreferredLocale return preferred locale for the given reuqest +// returns error if there is no preferred locale +func GetPreferredLocale(r *http.Request) (*Locale, error) { + locales := GetLocales(r) + if len(locales) == 0 { + return &Locale{}, errors.New("No locale found") + } + return &locales[0], nil +} + +func parseLang(val string) string { + locale := strings.Split(val, ";")[0] + lang := strings.Split(locale, "-")[0] + return lang +} + +func parseCountry(val string) string { + locale := strings.Split(val, ";")[0] + spl := strings.Split(locale, "-") + if len(spl) > 1 { + return spl[1] + } + return "" +} + +func parseQual(val string) float64 { + spl := strings.Split(val, ";") + if len(spl) > 1 { + qualSpl := strings.Split(spl[1], "=") + if len(qualSpl) > 1 { + qual, err := strconv.ParseFloat(qualSpl[1], 64) + if err != nil { + return 1 + } + return qual + } + } + return 1 +} + diff --git a/misc/i18n/locale_test.go b/misc/i18n/locale_test.go new file mode 100644 index 0000000..6887064 --- /dev/null +++ b/misc/i18n/locale_test.go @@ -0,0 +1,39 @@ +package i18n + +import ( + "testing" +) + +func TestLocale(t *testing.T) { + l0 := supportedLocales("ja-JP;q") + if len(l0) != 1 { + t.Errorf("Expected number of locales \"1\", got %d", len(l0)) + t.Fail() + } + l1 := supportedLocales("en,de-AT; q=0.8,de;q=0.6,bg; q=0.4,en-US;q=0.2,sr;q=0.2") + if len(l1) != 6 { + t.Errorf("Expected number of locales \"6\", got %d", len(l1)) + t.Fail() + } + l2 := supportedLocales("en") + if len(l2) != 1 { + t.Errorf("Expected number of locales \"1\", got %d", len(l2)) + t.Fail() + } + l3 := supportedLocales("") + if len(l3) != 0 { + t.Errorf("Expected number of locales \"0\", got %d", len(l3)) + t.Fail() + } + l4 := ParseLocale("en_US") + if l4.Lang != "en" || l4.Country != "US" { + t.Errorf("Expected \"en\" and \"US\", got %s and %s", l4.Lang, l4.Country) + t.Fail() + } + l5 := ParseLocale("en") + if l5.Lang != "en" || l5.Country != "" { + t.Errorf("Expected \"en\" and \"\", got %s and %s", l5.Lang, l5.Country) + t.Fail() + } + +} diff --git a/misc/i18n/translations/de_DE.json b/misc/i18n/translations/de_DE.json new file mode 100644 index 0000000..c12186d --- /dev/null +++ b/misc/i18n/translations/de_DE.json @@ -0,0 +1,32 @@ +{ + "main.title" : "CutleryPlus", + "main.subtitle" : "Messer, die legte in Besteck geschnitten.", + "menu" : { + "home" : "Home", + "products": { + "self": "Produkte", + "forks" : "Gabeln", + "knives" : "Messer", + "spoons" : "Löffel" + }, + "gallery" : "Galerie", + "about" : "Über uns", + "contact" : "Kontakt" + }, + "home" : { + "title": "Willkommen in CutleryPlus!", + "text" : { + "p1": "Lorem ipsum...", + "p2": "Ein weiterer ipsum lorem." + } + }, + "error" : { + "404" : "Seite {0} wurde nicht gefunden.", + "500" : "Stimmt etwas nicht auf unserer Seite ist, versuchen Sie es erneut.", + "contact" : { + "name" : "Sie müssen Ihren Namen eingeben.", + "email" : "Sie müssen Ihre E-Mail ein.", + "text" : "Sie können eine leere Nachricht nicht zu senden." + } + } +}
\ No newline at end of file diff --git a/misc/i18n/translations/en_US.json b/misc/i18n/translations/en_US.json new file mode 100644 index 0000000..6fa0b8f --- /dev/null +++ b/misc/i18n/translations/en_US.json @@ -0,0 +1,32 @@ +{ + "main.title" : "CutleryPlus", + "main.subtitle" : "Knives that put cut in cutlery.", + "menu" : { + "home" : "Home", + "products": { + "self": "Products", + "forks" : "Forks", + "knives" : "Knives", + "spoons" : "Spoons" + }, + "gallery" : "Gallery", + "about" : "About us", + "contact" : "Contact" + }, + "home" : { + "title": "Welcome to CutleryPlus!", + "text" : { + "p1": "Lorem ipsum...", + "p2": "Another ipsum lorem." + } + }, + "error" : { + "404" : "Page {0} not found!", + "500" : "Something is wrong on our side, please try again.", + "contact" : { + "name" : "You must enter your name.", + "email" : "You must enter your email.", + "text" : "You cannot send an empty message." + } + } +}
\ No newline at end of file diff --git a/misc/i18n/translations/sr_RS.json b/misc/i18n/translations/sr_RS.json new file mode 100644 index 0000000..fe2bea5 --- /dev/null +++ b/misc/i18n/translations/sr_RS.json @@ -0,0 +1,32 @@ +{ + "main.title" : "CutleryPlus", + "main.subtitle" : "Escajg za svakoga", + "menu" : { + "home" : "Pocetna", + "products": { + "self": "Proizvodi", + "forks" : "Viljuske", + "knives" : "Nozevi", + "spoons" : "Kasike" + }, + "gallery" : "Galerija", + "about" : "O nama", + "contact" : "Kontakt" + }, + "home" : { + "title": "Dobrodosli u CutleryPlus!", + "text" : { + "p1": "Lorem ipsum...", + "p2": "Jos jedan ipsum lorem." + } + }, + "error" : { + "404" : "Stranica {0} ne postoji.", + "500" : "Greska sa nase strane, pokusajte ponovo.", + "contact" : { + "name" : "Ime ne sme biti prazno.", + "email" : "Email ne sme biti prazan.", + "text" : "Ne mozete poslati praznu poruku." + } + } +}
\ No newline at end of file diff --git a/misc/localca/LICENSE b/misc/localca/LICENSE new file mode 100644 index 0000000..6aa3d70 --- /dev/null +++ b/misc/localca/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Jacob Hoffman-Andrews + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.
\ No newline at end of file diff --git a/misc/localca/doc.go b/misc/localca/doc.go new file mode 100644 index 0000000..fb4e829 --- /dev/null +++ b/misc/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/misc/localca/legal.go b/misc/localca/legal.go new file mode 100644 index 0000000..9d35318 --- /dev/null +++ b/misc/localca/legal.go @@ -0,0 +1,27 @@ +package localca + +import "go4.org/legal" + +func init() { + legal.RegisterLicense(`MIT License + +Copyright (c) 2016 Jacob Hoffman-Andrews + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.`) +} diff --git a/misc/localca/localca.go b/misc/localca/localca.go new file mode 100644 index 0000000..360b53f --- /dev/null +++ b/misc/localca/localca.go @@ -0,0 +1,121 @@ +package localca + +import ( + "context" + "crypto/tls" + "encoding/pem" + "errors" + "strings" + "time" + + "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() + + 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 + } + + 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/misc/localca/localca_test.go b/misc/localca/localca_test.go new file mode 100644 index 0000000..0c85fba --- /dev/null +++ b/misc/localca/localca_test.go @@ -0,0 +1,83 @@ +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) { + t.Skip("no") + 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", "localhost:9293", &tls.Config{InsecureSkipVerify: true}) + if err != nil { + t.Fatal(err) + } + defer cli.Close() + + cli.Write([]byte("butts")) + }) +} diff --git a/misc/localca/minica.go b/misc/localca/minica.go new file mode 100644 index 0000000..d47330b --- /dev/null +++ b/misc/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) { |
