├── .gitignore ├── scripts ├── travis_consul.sh ├── upload.sh └── build.sh ├── .travis.yml ├── commands ├── list.go ├── get.go ├── delete.go ├── reset.go ├── update.go ├── add.go ├── commands.go ├── README.md └── commands_test.go ├── LICENSE ├── main_test.go ├── core ├── common │ └── common.go ├── shaman_test.go └── shaman.go ├── cache ├── consul_test.go ├── scribble_test.go ├── cache_test.go ├── postgres_test.go ├── consul.go ├── scribble.go ├── cache.go └── postgres.go ├── api ├── README.md ├── records.go ├── api.go └── api_test.go ├── server ├── dns_test.go └── dns.go ├── main.go ├── config └── config.go ├── README.md └── vendor └── vendor.json /.gitignore: -------------------------------------------------------------------------------- 1 | shaman 2 | dns 3 | *.cover 4 | config.json 5 | build/ 6 | vendor/*/ 7 | -------------------------------------------------------------------------------- /scripts/travis_consul.sh: -------------------------------------------------------------------------------- 1 | #cleanup 2 | 3 | rm "consul_1.0.0_linux_amd64.zip" 4 | rm "consul" 5 | wget 'https://releases.hashicorp.com/consul/1.0.0/consul_1.0.0_linux_amd64.zip' 6 | unzip "consul_1.0.0_linux_amd64.zip" 7 | ./consul --version 8 | ./consul agent -dev & 9 | -------------------------------------------------------------------------------- /scripts/upload.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # upload to AWS S3 5 | echo "Uploading builds to S3..." 6 | aws s3 sync ./build/ s3://tools.nanopack.io/shaman --grants read=uri=http://acs.amazonaws.com/groups/global/AllUsers --region us-east-1 7 | 8 | echo "Creating invalidation for cloudfront" 9 | aws configure set preview.cloudfront true 10 | aws cloudfront create-invalidation \ 11 | --distribution-id E3B5Z3LYG19QSL \ 12 | --paths /shaman 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: "1.9" 4 | 5 | go_import_path: github.com/nanopack/shaman 6 | 7 | before_script: 8 | - scripts/travis_consul.sh 9 | - sudo -H pip install awscli 10 | 11 | install: 12 | - go get github.com/kardianos/govendor 13 | - govendor sync 14 | 15 | script: 16 | - govendor test +local -cover -v 17 | 18 | after_success: 19 | - export BRANCH=$(if [ "$TRAVIS_PULL_REQUEST" == "false" ]; then echo $TRAVIS_BRANCH; else echo $TRAVIS_PULL_REQUEST_BRANCH; fi) 20 | - 'if [ "$BRANCH" == "master" ]; then 21 | ./scripts/build.sh; 22 | ./scripts/upload.sh; 23 | fi' 24 | -------------------------------------------------------------------------------- /commands/list.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // ListDomains lists all domains in shaman 12 | ListDomains = &cobra.Command{ 13 | Use: "list", 14 | Short: "List all domains in shaman", 15 | Long: ``, 16 | 17 | Run: listRecords, 18 | } 19 | ) 20 | 21 | func listRecords(ccmd *cobra.Command, args []string) { 22 | var query string 23 | if full { 24 | query = "?full=true" 25 | } 26 | 27 | res, err := rest("GET", fmt.Sprintf("/records%v", query), nil) 28 | if err != nil { 29 | fail("Could not contact shaman - %v", err) 30 | } 31 | 32 | b, err := ioutil.ReadAll(res.Body) 33 | if err != nil { 34 | fail("Could not read shaman's response - %v", err) 35 | } 36 | 37 | fmt.Print(string(b)) 38 | } 39 | -------------------------------------------------------------------------------- /commands/get.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // GetDomain gets records for a domain 12 | GetDomain = &cobra.Command{ 13 | Use: "get", 14 | Short: "Get records for a domain", 15 | Long: ``, 16 | 17 | Run: getResource, 18 | } 19 | ) 20 | 21 | func getResource(ccmd *cobra.Command, args []string) { 22 | if resource.Domain == "" { 23 | fail("Domain must be specified. Try adding `-d`.") 24 | } 25 | 26 | res, err := rest("GET", fmt.Sprintf("/records/%v", resource.Domain), nil) 27 | if err != nil { 28 | fail("Could not contact shaman - %v", err) 29 | } 30 | 31 | b, err := ioutil.ReadAll(res.Body) 32 | if err != nil { 33 | fail("Could not read shaman's response - %v", err) 34 | } 35 | 36 | fmt.Print(string(b)) 37 | } 38 | -------------------------------------------------------------------------------- /commands/delete.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "fmt" 5 | "io/ioutil" 6 | 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var ( 11 | // DelDomain removes a domain from shaman 12 | DelDomain = &cobra.Command{ 13 | Use: "delete", 14 | Short: "Remove a domain from shaman", 15 | Long: ``, 16 | 17 | Run: delRecord, 18 | } 19 | ) 20 | 21 | func delRecord(ccmd *cobra.Command, args []string) { 22 | if resource.Domain == "" { 23 | fail("Domain must be specified. Try adding `-d`.") 24 | } 25 | 26 | res, err := rest("DELETE", fmt.Sprintf("/records/%v", resource.Domain), nil) 27 | if err != nil { 28 | fail("Could not contact shaman - %v", err) 29 | } 30 | 31 | // parse response 32 | b, err := ioutil.ReadAll(res.Body) 33 | if err != nil { 34 | fail("Could not read shaman's response - %v", err) 35 | } 36 | 37 | fmt.Print(string(b)) 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Nanopack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | # try and use the correct MD5 lib (depending on user OS darwin/linux) 5 | MD5=$(which md5 || which md5sum) 6 | 7 | # for versioning 8 | getCurrCommit() { 9 | echo `git rev-parse --short HEAD| tr -d "[ \r\n\']"` 10 | } 11 | 12 | # for versioning 13 | getCurrTag() { 14 | echo `git describe --always --tags --abbrev=0 | tr -d "[v\r\n]"` 15 | } 16 | 17 | # remove any previous builds that may have failed 18 | [ -e "./build" ] && \ 19 | echo "Cleaning up old builds..." && \ 20 | rm -rf "./build" 21 | 22 | # build shaman 23 | echo "Building shaman..." 24 | gox -ldflags="-s -X main.version=$(getCurrTag) -X main.commit=$(getCurrCommit)" \ 25 | -osarch "darwin/amd64 linux/amd64 windows/amd64" -output="./build/{{.OS}}/{{.Arch}}/shaman" 26 | 27 | # look through each os/arch/file and generate an md5 for each 28 | echo "Generating md5s..." 29 | for os in $(ls ./build); do 30 | for arch in $(ls ./build/${os}); do 31 | for file in $(ls ./build/${os}/${arch}); do 32 | cat "./build/${os}/${arch}/${file}" | ${MD5} | awk '{print $1}' >> "./build/${os}/${arch}/${file}.md5" 33 | done 34 | done 35 | done 36 | -------------------------------------------------------------------------------- /commands/reset.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | 9 | "github.com/spf13/cobra" 10 | 11 | shaman "github.com/nanopack/shaman/core/common" 12 | ) 13 | 14 | var ( 15 | // ResetDomains resets all domains in shaman 16 | ResetDomains = &cobra.Command{ 17 | Use: "reset", 18 | Short: "Reset all domains in shaman", 19 | Long: ``, 20 | 21 | Run: resetRecords, 22 | } 23 | ) 24 | 25 | func resetRecords(ccmd *cobra.Command, args []string) { 26 | if jsonString == "" { 27 | fail("Must pass json string. Try adding `-j`.") 28 | } 29 | 30 | resources := make([]shaman.Resource, 0) 31 | 32 | err := json.Unmarshal([]byte(jsonString), &resources) 33 | if err != nil { 34 | fail("Bad JSON syntax") 35 | } 36 | 37 | // validate valid values 38 | jsonBytes, err := json.Marshal(resources) 39 | if err != nil { 40 | fail("Bad values for resource") 41 | } 42 | 43 | res, err := rest("PUT", "/records", bytes.NewBuffer(jsonBytes)) 44 | if err != nil { 45 | fail("Could not contact shaman - %v", err) 46 | } 47 | 48 | // parse response 49 | b, err := ioutil.ReadAll(res.Body) 50 | if err != nil { 51 | fail("Could not read shaman's response - %v", err) 52 | } 53 | 54 | fmt.Print(string(b)) 55 | } 56 | -------------------------------------------------------------------------------- /commands/update.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | // UpdateDomain updates records for a domain 14 | UpdateDomain = &cobra.Command{ 15 | Use: "update", 16 | Short: "Update records for a domain", 17 | Long: ``, 18 | 19 | Run: updateRecord, 20 | } 21 | ) 22 | 23 | func updateRecord(ccmd *cobra.Command, args []string) { 24 | if jsonString != "" { 25 | err := json.Unmarshal([]byte(jsonString), &resource) 26 | if err != nil { 27 | fail("Bad JSON syntax") 28 | } 29 | } 30 | 31 | if resource.Domain == "" { 32 | fail("Domain must be specified. Try adding `-d`.") 33 | } 34 | 35 | resource.Records = append(resource.Records, record) 36 | 37 | // validate valid values 38 | jsonBytes, err := json.Marshal(resource) 39 | if err != nil { 40 | fail("Bad values for resource") 41 | } 42 | 43 | res, err := rest("PUT", fmt.Sprintf("/records/%v", resource.Domain), bytes.NewBuffer(jsonBytes)) 44 | if err != nil { 45 | fail("Could not contact shaman - %v", err) 46 | } 47 | 48 | // parse response 49 | b, err := ioutil.ReadAll(res.Body) 50 | if err != nil { 51 | fail("Could not read shaman's response - %v", err) 52 | } 53 | 54 | fmt.Print(string(b)) 55 | } 56 | -------------------------------------------------------------------------------- /commands/add.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var ( 13 | // AddDomain adds a domain to shaman 14 | AddDomain = &cobra.Command{ 15 | Use: "add", 16 | Short: "Add a domain to shaman", 17 | Long: ``, 18 | 19 | Run: addRecord, 20 | } 21 | ) 22 | 23 | func addRecord(ccmd *cobra.Command, args []string) { 24 | if jsonString != "" { 25 | err := json.Unmarshal([]byte(jsonString), &resource) 26 | if err != nil { 27 | fail("Bad JSON syntax") 28 | } 29 | } else { 30 | if record.Address == "" { 31 | // warn if record.Address is empty - doesn't apply to jsonString 32 | fail("Missing address for record. Try adding `-A`") 33 | } 34 | resource.Records = append(resource.Records, record) 35 | } 36 | 37 | if resource.Domain == "" { 38 | fail("Domain must be specified. Try adding `-d`.") 39 | } 40 | 41 | jsonBytes, err := json.Marshal(resource) 42 | if err != nil { 43 | fail("Bad values for resource") 44 | } 45 | 46 | res, err := rest("POST", "/records", bytes.NewBuffer(jsonBytes)) 47 | if err != nil { 48 | fail("Could not contact shaman - %v", err) 49 | } 50 | 51 | b, err := ioutil.ReadAll(res.Body) 52 | if err != nil { 53 | fail("Could not read shaman's response - %v", err) 54 | } 55 | 56 | fmt.Print(string(b)) 57 | } 58 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/nanopack/shaman/config" 11 | ) 12 | 13 | func TestMain(m *testing.M) { 14 | // manually configure 15 | config.LogLevel = "fatal" 16 | discard := &bytes.Buffer{} 17 | shamanTool.SetOutput(discard) 18 | 19 | // set args for shaman 20 | args := strings.Split("-O 127.0.0.1:8053 -2 none:// -s", " ") 21 | shamanTool.SetArgs(args) 22 | 23 | // run shaman server 24 | go main() 25 | <-time.After(time.Second) 26 | 27 | // run tests 28 | rtn := m.Run() 29 | 30 | os.Exit(rtn) 31 | } 32 | 33 | func TestShowHelp(t *testing.T) { 34 | config.Server = false 35 | shamanTool.SetArgs([]string{""}) 36 | 37 | shamanTool.Execute() 38 | } 39 | 40 | func TestBadConfig(t *testing.T) { 41 | args := strings.Split("-c /tmp/nowaythisexists list", " ") 42 | shamanTool.SetArgs(args) 43 | 44 | shamanTool.Execute() 45 | config.ConfigFile = "" 46 | } 47 | 48 | func TestShowVersion(t *testing.T) { 49 | args := strings.Split("-v", " ") 50 | shamanTool.SetArgs(args) 51 | 52 | shamanTool.Execute() 53 | config.Version = false 54 | } 55 | 56 | func TestBadCache(t *testing.T) { 57 | config.L2Connect = "!@#$%^&" 58 | args := strings.Split("-s", " ") 59 | shamanTool.SetArgs(args) 60 | 61 | shamanTool.Execute() 62 | config.L2Connect = "none://" 63 | } 64 | 65 | func TestBadDNSListen(t *testing.T) { 66 | config.L2Connect = "none://" 67 | config.DnsListen = "127.0.0.1:53" 68 | args := strings.Split("-s", " ") 69 | shamanTool.SetArgs(args) 70 | 71 | go shamanTool.Execute() 72 | <-time.After(time.Second) 73 | 74 | // port already in use, will fail here 75 | shamanTool.Execute() 76 | config.DnsListen = "127.0.0.1:8053" 77 | } 78 | -------------------------------------------------------------------------------- /core/common/common.go: -------------------------------------------------------------------------------- 1 | // Package common contains common structs used in shaman 2 | package common 3 | 4 | import ( 5 | "fmt" 6 | 7 | "github.com/nanopack/shaman/config" 8 | ) 9 | 10 | // Resource contains the domain name and a slice of its records 11 | type Resource struct { 12 | Domain string `json:"domain"` // google.com 13 | Records []Record `json:"records"` // dns records 14 | } 15 | 16 | // Record contains dns information 17 | type Record struct { 18 | TTL int `json:"ttl"` // seconds record may be cached (300) 19 | Class string `json:"class"` // protocol family (IN) 20 | RType string `json:"type"` // dns record type (A) 21 | Address string `json:"address"` // address domain resolves to (216.58.217.46) 22 | } 23 | 24 | // StringSlice returns a slice of strings with dns info, each ready for dns.NewRR 25 | func (self Resource) StringSlice() []string { 26 | var records []string 27 | for i := range self.Records { 28 | records = append(records, fmt.Sprintf("%s %d %s %s %s\n", self.Domain, 29 | self.Records[i].TTL, self.Records[i].Class, 30 | self.Records[i].RType, self.Records[i].Address)) 31 | } 32 | return records 33 | } 34 | 35 | // SanitizeDomain ensures the domain ends with a `.` 36 | func SanitizeDomain(domain *string) { 37 | t := []byte(*domain) 38 | if len(t) > 0 && t[len(t)-1] != '.' { 39 | *domain = string(append(t, '.')) 40 | } 41 | } 42 | 43 | // UnsanitizeDomain ensures the domain does not end with a `.` 44 | func UnsanitizeDomain(domain *string) { 45 | t := []byte(*domain) 46 | if len(t) > 0 && t[len(t)-1] == '.' { 47 | *domain = string(t[:len(t)-1]) 48 | } 49 | } 50 | 51 | // Validate ensures record values are set 52 | func (self *Resource) Validate() { 53 | SanitizeDomain(&self.Domain) 54 | 55 | for i := range self.Records { 56 | if self.Records[i].Class == "" { 57 | self.Records[i].Class = "IN" 58 | } 59 | if self.Records[i].TTL == 0 { 60 | self.Records[i].TTL = config.TTL 61 | } 62 | if self.Records[i].RType == "" { 63 | self.Records[i].RType = "A" 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /cache/consul_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nanopack/shaman/cache" 7 | "github.com/nanopack/shaman/config" 8 | shaman "github.com/nanopack/shaman/core/common" 9 | ) 10 | 11 | // test consul cache init 12 | func TestConsulInitialize(t *testing.T) { 13 | config.L2Connect = "consul://127.0.0.1:8500" 14 | err := cache.Initialize() 15 | cache.Initialize() 16 | if err != nil { 17 | t.Errorf("Failed to initalize consul cacher - %v", err) 18 | } 19 | } 20 | 21 | // test consul cache addRecord 22 | func TestConsulAddRecord(t *testing.T) { 23 | consulReset() 24 | err := cache.AddRecord(&nanopack) 25 | if err != nil { 26 | t.Errorf("Failed to add record to consul cacher - %v", err) 27 | } 28 | } 29 | 30 | // test consul cache getRecord 31 | func TestConsulGetRecord(t *testing.T) { 32 | consulReset() 33 | cache.AddRecord(&nanopack) 34 | _, err := cache.GetRecord("nanobox.io") 35 | _, err2 := cache.GetRecord("nanopack.io") 36 | if err == nil || err2 != nil { 37 | t.Errorf("Failed to get record from consul cacher - %v%v", err, err2) 38 | } 39 | } 40 | 41 | // test consul cache updateRecord 42 | func TestConsulUpdateRecord(t *testing.T) { 43 | consulReset() 44 | err := cache.UpdateRecord("nanobox.io", &nanopack) 45 | err2 := cache.UpdateRecord("nanopack.io", &nanopack) 46 | if err != nil || err2 != nil { 47 | t.Errorf("Failed to update record in consul cacher - %v%v", err, err2) 48 | } 49 | } 50 | 51 | // test consul cache deleteRecord 52 | func TestConsulDeleteRecord(t *testing.T) { 53 | consulReset() 54 | err := cache.DeleteRecord("nanobox.io") 55 | cache.AddRecord(&nanopack) 56 | err2 := cache.DeleteRecord("nanopack.io") 57 | if err != nil || err2 != nil { 58 | t.Errorf("Failed to delete record from consul cacher - %v%v", err, err2) 59 | } 60 | } 61 | 62 | // test consul cache resetRecords 63 | func TestConsulResetRecords(t *testing.T) { 64 | consulReset() 65 | err := cache.ResetRecords(&nanoBoth) 66 | if err != nil { 67 | t.Errorf("Failed to reset records in consul cacher - %v", err) 68 | } 69 | } 70 | 71 | // test consul cache listRecords 72 | func TestConsulListRecords(t *testing.T) { 73 | consulReset() 74 | _, err := cache.ListRecords() 75 | cache.ResetRecords(&nanoBoth) 76 | _, err2 := cache.ListRecords() 77 | if err != nil || err2 != nil { 78 | t.Errorf("Failed to list records in consul cacher - %v%v", err, err2) 79 | } 80 | } 81 | 82 | func consulReset() { 83 | config.L2Connect = "consul://127.0.0.1:8500" 84 | cache.Initialize() 85 | blank := make([]shaman.Resource, 0, 0) 86 | cache.ResetRecords(&blank) 87 | } 88 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | [![shaman logo](http://nano-assets.gopagoda.io/readme-headers/shaman.png)](http://nanobox.io/open-source#shaman) 2 | [![Build Status](https://travis-ci.org/nanopack/shaman.svg)](https://travis-ci.org/nanopack/shaman) 3 | 4 | # Shaman 5 | 6 | Small, lightweight, api-driven dns server. 7 | 8 | ## Routes: 9 | 10 | | Route | Description | Payload | Output | 11 | | --- | --- | --- | --- | 12 | | **POST** /records | Adds the domain and full record | json domain object | json domain object | 13 | | **PUT** /records | Update all domains and records (replaces all) | json array of domain objects | json array of domain objects | 14 | | **GET** /records | Returns a list of domains we have records for | nil | string array of domains | 15 | | **PUT** /records/{domain} | Update domain's records (replaces all) | json domain object | json domain object | 16 | | **GET** /records/{domain} | Returns the records for that domain | nil | json domain object | 17 | | **DELETE** /records/{domain} | Delete a domain | nil | success message | 18 | 19 | ## Usage Example: 20 | 21 | #### add domain 22 | ```sh 23 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records -d \ 24 | '{"domain":"nanopack.io","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]}' 25 | # {"domain":"nanopack.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]} 26 | ``` 27 | 28 | #### list domains 29 | ```sh 30 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records 31 | # ["nanopack.io"] 32 | ``` 33 | or add `?full=true` for the full records 34 | ```sh 35 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records?full=true 36 | # [{"domain":"nanopack.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]}] 37 | ``` 38 | 39 | #### update domains 40 | ```sh 41 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records -d \ 42 | '[{"domain":"nanobox.io","records":[{"address":"127.0.0.1"}]}]' \ 43 | -X PUT 44 | # [{"domain":"nanobox.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.1"}]}] 45 | ``` 46 | 47 | #### update domain 48 | ```sh 49 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records/nanobox.io -d \ 50 | '{"domain":"nanobox.io","records":[{"address":"127.0.0.2"}]}' \ 51 | -X PUT 52 | # {"domain":"nanobox.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]} 53 | ``` 54 | 55 | #### delete domain 56 | ```sh 57 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records/nanobox.io \ 58 | -X DELETE 59 | # {"msg":"success"} 60 | ``` 61 | 62 | #### get domain 63 | ```sh 64 | $ curl -k -H "X-AUTH-TOKEN: secret" https://localhost:1632/records/nanobox.io 65 | # {"err":"failed to find record for domain - 'nanobox.io'"} 66 | ``` 67 | 68 | [![oss logo](http://nano-assets.gopagoda.io/open-src/nanobox-open-src.png)](http://nanobox.io/open-source) 69 | -------------------------------------------------------------------------------- /cache/scribble_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/nanopack/shaman/cache" 8 | "github.com/nanopack/shaman/config" 9 | ) 10 | 11 | // test scribble cache init 12 | func TestScribbleInitialize(t *testing.T) { 13 | config.L2Connect = "/tmp/shamanCache" // default 14 | err := cache.Initialize() 15 | config.L2Connect = "!@#$%^&*()" // unparse-able 16 | err2 := cache.Initialize() 17 | config.L2Connect = "scribble:///roots/file" // unable to init? (test no sudo) 18 | err3 := cache.Initialize() 19 | config.L2Connect = "scribble:///" // defaulting to "/var/db" 20 | cache.Initialize() 21 | if err != nil || err2 == nil || err3 != nil { 22 | t.Errorf("Failed to initalize scribble cacher - %v%v%v", err, err2, err3) 23 | } 24 | } 25 | 26 | // test scribble cache addRecord 27 | func TestScribbleAddRecord(t *testing.T) { 28 | scribbleReset() 29 | err := cache.AddRecord(&nanopack) 30 | if err != nil { 31 | t.Errorf("Failed to add record to scribble cacher - %v", err) 32 | } 33 | } 34 | 35 | // test scribble cache getRecord 36 | func TestScribbleGetRecord(t *testing.T) { 37 | scribbleReset() 38 | cache.AddRecord(&nanopack) 39 | _, err := cache.GetRecord("nanobox.io") 40 | _, err2 := cache.GetRecord("nanopack.io") 41 | if err == nil || err2 != nil { 42 | t.Errorf("Failed to get record from scribble cacher - %v%v", err, err2) 43 | } 44 | } 45 | 46 | // test scribble cache updateRecord 47 | func TestScribbleUpdateRecord(t *testing.T) { 48 | scribbleReset() 49 | err := cache.UpdateRecord("nanobox.io", &nanopack) 50 | err2 := cache.UpdateRecord("nanopack.io", &nanopack) 51 | if err != nil || err2 != nil { 52 | t.Errorf("Failed to update record in scribble cacher - %v%v", err, err2) 53 | } 54 | } 55 | 56 | // test scribble cache deleteRecord 57 | func TestScribbleDeleteRecord(t *testing.T) { 58 | scribbleReset() 59 | err := cache.DeleteRecord("nanobox.io") 60 | cache.AddRecord(&nanopack) 61 | err2 := cache.DeleteRecord("nanopack.io") 62 | if err != nil || err2 != nil { 63 | t.Errorf("Failed to delete record from scribble cacher - %v%v", err, err2) 64 | } 65 | } 66 | 67 | // test scribble cache resetRecords 68 | func TestScribbleResetRecords(t *testing.T) { 69 | scribbleReset() 70 | err := cache.ResetRecords(&nanoBoth) 71 | if err != nil { 72 | t.Errorf("Failed to reset records in scribble cacher - %v", err) 73 | } 74 | } 75 | 76 | // test scribble cache listRecords 77 | func TestScribbleListRecords(t *testing.T) { 78 | scribbleReset() 79 | _, err := cache.ListRecords() 80 | cache.ResetRecords(&nanoBoth) 81 | _, err2 := cache.ListRecords() 82 | if err != nil || err2 != nil { 83 | t.Errorf("Failed to list records in scribble cacher - %v%v", err, err2) 84 | } 85 | } 86 | 87 | func scribbleReset() { 88 | os.RemoveAll("/tmp/shamanCache") 89 | config.L2Connect = "scribble:///tmp/shamanCache" 90 | cache.Initialize() 91 | } 92 | -------------------------------------------------------------------------------- /cache/cache_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/jcelliott/lumber" 8 | 9 | "github.com/nanopack/shaman/cache" 10 | "github.com/nanopack/shaman/config" 11 | shaman "github.com/nanopack/shaman/core/common" 12 | ) 13 | 14 | var ( 15 | nanopack = shaman.Resource{Domain: "nanopack.io.", Records: []shaman.Record{{Address: "127.0.0.1"}}} 16 | nanobox = shaman.Resource{Domain: "nanobox.io.", Records: []shaman.Record{{Address: "127.0.0.2"}}} 17 | nanoBoth = []shaman.Resource{nanopack, nanobox} 18 | ) 19 | 20 | func TestMain(m *testing.M) { 21 | // manually configure 22 | // config.Log = lumber.NewConsoleLogger(lumber.LvlInt("trace")) 23 | config.Log = lumber.NewConsoleLogger(lumber.LvlInt("FATAL")) 24 | 25 | // run tests 26 | rtn := m.Run() 27 | 28 | os.Exit(rtn) 29 | } 30 | 31 | // test nil cache init 32 | func TestNoneInitialize(t *testing.T) { 33 | config.L2Connect = "none://" 34 | err := cache.Initialize() 35 | if err != nil { 36 | t.Errorf("Failed to initalize none cacher - %v", err) 37 | } 38 | } 39 | 40 | // test nil cache addRecord 41 | func TestNoneAddRecord(t *testing.T) { 42 | noneReset() 43 | err := cache.AddRecord(&shaman.Resource{}) 44 | if err != nil { 45 | t.Errorf("Failed to add record to none cacher - %v", err) 46 | } 47 | } 48 | 49 | // test nil cache getRecord 50 | func TestNoneGetRecord(t *testing.T) { 51 | noneReset() 52 | _, err := cache.GetRecord("nanopack.io") 53 | if err != nil { 54 | t.Errorf("Failed to get record from none cacher - %v", err) 55 | } 56 | } 57 | 58 | // test nil cache updateRecord 59 | func TestNoneUpdateRecord(t *testing.T) { 60 | noneReset() 61 | err := cache.UpdateRecord("nanopack.io", &shaman.Resource{}) 62 | if err != nil { 63 | t.Errorf("Failed to update record in none cacher - %v", err) 64 | } 65 | } 66 | 67 | // test nil cache deleteRecord 68 | func TestNoneDeleteRecord(t *testing.T) { 69 | noneReset() 70 | err := cache.DeleteRecord("nanopack.io") 71 | if err != nil { 72 | t.Errorf("Failed to delete record from none cacher - %v", err) 73 | } 74 | } 75 | 76 | // test nil cache resetRecords 77 | func TestNoneResetRecords(t *testing.T) { 78 | noneReset() 79 | err := cache.ResetRecords(&[]shaman.Resource{}) 80 | if err != nil { 81 | t.Errorf("Failed to reset records in none cacher - %v", err) 82 | } 83 | } 84 | 85 | // test nil cache listRecords 86 | func TestNoneListRecords(t *testing.T) { 87 | noneReset() 88 | _, err := cache.ListRecords() 89 | if err != nil { 90 | t.Errorf("Failed to list records in none cacher - %v", err) 91 | } 92 | } 93 | 94 | func TestNoneExists(t *testing.T) { 95 | noneReset() 96 | if cache.Exists() { 97 | t.Error("Cache exits but shouldn't") 98 | } 99 | } 100 | 101 | func noneReset() { 102 | config.L2Connect = "none://" 103 | cache.Initialize() 104 | } 105 | -------------------------------------------------------------------------------- /cache/postgres_test.go: -------------------------------------------------------------------------------- 1 | package cache_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/nanopack/shaman/cache" 7 | "github.com/nanopack/shaman/config" 8 | shaman "github.com/nanopack/shaman/core/common" 9 | ) 10 | 11 | // test postgres cache init 12 | func TestPostgresInitialize(t *testing.T) { 13 | config.L2Connect = "postgres://postgres@127.0.0.1?sslmode=disable" // default 14 | err := cache.Initialize() 15 | config.L2Connect = "postgresql://postgres@127.0.0.1:9999?sslmode=disable" // unable to init? 16 | err2 := cache.Initialize() 17 | if err != nil || err2 != nil { 18 | t.Errorf("Failed to initalize postgres cacher - %v%v", err, err2) 19 | } 20 | } 21 | 22 | // test postgres cache addRecord 23 | func TestPostgresAddRecord(t *testing.T) { 24 | postgresReset() 25 | err := cache.AddRecord(&nanopack) 26 | if err != nil { 27 | t.Errorf("Failed to add record to postgres cacher - %v", err) 28 | } 29 | 30 | err = cache.AddRecord(&nanopack) 31 | if err != nil { 32 | t.Errorf("Failed to add record to postgres cacher - %v", err) 33 | } 34 | } 35 | 36 | // test postgres cache getRecord 37 | func TestPostgresGetRecord(t *testing.T) { 38 | postgresReset() 39 | cache.AddRecord(&nanopack) 40 | _, err := cache.GetRecord("nanobox.io.") 41 | _, err2 := cache.GetRecord("nanopack.io") 42 | if err == nil || err2 != nil { 43 | t.Errorf("Failed to get record from postgres cacher - %v%v", err, err2) 44 | } 45 | } 46 | 47 | // test postgres cache updateRecord 48 | func TestPostgresUpdateRecord(t *testing.T) { 49 | postgresReset() 50 | err := cache.UpdateRecord("nanobox.io", &nanopack) 51 | err2 := cache.UpdateRecord("nanopack.io", &nanopack) 52 | if err != nil || err2 != nil { 53 | t.Errorf("Failed to update record in postgres cacher - %v%v", err, err2) 54 | } 55 | } 56 | 57 | // test postgres cache deleteRecord 58 | func TestPostgresDeleteRecord(t *testing.T) { 59 | postgresReset() 60 | err := cache.DeleteRecord("nanobox.io") 61 | cache.AddRecord(&nanopack) 62 | err2 := cache.DeleteRecord("nanopack.io") 63 | if err != nil || err2 != nil { 64 | t.Errorf("Failed to delete record from postgres cacher - %v%v", err, err2) 65 | } 66 | } 67 | 68 | // test postgres cache resetRecords 69 | func TestPostgresResetRecords(t *testing.T) { 70 | postgresReset() 71 | err := cache.ResetRecords(&nanoBoth) 72 | if err != nil { 73 | t.Errorf("Failed to reset records in postgres cacher - %v", err) 74 | } 75 | } 76 | 77 | // test postgres cache listRecords 78 | func TestPostgresListRecords(t *testing.T) { 79 | postgresReset() 80 | _, err := cache.ListRecords() 81 | cache.ResetRecords(&nanoBoth) 82 | _, err2 := cache.ListRecords() 83 | if err != nil || err2 != nil { 84 | t.Errorf("Failed to list records in postgres cacher - %v%v", err, err2) 85 | } 86 | } 87 | 88 | func postgresReset() { 89 | config.L2Connect = "postgres://postgres@127.0.0.1?sslmode=disable" 90 | cache.Initialize() 91 | blank := make([]shaman.Resource, 0, 0) 92 | cache.ResetRecords(&blank) 93 | } 94 | -------------------------------------------------------------------------------- /server/dns_test.go: -------------------------------------------------------------------------------- 1 | package server_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | "time" 8 | 9 | "github.com/jcelliott/lumber" 10 | "github.com/miekg/dns" 11 | 12 | "github.com/nanopack/shaman/config" 13 | "github.com/nanopack/shaman/core" 14 | sham "github.com/nanopack/shaman/core/common" 15 | "github.com/nanopack/shaman/server" 16 | ) 17 | 18 | var nanopack = sham.Resource{Domain: "nanopack.io.", Records: []sham.Record{{Address: "127.0.0.1"}}} 19 | 20 | func TestMain(m *testing.M) { 21 | // manually configure 22 | config.DnsListen = "127.0.0.1:8053" 23 | config.Log = lumber.NewConsoleLogger(lumber.LvlInt("FATAL")) 24 | 25 | // start dns server 26 | go server.Start() 27 | <-time.After(time.Second) 28 | 29 | // run tests 30 | rtn := m.Run() 31 | 32 | os.Exit(rtn) 33 | } 34 | 35 | func TestDNS(t *testing.T) { 36 | err := shaman.AddRecord(&nanopack) 37 | if err != nil { 38 | t.Errorf("Failed to add record - %v", err) 39 | t.FailNow() 40 | } 41 | 42 | r, err := ResolveIt("nanopack.io", dns.TypeA) 43 | if err != nil { 44 | t.Errorf("Failed to get record - %v", err) 45 | } 46 | if len(r.Answer) == 0 { 47 | t.Error("No record found") 48 | } 49 | if len(r.Answer) > 0 && r.Answer[0].String() != "nanopack.io.\t60\tIN\tA\t127.0.0.1" { 50 | t.Errorf("Response doesn't match expected - %+q", r.Answer[0].String()) 51 | } 52 | 53 | r, err = ResolveIt("a.b.nanobox.io", dns.TypeA) 54 | if err != nil { 55 | t.Errorf("Failed to get record - %v", err) 56 | } 57 | if len(r.Answer) != 0 { 58 | t.Error("Found non-existant record") 59 | } 60 | 61 | r, err = ResolveIt("nanopack.io", dns.TypeMX, true) 62 | if err != nil { 63 | t.Errorf("Failed to get record - %v", err) 64 | } 65 | if len(r.Answer) != 0 { 66 | t.Error("Found non-existant record") 67 | } 68 | // test fallback 69 | config.DnsFallBack = "8.8.8.8:53" 70 | r, err = ResolveIt("www.google.com", dns.TypeA) 71 | if err != nil { 72 | t.Errorf("Failed to get record - %v", err) 73 | } 74 | if len(r.Answer) == 0 { 75 | t.Error("No record found") 76 | } 77 | 78 | // reset fallback 79 | config.DnsFallBack = "" 80 | r, err = ResolveIt("www.google.com", dns.TypeA) 81 | if len(r.Answer) != 0 { 82 | t.Error("answer found for unregistered domain when fallback is off.") 83 | } 84 | } 85 | 86 | func ResolveIt(domain string, rType uint16, badop ...bool) (*dns.Msg, error) { 87 | // root domain if not already 88 | root(&domain) 89 | m := new(dns.Msg) 90 | m.SetQuestion(domain, rType) 91 | 92 | if len(badop) > 0 { 93 | m.Opcode = dns.OpcodeStatus 94 | } 95 | 96 | // ask the dns server 97 | r, err := dns.Exchange(m, config.DnsListen) 98 | if err != nil { 99 | return nil, fmt.Errorf("Failed to exchange - %v", err) 100 | } 101 | 102 | return r, nil 103 | } 104 | 105 | func root(domain *string) { 106 | t := []byte(*domain) 107 | if len(t) > 0 && t[len(t)-1] != '.' { 108 | *domain = string(append(t, '.')) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /commands/commands.go: -------------------------------------------------------------------------------- 1 | // Package commands provides the cli functionality. 2 | // Runnable commands are: 3 | // add 4 | // get 5 | // update 6 | // delete 7 | // list 8 | // reset 9 | package commands 10 | 11 | import ( 12 | "crypto/tls" 13 | "fmt" 14 | "io" 15 | "net/http" 16 | "os" 17 | 18 | "github.com/spf13/cobra" 19 | 20 | "github.com/nanopack/shaman/config" 21 | shaman "github.com/nanopack/shaman/core/common" 22 | ) 23 | 24 | func rest(method string, path string, body io.Reader) (*http.Response, error) { 25 | uri := fmt.Sprintf("https://%s%s", config.ApiListen, path) 26 | 27 | if config.Insecure { 28 | http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 29 | } 30 | 31 | req, err := http.NewRequest(method, uri, body) 32 | if err != nil { 33 | panic(err) 34 | } 35 | req.Header.Add("X-AUTH-TOKEN", config.ApiToken) 36 | res, err := http.DefaultClient.Do(req) 37 | if err != nil { 38 | // if requesting `https://` failed, server may have been started with `-i`, try `http://` 39 | uri = fmt.Sprintf("http://%s%s", config.ApiListen, path) 40 | req, er := http.NewRequest(method, uri, body) 41 | if er != nil { 42 | panic(er) 43 | } 44 | req.Header.Add("X-AUTH-TOKEN", config.ApiToken) 45 | var err2 error 46 | res, err2 = http.DefaultClient.Do(req) 47 | if err2 != nil { 48 | // return original error to client 49 | return nil, err 50 | } 51 | } 52 | if res.StatusCode == 401 { 53 | return nil, fmt.Errorf("401 Unauthorized. Please specify api token (-t 'token')") 54 | } 55 | return res, nil 56 | } 57 | 58 | func fail(format string, args ...interface{}) { 59 | fmt.Printf(fmt.Sprintf("%v\n", format), args...) 60 | os.Exit(1) 61 | } 62 | 63 | func init() { 64 | domainFlags(AddDomain) 65 | DelDomain.Flags().StringVarP(&resource.Domain, "domain", "d", "", "Domain to remove") 66 | GetDomain.Flags().StringVarP(&resource.Domain, "domain", "d", "", "Domain to get") 67 | ListDomains.Flags().BoolVarP(&full, "full", "f", false, "Show complete records") 68 | ResetDomains.Flags().StringVarP(&jsonString, "json", "j", "", "JSON encoded data for domain[s] and record[s]") 69 | domainFlags(UpdateDomain) 70 | } 71 | 72 | var ( 73 | resource shaman.Resource 74 | record shaman.Record 75 | jsonString string 76 | full bool 77 | ) 78 | 79 | // ResetVars resets the flag vars (used for testing) 80 | func ResetVars() { 81 | resource = shaman.Resource{} 82 | record = shaman.Record{} 83 | jsonString = "" 84 | full = false 85 | } 86 | 87 | func domainFlags(ccmd *cobra.Command) { 88 | ccmd.Flags().StringVarP(&resource.Domain, "domain", "d", "", "Domain") 89 | ccmd.Flags().IntVarP(&record.TTL, "ttl", "T", 60, "Record time to live") 90 | ccmd.Flags().StringVarP(&record.Class, "class", "C", "IN", "Record class") 91 | ccmd.Flags().StringVarP(&record.RType, "type", "R", "A", "Record type (A, CNAME, MX, etc...)") 92 | ccmd.Flags().StringVarP(&record.Address, "address", "A", "", "Record address") 93 | ccmd.Flags().StringVarP(&jsonString, "json", "j", "", "JSON encoded data for domain[s] and record[s]") 94 | } 95 | -------------------------------------------------------------------------------- /api/records.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/nanopack/shaman/core" 8 | sham "github.com/nanopack/shaman/core/common" 9 | ) 10 | 11 | func createRecord(rw http.ResponseWriter, req *http.Request) { 12 | var resource sham.Resource 13 | err := parseBody(req, &resource) 14 | if err != nil { 15 | writeBody(rw, req, apiError{err.Error()}, http.StatusBadRequest) 16 | return 17 | } 18 | 19 | err = shaman.AddRecord(&resource) 20 | if err != nil { 21 | writeBody(rw, req, apiError{err.Error()}, http.StatusInternalServerError) 22 | return 23 | } 24 | 25 | writeBody(rw, req, resource, http.StatusOK) 26 | } 27 | 28 | func listRecords(rw http.ResponseWriter, req *http.Request) { 29 | if req.URL.Query().Get("full") == "true" { 30 | writeBody(rw, req, shaman.ListRecords(), http.StatusOK) 31 | return 32 | } 33 | 34 | writeBody(rw, req, shaman.ListDomains(), http.StatusOK) 35 | } 36 | 37 | func updateAnswers(rw http.ResponseWriter, req *http.Request) { 38 | resources := make([]sham.Resource, 0) 39 | err := parseBody(req, &resources) 40 | if err != nil { 41 | writeBody(rw, req, apiError{err.Error()}, http.StatusBadRequest) 42 | return 43 | } 44 | 45 | err = shaman.ResetRecords(&resources) 46 | if err != nil { 47 | writeBody(rw, req, apiError{err.Error()}, http.StatusInternalServerError) 48 | return 49 | } 50 | 51 | writeBody(rw, req, resources, http.StatusOK) 52 | } 53 | 54 | func updateRecord(rw http.ResponseWriter, req *http.Request) { 55 | var resource sham.Resource 56 | err := parseBody(req, &resource) 57 | if err != nil { 58 | writeBody(rw, req, apiError{err.Error()}, http.StatusBadRequest) 59 | return 60 | } 61 | 62 | domain := req.URL.Query().Get(":domain") 63 | 64 | if !shaman.Exists(domain) { 65 | // create resource if not exist 66 | err = shaman.AddRecord(&resource) 67 | if err != nil { 68 | writeBody(rw, req, apiError{err.Error()}, http.StatusInternalServerError) 69 | return 70 | } 71 | 72 | // "MUST reply 201" (https://www.w3.org/Protocols/rfc2616/rfc2616-sec9.html) 73 | writeBody(rw, req, resource, http.StatusCreated) 74 | return 75 | } 76 | 77 | err = shaman.UpdateRecord(domain, &resource) 78 | if err != nil { 79 | writeBody(rw, req, apiError{err.Error()}, http.StatusInternalServerError) 80 | return 81 | } 82 | 83 | writeBody(rw, req, resource, http.StatusOK) 84 | } 85 | 86 | func getRecord(rw http.ResponseWriter, req *http.Request) { 87 | domain := req.URL.Query().Get(":domain") 88 | 89 | resource, err := shaman.GetRecord(domain) 90 | if err != nil { 91 | writeBody(rw, req, apiError{fmt.Sprintf("failed to find record for domain - '%v'", domain)}, http.StatusNotFound) 92 | return 93 | } 94 | 95 | writeBody(rw, req, resource, http.StatusOK) 96 | } 97 | 98 | func deleteRecord(rw http.ResponseWriter, req *http.Request) { 99 | domain := req.URL.Query().Get(":domain") 100 | 101 | err := shaman.DeleteRecord(domain) 102 | if err != nil { 103 | writeBody(rw, req, apiError{err.Error()}, http.StatusInternalServerError) 104 | return 105 | } 106 | 107 | writeBody(rw, req, apiMsg{"success"}, http.StatusOK) 108 | } 109 | -------------------------------------------------------------------------------- /cache/consul.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "bytes" 5 | "encoding/gob" 6 | "fmt" 7 | "net/url" 8 | 9 | "github.com/nanopack/shaman/config" 10 | 11 | consul "github.com/hashicorp/consul/api" 12 | shaman "github.com/nanopack/shaman/core/common" 13 | ) 14 | 15 | const prefix = "domains:" 16 | 17 | type consulDb struct { 18 | db *consul.Client 19 | } 20 | 21 | func addPrefix(in string) string { 22 | return prefix + in 23 | } 24 | 25 | func (client *consulDb) initialize() error { 26 | u, err := url.Parse(config.L2Connect) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | consulConfig := consul.DefaultNonPooledConfig() 32 | consulConfig.Address = u.Host 33 | consulConfig.Scheme = "http" 34 | consulC, err := consul.NewClient(consulConfig) 35 | if err != nil { 36 | return err 37 | } 38 | client.db = consulC 39 | return nil 40 | } 41 | 42 | func (client consulDb) addRecord(resource shaman.Resource) error { 43 | return client.updateRecord(resource.Domain, resource) 44 | } 45 | 46 | func (client consulDb) getRecord(domain string) (*shaman.Resource, error) { 47 | kvHandler := client.db.KV() 48 | kvPair, _, err := kvHandler.Get(addPrefix(domain), nil) 49 | if err != nil { 50 | return nil, err 51 | } 52 | if kvPair == nil { 53 | return nil, errNoRecordError 54 | } 55 | var result shaman.Resource 56 | err = gob.NewDecoder(bytes.NewReader(kvPair.Value)).Decode(&result) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | return &result, nil 62 | } 63 | 64 | func (client consulDb) updateRecord(domain string, resource shaman.Resource) error { 65 | kvHandler := client.db.KV() 66 | var buf bytes.Buffer 67 | err := gob.NewEncoder(&buf).Encode(&resource) 68 | if err != nil { 69 | return err 70 | } 71 | 72 | _, err = kvHandler.Put(&consul.KVPair{ 73 | Key: addPrefix(domain), 74 | Value: buf.Bytes(), 75 | }, nil) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | return nil 81 | } 82 | 83 | func (client consulDb) deleteRecord(domain string) error { 84 | kvHandler := client.db.KV() 85 | _, err := kvHandler.Delete(addPrefix(domain), nil) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (client consulDb) resetRecords(resources []shaman.Resource) error { 94 | kvHandler := client.db.KV() 95 | _, err := kvHandler.DeleteTree(prefix, nil) 96 | if err != nil { 97 | return err 98 | } 99 | 100 | for i := range resources { 101 | err = client.addRecord(resources[i]) // prevents duplicates 102 | if err != nil { 103 | return fmt.Errorf("Failed to save records - %v", err) 104 | } 105 | } 106 | return nil 107 | } 108 | 109 | func (client consulDb) listRecords() ([]shaman.Resource, error) { 110 | kvHandler := client.db.KV() 111 | kvPairs, _, err := kvHandler.List(prefix, nil) 112 | if err != nil { 113 | return nil, err 114 | } 115 | 116 | result := []shaman.Resource{} 117 | for _, kvPair := range kvPairs { 118 | var resource shaman.Resource 119 | err := gob.NewDecoder(bytes.NewReader(kvPair.Value)).Decode(&resource) 120 | if err != nil { 121 | return nil, err 122 | } 123 | result = append(result, resource) 124 | } 125 | 126 | return result, nil 127 | } 128 | -------------------------------------------------------------------------------- /cache/scribble.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/nanobox-io/golang-scribble" 10 | 11 | "github.com/nanopack/shaman/config" 12 | shaman "github.com/nanopack/shaman/core/common" 13 | ) 14 | 15 | type scribbleDb struct { 16 | db *scribble.Driver 17 | } 18 | 19 | func (self *scribbleDb) initialize() error { 20 | u, err := url.Parse(config.L2Connect) 21 | if err != nil { 22 | return fmt.Errorf("Failed to parse 'l2-connect' - %v", err) 23 | } 24 | dir := u.Path 25 | if dir == "" || dir == "/" { 26 | config.Log.Debug("Invalid directory, using default '/var/db/shaman'") 27 | dir = "/var/db/shaman" 28 | } 29 | db, err := scribble.New(dir, nil) 30 | if err != nil { 31 | config.Log.Fatal("Failed to create db") 32 | return fmt.Errorf("Failed to create new db at '%v' - %v", dir, err) 33 | } 34 | 35 | self.db = db 36 | return nil 37 | } 38 | 39 | func (self scribbleDb) addRecord(resource shaman.Resource) error { 40 | err := self.db.Write("hosts", resource.Domain, resource) 41 | if err != nil { 42 | err = fmt.Errorf("Failed to save record - %v", err) 43 | } 44 | return err 45 | } 46 | 47 | func (self scribbleDb) getRecord(domain string) (*shaman.Resource, error) { 48 | resource := shaman.Resource{} 49 | err := self.db.Read("hosts", domain, &resource) 50 | if err != nil { 51 | if strings.Contains(err.Error(), "no such file or directory") { 52 | err = errNoRecordError 53 | } 54 | return nil, err 55 | } 56 | return &resource, nil 57 | } 58 | 59 | func (self scribbleDb) updateRecord(domain string, resource shaman.Resource) error { 60 | if domain != resource.Domain { 61 | err := self.deleteRecord(domain) 62 | if err != nil { 63 | return fmt.Errorf("Failed to clear current record - %v", err) 64 | } 65 | } 66 | 67 | return self.addRecord(resource) 68 | } 69 | 70 | func (self scribbleDb) deleteRecord(domain string) error { 71 | err := self.db.Delete("hosts", domain) 72 | if err != nil { 73 | if strings.Contains(err.Error(), "Unable to find") { 74 | err = nil 75 | } else { 76 | err = fmt.Errorf("Failed to delete record - %v", err) 77 | } 78 | } 79 | return err 80 | } 81 | 82 | func (self scribbleDb) resetRecords(resources []shaman.Resource) (err error) { 83 | self.db.Delete("hosts", "") 84 | for i := range resources { 85 | err = self.db.Write("hosts", resources[i].Domain, resources[i]) 86 | if err != nil { 87 | err = fmt.Errorf("Failed to save records - %v", err) 88 | } 89 | } 90 | return err 91 | } 92 | 93 | func (self scribbleDb) listRecords() ([]shaman.Resource, error) { 94 | resources := make([]shaman.Resource, 0) 95 | values, err := self.db.ReadAll("hosts") 96 | if err != nil { 97 | if strings.Contains(err.Error(), "no such file or directory") { 98 | // if error is about a missing db, return empty array 99 | return resources, nil 100 | } 101 | return nil, err 102 | } 103 | for i := range values { 104 | var resource shaman.Resource 105 | if err = json.Unmarshal([]byte(values[i]), &resource); err != nil { 106 | return nil, fmt.Errorf("Bad JSON syntax found in stored body") 107 | } 108 | resources = append(resources, resource) 109 | } 110 | return resources, nil 111 | } 112 | -------------------------------------------------------------------------------- /api/api.go: -------------------------------------------------------------------------------- 1 | // Package api provides a restful interface to manage entries in the DNS database. 2 | package api 3 | 4 | import ( 5 | "crypto/tls" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io/ioutil" 10 | "net/http" 11 | 12 | "github.com/gorilla/pat" 13 | nanoauth "github.com/nanobox-io/golang-nanoauth" 14 | 15 | "github.com/nanopack/shaman/config" 16 | ) 17 | 18 | type ( 19 | apiError struct { 20 | ErrorString string `json:"err"` 21 | } 22 | apiMsg struct { 23 | MsgString string `json:"msg"` 24 | } 25 | ) 26 | 27 | var ( 28 | auth nanoauth.Auth 29 | errBadJson = errors.New("Bad JSON syntax received in body") 30 | errBodyReadFail = errors.New("Body Read Failed") 31 | ) 32 | 33 | // Start starts shaman's http api 34 | func Start() error { 35 | auth.Header = "X-AUTH-TOKEN" 36 | 37 | // handle config.Insecure 38 | if config.Insecure { 39 | config.Log.Info("Shaman listening at http://%s...", config.ApiListen) 40 | return fmt.Errorf("API stopped - %v", auth.ListenAndServe(config.ApiListen, config.ApiToken, routes())) 41 | } 42 | 43 | var cert *tls.Certificate 44 | var err error 45 | if config.ApiCrt == "" { 46 | cert, err = nanoauth.Generate(config.ApiDomain) 47 | } else { 48 | cert, err = nanoauth.Load(config.ApiCrt, config.ApiKey, config.ApiKeyPassword) 49 | } 50 | if err != nil { 51 | return fmt.Errorf("Failed to generate or load cert - %s", err.Error()) 52 | } 53 | 54 | auth.Certificate = cert 55 | 56 | config.Log.Info("Shaman listening at https://%v", config.ApiListen) 57 | 58 | return fmt.Errorf("API stopped - %v", auth.ListenAndServeTLS(config.ApiListen, config.ApiToken, routes())) 59 | } 60 | 61 | func routes() *pat.Router { 62 | router := pat.New() 63 | 64 | router.Delete("/records/{domain}", deleteRecord) // delete resource 65 | router.Put("/records/{domain}", updateRecord) // reset resource's records 66 | router.Get("/records/{domain}", getRecord) // return resource's records 67 | 68 | router.Post("/records", createRecord) // add a resource 69 | router.Get("/records", listRecords) // return all domains 70 | router.Put("/records", updateAnswers) // reset all resources 71 | 72 | return router 73 | } 74 | 75 | func writeBody(rw http.ResponseWriter, req *http.Request, v interface{}, status int) error { 76 | b, err := json.Marshal(v) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | // print the error only if there is one 82 | var msg map[string]string 83 | json.Unmarshal(b, &msg) 84 | 85 | var errMsg string 86 | if msg["error"] != "" { 87 | errMsg = msg["error"] 88 | } 89 | 90 | config.Log.Debug("%s %d %s %s %s", req.RemoteAddr, status, req.Method, req.RequestURI, errMsg) 91 | 92 | rw.Header().Set("Content-Type", "application/json") 93 | rw.WriteHeader(status) 94 | rw.Write(append(b, byte('\n'))) 95 | 96 | return nil 97 | } 98 | 99 | // parseBody parses the json body into v 100 | func parseBody(req *http.Request, v interface{}) error { 101 | 102 | // read the body 103 | b, err := ioutil.ReadAll(req.Body) 104 | if err != nil { 105 | config.Log.Error(err.Error()) 106 | return errBodyReadFail 107 | } 108 | defer req.Body.Close() 109 | 110 | // parse body and store in v 111 | err = json.Unmarshal(b, v) 112 | if err != nil { 113 | return errBadJson 114 | } 115 | 116 | return nil 117 | } 118 | -------------------------------------------------------------------------------- /cache/cache.go: -------------------------------------------------------------------------------- 1 | // Package cache provides a pluggable backend for persistent record storage. 2 | package cache 3 | 4 | import ( 5 | "errors" 6 | "fmt" 7 | "net/url" 8 | 9 | "github.com/nanopack/shaman/config" 10 | shaman "github.com/nanopack/shaman/core/common" 11 | ) 12 | 13 | var ( 14 | storage cacher 15 | errNoRecordError = errors.New("No Record Found") 16 | ) 17 | 18 | // The cacher interface is what all the backends [will] implement 19 | type cacher interface { 20 | initialize() error 21 | addRecord(resource shaman.Resource) error 22 | getRecord(domain string) (*shaman.Resource, error) 23 | updateRecord(domain string, resource shaman.Resource) error 24 | deleteRecord(domain string) error 25 | resetRecords(resources []shaman.Resource) error 26 | listRecords() ([]shaman.Resource, error) 27 | } 28 | 29 | // Initialize sets default cacher and initialize it 30 | func Initialize() error { 31 | u, err := url.Parse(config.L2Connect) 32 | if err != nil { 33 | return fmt.Errorf("Failed to parse 'l2-connect' - %v", err) 34 | } 35 | 36 | switch u.Scheme { 37 | case "scribble": 38 | storage = &scribbleDb{} 39 | case "postgres": 40 | storage = &postgresDb{} 41 | case "postgresql": 42 | storage = &postgresDb{} 43 | case "consul": 44 | storage = &consulDb{} 45 | case "none": 46 | storage = nil 47 | default: 48 | storage = &scribbleDb{} 49 | } 50 | 51 | if storage != nil { 52 | err = storage.initialize() 53 | if err != nil { 54 | storage = nil 55 | config.Log.Info("Failed to initialize cache, turning off - %v", err) 56 | err = nil 57 | } 58 | } 59 | 60 | return err 61 | } 62 | 63 | // AddRecord adds a record to the persistent cache 64 | func AddRecord(resource *shaman.Resource) error { 65 | if storage == nil { 66 | return nil 67 | } 68 | resource.Validate() 69 | return storage.addRecord(*resource) 70 | } 71 | 72 | // GetRecord gets a record to the persistent cache 73 | func GetRecord(domain string) (*shaman.Resource, error) { 74 | if storage == nil { 75 | return nil, nil 76 | } 77 | 78 | shaman.SanitizeDomain(&domain) 79 | return storage.getRecord(domain) 80 | } 81 | 82 | // UpdateRecord updates a record in the persistent cache 83 | func UpdateRecord(domain string, resource *shaman.Resource) error { 84 | if storage == nil { 85 | return nil 86 | } 87 | shaman.SanitizeDomain(&domain) 88 | resource.Validate() 89 | return storage.updateRecord(domain, *resource) 90 | } 91 | 92 | // DeleteRecord removes a record from the persistent cache 93 | func DeleteRecord(domain string) error { 94 | if storage == nil { 95 | return nil 96 | } 97 | shaman.SanitizeDomain(&domain) 98 | return storage.deleteRecord(domain) 99 | } 100 | 101 | // ResetRecords replaces all records in the persistent cache 102 | func ResetRecords(resources *[]shaman.Resource) error { 103 | if storage == nil { 104 | return nil 105 | } 106 | for i := range *resources { 107 | (*resources)[i].Validate() 108 | } 109 | 110 | return storage.resetRecords(*resources) 111 | } 112 | 113 | // ListRecords lists all records in the persistent cache 114 | func ListRecords() ([]shaman.Resource, error) { 115 | if storage == nil { 116 | return make([]shaman.Resource, 0), nil 117 | } 118 | return storage.listRecords() 119 | } 120 | 121 | // Exists returns whether the default cacher exists 122 | func Exists() bool { 123 | return storage != nil 124 | } 125 | -------------------------------------------------------------------------------- /core/shaman_test.go: -------------------------------------------------------------------------------- 1 | package shaman_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "testing" 7 | 8 | "github.com/jcelliott/lumber" 9 | 10 | "github.com/nanopack/shaman/config" 11 | "github.com/nanopack/shaman/core" 12 | sham "github.com/nanopack/shaman/core/common" 13 | ) 14 | 15 | var ( 16 | nanopack = sham.Resource{Domain: "nanopack.io.", Records: []sham.Record{{Address: "127.0.0.1"}}} 17 | nanopack2 = sham.Resource{Domain: "nanopack.io.", Records: []sham.Record{{Address: "127.0.0.3"}}} 18 | nanobox = sham.Resource{Domain: "nanobox.io.", Records: []sham.Record{{Address: "127.0.0.2"}}} 19 | nanoBoth = []sham.Resource{nanopack, nanobox} 20 | ) 21 | 22 | func TestMain(m *testing.M) { 23 | shamanClear() 24 | // manually configure 25 | config.Log = lumber.NewConsoleLogger(lumber.LvlInt("FATAL")) 26 | 27 | // run tests 28 | rtn := m.Run() 29 | 30 | os.Exit(rtn) 31 | } 32 | 33 | func TestAddRecord(t *testing.T) { 34 | shamanClear() 35 | err := shaman.AddRecord(&nanopack) 36 | err = shaman.AddRecord(&nanopack) 37 | err2 := shaman.AddRecord(&nanopack2) 38 | if err != nil || err2 != nil { 39 | t.Errorf("Failed to add record - %v%v", err, err2) 40 | } 41 | } 42 | 43 | func TestGetRecord(t *testing.T) { 44 | shamanClear() 45 | _, err := shaman.GetRecord("nanopack.io") 46 | shaman.AddRecord(&nanopack) 47 | _, err2 := shaman.GetRecord("nanopack.io") 48 | if err == nil || err2 != nil { 49 | // t.Errorf("Failed to get record - %v%v", err, "hi") 50 | t.Errorf("Failed to get record - %v%v", err, err2) 51 | } 52 | } 53 | 54 | func TestUpdateRecord(t *testing.T) { 55 | shamanClear() 56 | err := shaman.UpdateRecord("nanopack.io", &nanopack) 57 | err2 := shaman.UpdateRecord("nanobox.io", &nanopack) 58 | if err != nil || err2 != nil { 59 | t.Errorf("Failed to update record - %v%v", err, err2) 60 | } 61 | } 62 | 63 | func TestDeleteRecord(t *testing.T) { 64 | shamanClear() 65 | err := shaman.DeleteRecord("nanobox.io") 66 | shaman.AddRecord(&nanopack) 67 | err2 := shaman.DeleteRecord("nanopack.io") 68 | if err != nil || err2 != nil { 69 | t.Errorf("Failed to delete record - %v%v", err, err2) 70 | } 71 | } 72 | 73 | func TestResetRecords(t *testing.T) { 74 | shamanClear() 75 | err := shaman.ResetRecords(&nanoBoth) 76 | err2 := shaman.ResetRecords(&nanoBoth, true) 77 | if err != nil || err2 != nil { 78 | t.Errorf("Failed to reset records - %v%v", err, err2) 79 | } 80 | } 81 | 82 | func TestListDomains(t *testing.T) { 83 | shamanClear() 84 | domains := shaman.ListDomains() 85 | if fmt.Sprint(domains) != "[]" { 86 | t.Errorf("Failed to list domains - %+q", domains) 87 | } 88 | shaman.ResetRecords(&nanoBoth) 89 | domains = shaman.ListDomains() 90 | if len(domains) != 2 { 91 | t.Errorf("Failed to list domains - %+q", domains) 92 | } 93 | } 94 | 95 | func TestListRecords(t *testing.T) { 96 | shamanClear() 97 | resources := shaman.ListRecords() 98 | if fmt.Sprint(resources) != "[]" { 99 | t.Errorf("Failed to list records - %+q", resources) 100 | } 101 | shaman.ResetRecords(&nanoBoth) 102 | resources = shaman.ListRecords() 103 | if len(resources) == 2 && (resources[0].Domain != "nanopack.io." && resources[0].Domain != "nanobox.io.") { 104 | t.Errorf("Failed to list records - %+q", resources) 105 | } 106 | } 107 | 108 | func TestExists(t *testing.T) { 109 | shamanClear() 110 | if shaman.Exists("nanopack.io") { 111 | t.Errorf("Failed to list records") 112 | } 113 | shaman.AddRecord(&nanopack) 114 | if !shaman.Exists("nanopack.io") { 115 | t.Errorf("Failed to list records") 116 | } 117 | } 118 | 119 | func shamanClear() { 120 | shaman.Answers = make(map[string]sham.Resource, 0) 121 | } 122 | -------------------------------------------------------------------------------- /commands/README.md: -------------------------------------------------------------------------------- 1 | [![shaman logo](http://nano-assets.gopagoda.io/readme-headers/shaman.png)](http://nanobox.io/open-source#shaman) 2 | [![Build Status](https://travis-ci.org/nanopack/shaman.svg)](https://travis-ci.org/nanopack/shaman) 3 | 4 | # Shaman 5 | 6 | Small, lightweight, api-driven dns server. 7 | 8 | ## CLI Commands: 9 | 10 | ``` 11 | shaman - api driven dns server 12 | 13 | Usage: 14 | shaman [flags] 15 | shaman [command] 16 | 17 | Available Commands: 18 | add Add a domain to shaman 19 | delete Remove a domain from shaman 20 | list List all domains in shaman 21 | get Get records for a domain 22 | update Update records for a domain 23 | reset Reset all domains in shaman 24 | 25 | Flags: 26 | -C, --api-crt string Path to SSL crt for API access 27 | -k, --api-key string Path to SSL key for API access 28 | -p, --api-key-password string Password for SSL key 29 | -H, --api-listen string Listen address for the API (ip:port) (default "127.0.0.1:1632") 30 | -c, --config-file string Configuration file to load 31 | -O, --dns-listen string Listen address for DNS requests (ip:port) (default "127.0.0.1:53") 32 | -d, --domain string Parent domain for requests (default ".") 33 | -i, --insecure Disable tls key checking (client) and listen on http (api). Also disables auth-token 34 | -2, --l2-connect string Connection string for the l2 cache (default "scribble:///var/db/shaman") 35 | -l, --log-level string Log level to output [fatal|error|info|debug|trace] (default "INFO") 36 | -s, --server Run in server mode 37 | -t, --token string Token for API Access (default "secret") 38 | -T, --ttl int Default TTL for DNS records (default 60) 39 | -v, --version Print version info and exit 40 | 41 | Use "shaman [command] --help" for more information about a command. 42 | ``` 43 | 44 | ## Server Usage Example: 45 | ``` 46 | $ shaman --server 47 | ``` 48 | or 49 | ``` 50 | $ shaman -c config.json 51 | ``` 52 | 53 | >config.json 54 | >```json 55 | { 56 | "api-crt": "", 57 | "api-key": "", 58 | "api-key-password": "", 59 | "api-listen": "127.0.0.1:1632", 60 | "token": "secret", 61 | "insecure": false, 62 | "l2-connect": "scribble:///var/db/shaman", 63 | "ttl": 60, 64 | "domain": ".", 65 | "dns-listen": "127.0.0.1:53", 66 | "log-level": "info", 67 | "server": true 68 | } 69 | ``` 70 | 71 | ## Client Usage Example: 72 | 73 | #### add records 74 | 75 | ```sh 76 | $ shaman -i add -d nanopack.io -A 127.0.0.1 77 | # {"domain":"nanopack.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.1"}]} 78 | 79 | $ shaman -i add -j '{"domain":"nanopack.io","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]}' 80 | # {"domain":"nanopack.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"},{"ttl":60,"class":"IN","type":"A","address":"127.0.0.1"}]} 81 | ``` 82 | 83 | #### delete record 84 | 85 | ```sh 86 | $ shaman -i delete -d nanobox.io 87 | # {"msg":"success"} 88 | ``` 89 | 90 | #### update record 91 | 92 | ```sh 93 | $ shaman -i update -d nanopack.io -A 127.0.0.2 94 | # {"domain":"nanopack.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]} 95 | ``` 96 | 97 | #### get record 98 | 99 | ```sh 100 | $ shaman -i get -d nanopack.io 101 | # {"domain":"nanopack.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.2"}]} 102 | ``` 103 | 104 | #### reset records 105 | 106 | ```sh 107 | $ shaman -i reset -j '[{"domain":"nanobox.io", "records":[{"address":"127.0.0.5"}]}]' 108 | # [{"domain":"nanobox.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.5"}]}] 109 | ``` 110 | 111 | #### list records 112 | 113 | ```sh 114 | $ shaman -i list 115 | # ["nanobox.io"] 116 | 117 | $ shaman -i list -f 118 | # [{"domain":"nanobox.io.","records":[{"ttl":60,"class":"IN","type":"A","address":"127.0.0.5"}]}] 119 | ``` 120 | 121 | [![oss logo](http://nano-assets.gopagoda.io/open-src/nanobox-open-src.png)](http://nanobox.io/open-source) 122 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Shaman is a small, clusterable, lightweight, api-driven dns server. 2 | // 3 | // Usage 4 | // 5 | // To start shaman as a server, simply run (with administrator privileges): 6 | // 7 | // shaman -s 8 | // 9 | // For more specific usage information, refer to the help doc `shaman -h`: 10 | // Usage: 11 | // shaman [flags] 12 | // shaman [command] 13 | // 14 | // Available Commands: 15 | // add Add a domain to shaman 16 | // delete Remove a domain from shaman 17 | // list List all domains in shaman 18 | // get Get records for a domain 19 | // update Update records for a domain 20 | // reset Reset all domains in shaman 21 | // 22 | // Flags: 23 | // -C, --api-crt string Path to SSL crt for API access 24 | // -k, --api-key string Path to SSL key for API access 25 | // -p, --api-key-password string Password for SSL key 26 | // -H, --api-listen string Listen address for the API (ip:port) (default "127.0.0.1:1632") 27 | // -c, --config-file string Configuration file to load 28 | // -O, --dns-listen string Listen address for DNS requests (ip:port) (default "127.0.0.1:53") 29 | // -d, --domain string Parent domain for requests (default ".") 30 | // -i, --insecure Disable tls key checking (client) and listen on http (api). Also disables auth-token 31 | // -2, --l2-connect string Connection string for the l2 cache (default "scribble:///var/db/shaman") 32 | // -l, --log-level string Log level to output [fatal|error|info|debug|trace] (default "INFO") 33 | // -s, --server Run in server mode 34 | // -t, --token string Token for API Access (default "secret") 35 | // -T, --ttl int Default TTL for DNS records (default 60) 36 | // -v, --version Print version info and exit 37 | // 38 | package main 39 | 40 | import ( 41 | "fmt" 42 | 43 | "github.com/jcelliott/lumber" 44 | "github.com/spf13/cobra" 45 | 46 | "github.com/nanopack/shaman/api" 47 | "github.com/nanopack/shaman/cache" 48 | "github.com/nanopack/shaman/commands" 49 | "github.com/nanopack/shaman/config" 50 | "github.com/nanopack/shaman/server" 51 | ) 52 | 53 | var ( 54 | // shaman provides the shaman cli/server functionality 55 | shamanTool = &cobra.Command{ 56 | Use: "shaman", 57 | Short: "shaman - api driven dns server", 58 | Long: ``, 59 | PersistentPreRunE: readConfig, 60 | PreRunE: preFlight, 61 | RunE: startShaman, 62 | SilenceErrors: true, 63 | SilenceUsage: true, 64 | } 65 | 66 | // shaman version information (populated by go linker) 67 | // -ldflags="-X main.version=${tag} -X main.commit=${commit}" 68 | version string 69 | commit string 70 | ) 71 | 72 | // add supported cli commands/flags 73 | func init() { 74 | shamanTool.AddCommand(commands.AddDomain) 75 | shamanTool.AddCommand(commands.DelDomain) 76 | shamanTool.AddCommand(commands.ListDomains) 77 | shamanTool.AddCommand(commands.GetDomain) 78 | shamanTool.AddCommand(commands.UpdateDomain) 79 | shamanTool.AddCommand(commands.ResetDomains) 80 | 81 | config.AddFlags(shamanTool) 82 | } 83 | 84 | func main() { 85 | shamanTool.Execute() 86 | } 87 | 88 | func readConfig(ccmd *cobra.Command, args []string) error { 89 | if err := config.LoadConfigFile(); err != nil { 90 | fmt.Printf("Error: %v\n", err) 91 | return err 92 | } 93 | return nil 94 | } 95 | 96 | func preFlight(ccmd *cobra.Command, args []string) error { 97 | if config.Version { 98 | fmt.Printf("shaman %s (%s)\n", version, commit) 99 | return fmt.Errorf("") 100 | } 101 | 102 | if !config.Server { 103 | ccmd.HelpFunc()(ccmd, args) 104 | return fmt.Errorf("") 105 | } 106 | return nil 107 | } 108 | 109 | func startShaman(ccmd *cobra.Command, args []string) error { 110 | config.Log = lumber.NewConsoleLogger(lumber.LvlInt(config.LogLevel)) 111 | 112 | // initialize cache 113 | err := cache.Initialize() 114 | if err != nil { 115 | config.Log.Fatal(err.Error()) 116 | return err 117 | } 118 | 119 | // make channel for errors 120 | errors := make(chan error) 121 | 122 | go func() { 123 | errors <- api.Start() 124 | }() 125 | go func() { 126 | errors <- server.Start() 127 | }() 128 | 129 | // break if any of them return an error (blocks exit) 130 | if err := <-errors; err != nil { 131 | config.Log.Fatal(err.Error()) 132 | } 133 | return err 134 | } 135 | -------------------------------------------------------------------------------- /server/dns.go: -------------------------------------------------------------------------------- 1 | // Package server contains logic to handle DNS requests. 2 | package server 3 | 4 | import ( 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/miekg/dns" 9 | 10 | "github.com/nanopack/shaman/config" 11 | "github.com/nanopack/shaman/core" 12 | sham "github.com/nanopack/shaman/core/common" 13 | ) 14 | 15 | // Start starts the DNS listener 16 | func Start() error { 17 | dns.HandleFunc(".", handlerFunc) 18 | udpListener := &dns.Server{Addr: config.DnsListen, Net: "udp"} 19 | config.Log.Info("DNS listening at udp://%v", config.DnsListen) 20 | return fmt.Errorf("DNS listener stopped - %v", udpListener.ListenAndServe()) 21 | } 22 | 23 | // handlerFunc receives requests, looks up the result and returns what is found. 24 | func handlerFunc(res dns.ResponseWriter, req *dns.Msg) { 25 | message := new(dns.Msg) 26 | switch req.Opcode { 27 | case dns.OpcodeQuery: 28 | message.SetReply(req) 29 | message.Compress = false 30 | message.Answer = make([]dns.RR, 0) 31 | 32 | for _, question := range message.Question { 33 | answers := answerQuestion(question.Qtype, strings.ToLower(question.Name)) 34 | if len(answers) > 0 { 35 | for i := range answers { 36 | message.Answer = append(message.Answer, answers[i]) 37 | } 38 | } else { 39 | // If there are no records, go back through and search for SOA records 40 | for _, question := range message.Question { 41 | answers := answerQuestion(dns.TypeSOA, strings.ToLower(question.Name)) 42 | for i := range answers { 43 | message.Ns = append(message.Ns, answers[i]) 44 | } 45 | } 46 | } 47 | } 48 | if len(message.Answer) == 0 && len(message.Ns) == 0 { 49 | message.Rcode = dns.RcodeNameError 50 | } 51 | default: 52 | message = message.SetRcode(req, dns.RcodeNotImplemented) 53 | } 54 | res.WriteMsg(message) 55 | } 56 | 57 | // answerQuestion returns resource record answers for the domain in question 58 | func answerQuestion(qtype uint16, name ...string) []dns.RR { 59 | answers := make([]dns.RR, 0) 60 | qName := name[len(name)-1] // either `len` every time, or use var 61 | 62 | // get the resource (check memory, cache, and upstream) 63 | r, err := shaman.GetRecord(qName) 64 | if err != nil { 65 | // fetch from fallback server if fallback dns server is provided 66 | if config.DnsFallBack != "" { 67 | config.Log.Trace("Getting records for '%s' from fallback dns server '%s'", qName, config.DnsFallBack) 68 | if resource, err := getAnswerFromFallBackServer(qName, config.DnsFallBack); err != nil { 69 | config.Log.Trace("Failed to get records for '%s' from fallback dns server - %v", qName, err) 70 | } else { 71 | r = resource 72 | } 73 | } else { 74 | config.Log.Trace("Failed to get records for '%s' - %v", qName, err) 75 | } 76 | } 77 | 78 | // validate the records and append correct type to answers[] 79 | for _, record := range r.StringSlice() { 80 | entry, err := dns.NewRR(record) 81 | if err != nil { 82 | config.Log.Debug("Failed to create RR from record - %v", err) 83 | continue 84 | } 85 | entry.Header().Name = name[0] 86 | if entry.Header().Rrtype == qtype || qtype == dns.TypeANY { 87 | answers = append(answers, entry) 88 | } 89 | } 90 | 91 | // recursively resolve if no records found (essentially provides wildcard 92 | // registration support) 93 | if len(answers) == 0 { 94 | qName = stripSubdomain(qName) 95 | if len(qName) > 0 { 96 | config.Log.Trace("Checking again with '%v'", qName) 97 | return answerQuestion(qtype, name[0], qName) 98 | } 99 | } 100 | 101 | return answers 102 | } 103 | 104 | // stripSubdomain strips off the subbest domain, returning the domain (won't return TLD) 105 | func stripSubdomain(name string) string { 106 | words := 3 // assume rooted domain (end with '.') 107 | // handle edge case of unrooted domain 108 | t := []byte(name) 109 | if len(t) > 0 && t[len(t)-1] != '.' { 110 | words = 2 111 | } 112 | 113 | config.Log.Trace("Stripping subdomain from '%v'", name) 114 | names := strings.Split(name, ".") 115 | 116 | // prevent searching for just 'com.' (["domain", "com", ""]) 117 | if len(names) > words { 118 | return strings.Join(names[1:], ".") 119 | } 120 | return "" 121 | } 122 | 123 | // getAnswerFromFallBackServer gets record from the fallback dns server 124 | func getAnswerFromFallBackServer(qName string, fallBackServer string) (sham.Resource, error) { 125 | resource := sham.Resource{} 126 | records := []sham.Record{} 127 | 128 | c := new(dns.Client) 129 | m := new(dns.Msg) 130 | m.SetQuestion(dns.Fqdn(qName), dns.TypeA) 131 | m.RecursionDesired = true 132 | 133 | r, _, err := c.Exchange(m, fallBackServer) 134 | if err != nil { 135 | return resource, err 136 | } 137 | 138 | resource.Domain = qName 139 | for _, r1 := range r.Answer { 140 | record := sham.Record{} 141 | 142 | record.TTL = int(r1.Header().Ttl) 143 | record.RType = dns.Class(r1.Header().Class).String() 144 | record.RType = dns.Type(r1.Header().Rrtype).String() 145 | // for getting address 146 | data := strings.Split(r1.String(), "\t") 147 | record.Address = data[len(data)-1] 148 | 149 | records = append(records, record) 150 | } 151 | resource.Records = records 152 | return resource, nil 153 | } 154 | -------------------------------------------------------------------------------- /config/config.go: -------------------------------------------------------------------------------- 1 | // Package config is a central location for configuration options. It also contains 2 | // config file parsing logic. 3 | package config 4 | 5 | import ( 6 | "fmt" 7 | "path/filepath" 8 | 9 | "github.com/jcelliott/lumber" 10 | "github.com/spf13/cobra" 11 | "github.com/spf13/viper" 12 | ) 13 | 14 | var ( 15 | ApiDomain = "shaman.nanobox.io" // Domain for generating cert (if none passed) 16 | ApiCrt = "" // Path to SSL crt for API access 17 | ApiKey = "" // Path to SSL key for API access 18 | ApiKeyPassword = "" // Password for SSL key 19 | ApiListen = "127.0.0.1:1632" // Listen address for the API (ip:port) 20 | ApiToken = "secret" // Token for API Access 21 | Insecure = false // Disable tls key checking (client) and listen on http (server) 22 | L2Connect = "scribble:///var/db/shaman" // Connection string for the l2 cache 23 | TTL int = 60 // Default TTL for DNS records 24 | Domain = "." // Parent domain for requests 25 | DnsListen = "127.0.0.1:53" // Listen address for DNS requests (ip:port) 26 | DnsFallBack = "" // fallback dns server if record not found in cache, not used if empty 27 | 28 | LogLevel = "INFO" // Log level to output [fatal|error|info|debug|trace] 29 | Server = false // Run in server mode 30 | ConfigFile = "" // Configuration file to load 31 | Version = false // Print version info and exit 32 | 33 | Log lumber.Logger // Central logger for shaman 34 | ) 35 | 36 | // AddFlags adds the available cli flags 37 | func AddFlags(cmd *cobra.Command) { 38 | // api 39 | cmd.Flags().StringVarP(&ApiDomain, "api-domain", "a", ApiDomain, "Domain of generated cert (if none passed)") 40 | cmd.Flags().StringVarP(&ApiCrt, "api-crt", "C", ApiCrt, "Path to SSL crt for API access") 41 | cmd.Flags().StringVarP(&ApiKey, "api-key", "k", ApiKey, "Path to SSL key for API access") 42 | cmd.Flags().StringVarP(&ApiKeyPassword, "api-key-password", "p", ApiKeyPassword, "Password for SSL key") 43 | cmd.PersistentFlags().StringVarP(&ApiListen, "api-listen", "H", ApiListen, "Listen address for the API (ip:port)") 44 | cmd.PersistentFlags().StringVarP(&ApiToken, "token", "t", ApiToken, "Token for API Access") 45 | cmd.PersistentFlags().BoolVarP(&Insecure, "insecure", "i", Insecure, "Disable tls key checking (client) and listen on http (api). Also disables auth-token") 46 | 47 | // dns 48 | cmd.Flags().StringVarP(&L2Connect, "l2-connect", "2", L2Connect, "Connection string for the l2 cache") 49 | cmd.Flags().IntVarP(&TTL, "ttl", "T", TTL, "Default TTL for DNS records") 50 | cmd.Flags().StringVarP(&Domain, "domain", "d", Domain, "Parent domain for requests") 51 | cmd.Flags().StringVarP(&DnsListen, "dns-listen", "O", DnsListen, "Listen address for DNS requests (ip:port)") 52 | cmd.Flags().StringVarP(&DnsFallBack, "fallback-dns", "f", DnsFallBack, "Fallback dns server address (ip:port), if not specified fallback is not used") 53 | 54 | // core 55 | cmd.Flags().StringVarP(&LogLevel, "log-level", "l", LogLevel, "Log level to output [fatal|error|info|debug|trace]") 56 | cmd.Flags().BoolVarP(&Server, "server", "s", Server, "Run in server mode") 57 | cmd.PersistentFlags().StringVarP(&ConfigFile, "config-file", "c", ConfigFile, "Configuration file to load") 58 | 59 | cmd.Flags().BoolVarP(&Version, "version", "v", Version, "Print version info and exit") 60 | } 61 | 62 | // LoadConfigFile reads the specified config file 63 | func LoadConfigFile() error { 64 | if ConfigFile == "" { 65 | return nil 66 | } 67 | 68 | // Set defaults to whatever might be there already 69 | viper.SetDefault("api-domain", ApiDomain) 70 | viper.SetDefault("api-crt", ApiCrt) 71 | viper.SetDefault("api-key", ApiKey) 72 | viper.SetDefault("api-key-password", ApiKeyPassword) 73 | viper.SetDefault("api-listen", ApiListen) 74 | viper.SetDefault("token", ApiToken) 75 | viper.SetDefault("insecure", Insecure) 76 | viper.SetDefault("l2-connect", L2Connect) 77 | viper.SetDefault("ttl", TTL) 78 | viper.SetDefault("domain", Domain) 79 | viper.SetDefault("dns-listen", DnsListen) 80 | viper.SetDefault("log-level", LogLevel) 81 | viper.SetDefault("server", Server) 82 | viper.SetDefault("fallback-dns", DnsFallBack) 83 | 84 | filename := filepath.Base(ConfigFile) 85 | viper.SetConfigName(filename[:len(filename)-len(filepath.Ext(filename))]) 86 | viper.AddConfigPath(filepath.Dir(ConfigFile)) 87 | 88 | err := viper.ReadInConfig() 89 | if err != nil { 90 | return fmt.Errorf("Failed to read config file - %v", err) 91 | } 92 | 93 | // Set values. Config file will override commandline 94 | ApiDomain = viper.GetString("api-domain") 95 | ApiCrt = viper.GetString("api-crt") 96 | ApiKey = viper.GetString("api-key") 97 | ApiKeyPassword = viper.GetString("api-key-password") 98 | ApiListen = viper.GetString("api-listen") 99 | ApiToken = viper.GetString("token") 100 | Insecure = viper.GetBool("insecure") 101 | L2Connect = viper.GetString("l2-connect") 102 | TTL = viper.GetInt("ttl") 103 | Domain = viper.GetString("domain") 104 | DnsListen = viper.GetString("dns-listen") 105 | LogLevel = viper.GetString("log-level") 106 | Server = viper.GetBool("server") 107 | 108 | return nil 109 | } 110 | -------------------------------------------------------------------------------- /core/shaman.go: -------------------------------------------------------------------------------- 1 | // Package shaman contains the logic to add/remove DNS entries. 2 | package shaman 3 | 4 | // todo: atomic C.U.D. 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/nanopack/shaman/cache" 10 | "github.com/nanopack/shaman/config" 11 | sham "github.com/nanopack/shaman/core/common" 12 | ) 13 | 14 | // Answers is the cached collection of dns records 15 | var Answers map[string]sham.Resource 16 | 17 | func init() { 18 | Answers = make(map[string]sham.Resource, 0) 19 | } 20 | 21 | // GetRecord returns a resource for the specified domain 22 | func GetRecord(domain string) (sham.Resource, error) { 23 | sham.SanitizeDomain(&domain) 24 | 25 | resource, ok := Answers[domain] 26 | // if domain not cached in memory... 27 | if !ok { 28 | // fetch from cache 29 | record, err := cache.GetRecord(domain) 30 | if record == nil { 31 | return resource, fmt.Errorf("Failed to find domain - %v", err) 32 | } 33 | // update local cache 34 | config.Log.Debug("Cache differs from local, updating...") 35 | Answers[domain] = *record 36 | } 37 | 38 | return Answers[domain], nil 39 | } 40 | 41 | // ListDomains returns a list of all known domains 42 | func ListDomains() []string { 43 | domains := make([]string, 0) 44 | 45 | for _, record := range ListRecords() { 46 | sham.UnsanitizeDomain(&record.Domain) 47 | domains = append(domains, record.Domain) 48 | } 49 | 50 | return domains 51 | } 52 | 53 | // ListRecords returns all known domains 54 | func ListRecords() []sham.Resource { 55 | if cache.Exists() { 56 | // get from cache 57 | stored, _ := cache.ListRecords() 58 | if len(Answers) != len(stored) { 59 | config.Log.Debug("Cache differs from local, updating...") 60 | ResetRecords(&stored, true) 61 | } 62 | } 63 | 64 | resources := make([]sham.Resource, 0) 65 | for _, v := range Answers { 66 | resources = append(resources, v) 67 | } 68 | 69 | return resources 70 | } 71 | 72 | // DeleteRecord deletes the resource(domain) 73 | func DeleteRecord(domain string) error { 74 | sham.SanitizeDomain(&domain) 75 | 76 | // update cache 77 | config.Log.Trace("Removing record from persistent cache...") 78 | err := cache.DeleteRecord(domain) 79 | if err != nil { 80 | return err 81 | } 82 | 83 | // todo: atomic 84 | delete(Answers, domain) 85 | 86 | // otherwise, be idempotent and report it was deleted... 87 | return nil 88 | } 89 | 90 | // AddRecord adds a record to a resource(domain) 91 | func AddRecord(resource *sham.Resource) error { 92 | resource.Validate() 93 | domain := resource.Domain 94 | 95 | // todo: atomic 96 | _, ok := Answers[domain] 97 | if ok { 98 | config.Log.Trace("Domain is in local cache") 99 | // if we have the domain registered... 100 | for k := range Answers[domain].Records { 101 | for j := range resource.Records { 102 | // check if the record exists... 103 | if resource.Records[j].RType == Answers[domain].Records[k].RType && 104 | resource.Records[j].Address == Answers[domain].Records[k].Address && 105 | resource.Records[j].Class == Answers[domain].Records[k].Class { 106 | // if so, skip... 107 | config.Log.Trace("Record exists in local cache, skipping...") 108 | goto next 109 | } 110 | } 111 | // otherwise, add the record 112 | config.Log.Trace("Record not in local cache, adding...") 113 | resource.Records = append(resource.Records, Answers[domain].Records[k]) 114 | next: 115 | } 116 | } 117 | 118 | // store in cache 119 | config.Log.Trace("Saving record to persistent cache...") 120 | err := cache.AddRecord(resource) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | // add the resource to the list of knowns 126 | Answers[domain] = *resource 127 | 128 | return nil 129 | } 130 | 131 | // Exists returns whether or not that domain exists 132 | func Exists(domain string) bool { 133 | sham.SanitizeDomain(&domain) 134 | _, ok := Answers[domain] 135 | return ok 136 | } 137 | 138 | // UpdateRecord updates a record to a resource(domain) 139 | func UpdateRecord(domain string, resource *sham.Resource) error { 140 | resource.Validate() 141 | sham.SanitizeDomain(&domain) 142 | 143 | // in case of some update to domain name... 144 | if domain != resource.Domain { 145 | // delete old domain 146 | err := DeleteRecord(domain) 147 | if err != nil { 148 | return fmt.Errorf("Failed to clean up old domain - %v", err) 149 | } 150 | } 151 | 152 | // store in cache 153 | config.Log.Trace("Updating record in persistent cache...") 154 | err := cache.UpdateRecord(domain, resource) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | // set new resource to domain 160 | // todo: atomic 161 | Answers[resource.Domain] = *resource 162 | 163 | return nil 164 | } 165 | 166 | // ResetRecords resets all answers. If any nocache has any values, caching is skipped 167 | func ResetRecords(resources *[]sham.Resource, nocache ...bool) error { 168 | for i := range *resources { 169 | (*resources)[i].Validate() 170 | } 171 | 172 | // new map to clear current answers 173 | answers := make(map[string]sham.Resource) 174 | 175 | for i := range *resources { 176 | answers[(*resources)[i].Domain] = (*resources)[i] 177 | } 178 | 179 | if len(nocache) == 0 { 180 | // store in cache 181 | config.Log.Trace("Resetting records in persistent cache...") 182 | err := cache.ResetRecords(resources) 183 | if err != nil { 184 | return err 185 | } 186 | } 187 | 188 | // reset the answers 189 | // todo: atomic 190 | Answers = answers 191 | 192 | return nil 193 | } 194 | -------------------------------------------------------------------------------- /commands/commands_test.go: -------------------------------------------------------------------------------- 1 | package commands_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "strings" 7 | "testing" 8 | "time" 9 | 10 | "github.com/jcelliott/lumber" 11 | "github.com/spf13/cobra" 12 | 13 | "github.com/nanopack/shaman/api" 14 | "github.com/nanopack/shaman/commands" 15 | "github.com/nanopack/shaman/config" 16 | ) 17 | 18 | func init() { 19 | shamanTool.AddCommand(commands.AddDomain) 20 | shamanTool.AddCommand(commands.DelDomain) 21 | shamanTool.AddCommand(commands.ListDomains) 22 | shamanTool.AddCommand(commands.GetDomain) 23 | shamanTool.AddCommand(commands.UpdateDomain) 24 | shamanTool.AddCommand(commands.ResetDomains) 25 | 26 | config.AddFlags(shamanTool) 27 | } 28 | 29 | type ( 30 | execable func() error // cobra.Command.Execute() 'alias' 31 | ) 32 | 33 | var shamanTool = &cobra.Command{ 34 | Use: "shaman", 35 | Short: "shaman - api driven dns server", 36 | Long: ``, 37 | 38 | Run: startShaman, 39 | } 40 | 41 | func startShaman(ccmd *cobra.Command, args []string) { 42 | return 43 | } 44 | 45 | func TestMain(m *testing.M) { 46 | // manually configure 47 | initialize() 48 | 49 | // start api 50 | go api.Start() 51 | <-time.After(time.Second) 52 | rtn := m.Run() 53 | 54 | os.Exit(rtn) 55 | } 56 | 57 | func TestAddRecord(t *testing.T) { 58 | commands.ResetVars() 59 | 60 | args := strings.Split("add -d nanobox.io -A 127.0.0.1", " ") 61 | shamanTool.SetArgs(args) 62 | 63 | out, err := capture(shamanTool.Execute) 64 | if err != nil { 65 | t.Errorf("Failed to execute - %v", err.Error()) 66 | } 67 | 68 | if string(out) != "{\"domain\":\"nanobox.io.\",\"records\":[{\"ttl\":60,\"class\":\"IN\",\"type\":\"A\",\"address\":\"127.0.0.1\"}]}\n" { 69 | t.Errorf("Unexpected output: %+q", string(out)) 70 | } 71 | } 72 | 73 | func TestListRecords(t *testing.T) { 74 | commands.ResetVars() 75 | 76 | args := strings.Split("list", " ") 77 | shamanTool.SetArgs(args) 78 | 79 | out, err := capture(shamanTool.Execute) 80 | if err != nil { 81 | t.Errorf("Failed to execute - %v", err.Error()) 82 | } 83 | 84 | if string(out) != "[\"nanobox.io\"]\n" { 85 | t.Errorf("Unexpected output: %+q", string(out)) 86 | } 87 | 88 | args = strings.Split("list -f", " ") 89 | shamanTool.SetArgs(args) 90 | 91 | out, err = capture(shamanTool.Execute) 92 | if err != nil { 93 | t.Errorf("Failed to execute - %v", err.Error()) 94 | } 95 | 96 | if string(out) != "[{\"domain\":\"nanobox.io.\",\"records\":[{\"ttl\":60,\"class\":\"IN\",\"type\":\"A\",\"address\":\"127.0.0.1\"}]}]\n" { 97 | t.Errorf("Unexpected output: %+q", string(out)) 98 | } 99 | } 100 | 101 | func TestResetRecords(t *testing.T) { 102 | commands.ResetVars() 103 | 104 | args := strings.Split("reset -j [{\"domain\":\"nanopack.io\"}]", " ") 105 | shamanTool.SetArgs(args) 106 | 107 | out, err := capture(shamanTool.Execute) 108 | if err != nil { 109 | t.Errorf("Failed to execute - %v", err.Error()) 110 | } 111 | 112 | if string(out) != "[{\"domain\":\"nanopack.io.\",\"records\":null}]\n" { 113 | t.Errorf("Unexpected output: %+q", string(out)) 114 | } 115 | 116 | args = strings.Split("list", " ") 117 | shamanTool.SetArgs(args) 118 | 119 | out, err = capture(shamanTool.Execute) 120 | if err != nil { 121 | t.Errorf("Failed to execute - %v", err.Error()) 122 | } 123 | 124 | if string(out) != "[\"nanopack.io\"]\n" { 125 | t.Errorf("Unexpected output: %+q", string(out)) 126 | } 127 | } 128 | 129 | func TestUpdateRecord(t *testing.T) { 130 | commands.ResetVars() 131 | 132 | args := strings.Split("update -d nanopack.io -A 127.0.0.5", " ") 133 | shamanTool.SetArgs(args) 134 | 135 | out, err := capture(shamanTool.Execute) 136 | if err != nil { 137 | t.Errorf("Failed to execute - %v", err.Error()) 138 | } 139 | 140 | if string(out) != "{\"domain\":\"nanopack.io.\",\"records\":[{\"ttl\":60,\"class\":\"IN\",\"type\":\"A\",\"address\":\"127.0.0.5\"}]}\n" { 141 | t.Errorf("Unexpected output: %+q", string(out)) 142 | } 143 | 144 | args = strings.Split("list", " ") 145 | shamanTool.SetArgs(args) 146 | 147 | out, err = capture(shamanTool.Execute) 148 | if err != nil { 149 | t.Errorf("Failed to execute - %v", err.Error()) 150 | } 151 | 152 | if string(out) != "[\"nanopack.io\"]\n" { 153 | t.Errorf("Unexpected output: %+q", string(out)) 154 | } 155 | } 156 | 157 | func TestGetRecord(t *testing.T) { 158 | commands.ResetVars() 159 | 160 | args := strings.Split("get -d nanopack.io", " ") 161 | shamanTool.SetArgs(args) 162 | 163 | out, err := capture(shamanTool.Execute) 164 | if err != nil { 165 | t.Errorf("Failed to execute - %v", err.Error()) 166 | } 167 | 168 | if string(out) != "{\"domain\":\"nanopack.io.\",\"records\":[{\"ttl\":60,\"class\":\"IN\",\"type\":\"A\",\"address\":\"127.0.0.5\"}]}\n" { 169 | t.Errorf("Unexpected output: %+q", string(out)) 170 | } 171 | } 172 | 173 | func TestDeleteRecord(t *testing.T) { 174 | commands.ResetVars() 175 | 176 | args := strings.Split("delete -d nanopack.io", " ") 177 | shamanTool.SetArgs(args) 178 | 179 | out, err := capture(shamanTool.Execute) 180 | if err != nil { 181 | t.Errorf("Failed to execute - %v", err.Error()) 182 | } 183 | 184 | if string(out) != "{\"msg\":\"success\"}\n" { 185 | t.Errorf("Unexpected output: %+q", string(out)) 186 | } 187 | } 188 | 189 | /////////////////////////////////////////////////// 190 | // PRIVS 191 | /////////////////////////////////////////////////// 192 | 193 | // function to capture output of cli 194 | func capture(fn execable) ([]byte, error) { 195 | oldStdout := os.Stdout 196 | r, w, _ := os.Pipe() 197 | os.Stdout = w 198 | 199 | err := fn() 200 | os.Stdout = oldStdout 201 | w.Close() // do not defer after os.Pipe() 202 | if err != nil { 203 | return nil, err 204 | } 205 | 206 | return ioutil.ReadAll(r) 207 | } 208 | 209 | // manually configure and start internals 210 | func initialize() { 211 | config.Insecure = true 212 | config.L2Connect = "none://" 213 | config.ApiListen = "127.0.0.1:1634" 214 | config.Log = lumber.NewConsoleLogger(lumber.LvlInt("FATAL")) 215 | config.LogLevel = "FATAL" 216 | } 217 | -------------------------------------------------------------------------------- /cache/postgres.go: -------------------------------------------------------------------------------- 1 | package cache 2 | 3 | import ( 4 | "database/sql" 5 | "fmt" 6 | 7 | _ "github.com/lib/pq" 8 | 9 | "github.com/nanopack/shaman/config" 10 | shaman "github.com/nanopack/shaman/core/common" 11 | ) 12 | 13 | type postgresDb struct { 14 | pg *sql.DB 15 | } 16 | 17 | func (p *postgresDb) connect() error { 18 | // todo: example: config.DatabaseConnection = "postgres://postgres@127.0.0.1?sslmode=disable" 19 | db, err := sql.Open("postgres", config.L2Connect) 20 | if err != nil { 21 | return fmt.Errorf("Failed to connect to postgres - %v", err) 22 | } 23 | err = db.Ping() 24 | if err != nil { 25 | return fmt.Errorf("Failed to ping postgres on connect - %v", err) 26 | } 27 | 28 | p.pg = db 29 | return nil 30 | } 31 | 32 | func (p postgresDb) createTables() error { 33 | // create records table 34 | _, err := p.pg.Exec(` 35 | CREATE TABLE IF NOT EXISTS records ( 36 | recordId SERIAL PRIMARY KEY NOT NULL, 37 | domain TEXT NOT NULL, 38 | address TEXT NOT NULL, 39 | ttl INTEGER, 40 | class TEXT, 41 | type TEXT 42 | )`) 43 | if err != nil { 44 | return fmt.Errorf("Failed to create records table - %v", err) 45 | } 46 | 47 | return nil 48 | } 49 | 50 | func (p *postgresDb) initialize() error { 51 | err := p.connect() 52 | if err != nil { 53 | return fmt.Errorf("Failed to create new connection - %v", err) 54 | } 55 | 56 | // create tables 57 | err = p.createTables() 58 | if err != nil { 59 | return fmt.Errorf("Failed to create tables - %v", err) 60 | } 61 | 62 | return nil 63 | } 64 | 65 | func (p postgresDb) addRecord(resource shaman.Resource) error { 66 | resources, err := p.listRecords() 67 | if err != nil { 68 | return err 69 | } 70 | 71 | for i := range resources { 72 | if resources[i].Domain == resource.Domain { 73 | // if domains match, check address 74 | for k := range resources[i].Records { 75 | next: 76 | for j := range resource.Records { 77 | // check if the record exists... 78 | if resource.Records[j].RType == resources[i].Records[k].RType && 79 | resource.Records[j].Address == resources[i].Records[k].Address && 80 | resource.Records[j].Class == resources[i].Records[k].Class { 81 | // if so, skip 82 | config.Log.Trace("Record exists in persistent, skipping...") 83 | resource.Records = append(resource.Records[:i], resource.Records[i+1:]...) 84 | goto next 85 | } 86 | } 87 | } 88 | } 89 | } 90 | 91 | // add records 92 | for i := range resource.Records { 93 | config.Log.Trace("Adding record to database...") 94 | _, err = p.pg.Exec(fmt.Sprintf(` 95 | INSERT INTO records(domain, address, ttl, class, type) 96 | VALUES('%v', '%v', '%v', '%v', '%v')`, 97 | resource.Domain, resource.Records[i].Address, resource.Records[i].TTL, 98 | resource.Records[i].Class, resource.Records[i].RType)) 99 | if err != nil { 100 | return fmt.Errorf("Failed to insert into records table - %v", err) 101 | } 102 | } 103 | 104 | return nil 105 | } 106 | 107 | func (p postgresDb) getRecord(domain string) (*shaman.Resource, error) { 108 | // read from records table 109 | rows, err := p.pg.Query(fmt.Sprintf("SELECT address, ttl, class, type FROM records WHERE domain = '%v'", domain)) 110 | if err != nil { 111 | return nil, fmt.Errorf("Failed to select from records table - %v", err) 112 | } 113 | defer rows.Close() 114 | 115 | records := make([]shaman.Record, 0, 0) 116 | 117 | // get data 118 | for rows.Next() { 119 | rcrd := shaman.Record{} 120 | err = rows.Scan(&rcrd.Address, &rcrd.TTL, &rcrd.Class, &rcrd.RType) 121 | if err != nil { 122 | return nil, fmt.Errorf("Failed to save results into record - %v", err) 123 | } 124 | 125 | records = append(records, rcrd) 126 | } 127 | 128 | // check for errors 129 | if err = rows.Err(); err != nil { 130 | return nil, fmt.Errorf("Error with results - %v", err) 131 | } 132 | 133 | if len(records) == 0 { 134 | return nil, errNoRecordError 135 | } 136 | 137 | return &shaman.Resource{Domain: domain, Records: records}, nil 138 | } 139 | 140 | func (p postgresDb) updateRecord(domain string, resource shaman.Resource) error { 141 | // delete old from records 142 | err := p.deleteRecord(domain) 143 | if err != nil { 144 | return fmt.Errorf("Failed to clean old records - %v", err) 145 | } 146 | 147 | return p.addRecord(resource) 148 | } 149 | 150 | func (p postgresDb) deleteRecord(domain string) error { 151 | _, err := p.pg.Exec(fmt.Sprintf(`DELETE FROM records WHERE domain = '%v'`, domain)) 152 | if err != nil { 153 | return fmt.Errorf("Failed to delete from records table - %v", err) 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (p postgresDb) resetRecords(resources []shaman.Resource) error { 160 | // truncate records table 161 | _, err := p.pg.Exec("TRUNCATE records") 162 | if err != nil { 163 | return fmt.Errorf("Failed to truncate records table - %v", err) 164 | } 165 | for i := range resources { 166 | err = p.addRecord(resources[i]) // prevents duplicates 167 | if err != nil { 168 | return fmt.Errorf("Failed to save records - %v", err) 169 | } 170 | } 171 | return nil 172 | } 173 | 174 | func (p postgresDb) listRecords() ([]shaman.Resource, error) { 175 | // read from records table 176 | rows, err := p.pg.Query("SELECT DISTINCT domain FROM records") 177 | if err != nil { 178 | return nil, fmt.Errorf("Failed to select from records table - %v", err) 179 | } 180 | defer rows.Close() 181 | 182 | resources := make([]shaman.Resource, 0) 183 | 184 | // get data 185 | for rows.Next() { 186 | var domain string 187 | err = rows.Scan(&domain) 188 | if err != nil { 189 | return nil, fmt.Errorf("Failed to save domain - %v", err) 190 | } 191 | resource, err := p.getRecord(domain) 192 | if err != nil { 193 | return nil, fmt.Errorf("Failed to get record for domain - %v", err) 194 | } 195 | 196 | resources = append(resources, *resource) 197 | } 198 | 199 | // check for errors 200 | if err = rows.Err(); err != nil { 201 | return nil, fmt.Errorf("Error with results - %v", err) 202 | } 203 | return resources, nil 204 | } 205 | -------------------------------------------------------------------------------- /api/api_test.go: -------------------------------------------------------------------------------- 1 | package api_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "encoding/json" 7 | "fmt" 8 | "io/ioutil" 9 | "net/http" 10 | "os" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/jcelliott/lumber" 16 | 17 | "github.com/nanopack/shaman/api" 18 | "github.com/nanopack/shaman/config" 19 | shaman "github.com/nanopack/shaman/core/common" 20 | ) 21 | 22 | var ( 23 | testResource1 = `{"domain":"google.com","records":[{"type":"A","address":"127.0.0.1"}]}` 24 | testResource2 = `{"domain":"google.com","records":[{"type":"A","address":"127.0.0.2"}]}` 25 | badResource = `{"domain":"google.com","records":[{"type":1,"address":"127.0.0.3"}]}` 26 | testResource3 = `{"domain":"foogle.com","records":[{"type":"A","address":"127.0.0.4"}]}` 27 | ) 28 | 29 | func TestMain(m *testing.M) { 30 | // manually configure 31 | initialize() 32 | 33 | // start api 34 | go api.Start() 35 | <-time.After(time.Second) 36 | rtn := m.Run() 37 | 38 | os.Exit(rtn) 39 | } 40 | 41 | // test put records 42 | func TestPutRecords(t *testing.T) { 43 | // good request test 44 | resp, _, err := rest("PUT", "/records", fmt.Sprintf("[%v]", testResource1)) 45 | if err != nil { 46 | t.Error(err) 47 | } 48 | 49 | var resources []shaman.Resource 50 | json.Unmarshal(resp, &resources) 51 | 52 | if len(resources) != 1 { 53 | t.Errorf("%q doesn't match expected out", resources) 54 | } 55 | 56 | if len(resources) == 1 && 57 | len(resources[0].Records) == 1 && 58 | resources[0].Records[0].Address != "127.0.0.1" { 59 | t.Errorf("%q doesn't match expected out", resources) 60 | } 61 | 62 | // bad request test 63 | resp, _, err = rest("PUT", "/records", testResource1) 64 | if err != nil { 65 | t.Error(err) 66 | } 67 | 68 | if !strings.Contains(string(resp), "Bad JSON syntax received in body") { 69 | t.Errorf("%q doesn't match expected out", resp) 70 | } 71 | 72 | // clear records 73 | rest("PUT", "/records", "[]") 74 | } 75 | 76 | // todo: "tests should be able to run independent" `go test -v ./api -run TestGet` 77 | // test get records 78 | func TestGetRecords(t *testing.T) { 79 | body, _, err := rest("GET", "/records", "") 80 | if err != nil { 81 | t.Error(err) 82 | } 83 | if string(body) != "[]\n" { 84 | t.Errorf("%q doesn't match expected out", body) 85 | } 86 | body, _, err = rest("GET", "/records?full=true", "") 87 | if err != nil { 88 | t.Error(err) 89 | } 90 | if string(body) != "[]\n" { 91 | t.Errorf("%q doesn't match expected out", body) 92 | } 93 | } 94 | 95 | // test post records 96 | func TestPostRecord(t *testing.T) { 97 | // good request test 98 | resp, _, err := rest("POST", "/records", testResource1) 99 | if err != nil { 100 | t.Error(err) 101 | } 102 | 103 | var resource shaman.Resource 104 | json.Unmarshal(resp, &resource) 105 | 106 | if resource.Domain != "google.com." { 107 | t.Errorf("%q doesn't match expected out", resource) 108 | } 109 | 110 | // bad request test 111 | resp, _, err = rest("POST", "/records", badResource) 112 | if err != nil { 113 | t.Error(err) 114 | } 115 | 116 | if !strings.Contains(string(resp), "Bad JSON syntax received in body") { 117 | t.Errorf("%q doesn't match expected out", resp) 118 | } 119 | } 120 | 121 | // test get resource 122 | func TestGetRecord(t *testing.T) { 123 | // good request test 124 | resp, _, err := rest("GET", "/records/google.com", "") 125 | if err != nil { 126 | t.Error(err) 127 | } 128 | 129 | var resource shaman.Resource 130 | json.Unmarshal(resp, &resource) 131 | 132 | if resource.Domain != "google.com." { 133 | t.Errorf("%q doesn't match expected out", resource) 134 | } 135 | 136 | // bad request test 137 | resp, _, err = rest("GET", "/records/not-real.com", "") 138 | if err != nil { 139 | t.Error(err) 140 | } 141 | 142 | if !strings.Contains(string(resp), "failed to find record for domain - 'not-real.com'") { 143 | t.Errorf("%q doesn't match expected out", resp) 144 | } 145 | } 146 | 147 | // test put records 148 | func TestPutRecord(t *testing.T) { 149 | // good request test - create(201) 150 | resp, code, err := rest("PUT", "/records/foogle.com", testResource3) 151 | if err != nil { 152 | t.Error(err) 153 | } 154 | if code != 201 { 155 | t.Error("Failed to meet rfc2616 spec, expecting 201") 156 | } 157 | 158 | var resource shaman.Resource 159 | json.Unmarshal(resp, &resource) 160 | 161 | if len(resource.Records) == 1 && 162 | resource.Records[0].Address != "127.0.0.4" { 163 | t.Errorf("%q doesn't match expected out", resource) 164 | } 165 | 166 | // good request test - update 167 | resp, _, err = rest("PUT", "/records/foogle.com", testResource2) 168 | if err != nil { 169 | t.Error(err) 170 | } 171 | 172 | // verify old resource is gone 173 | resp, _, err = rest("GET", "/records/foogle.com", "") 174 | if err != nil { 175 | t.Error(err) 176 | } 177 | 178 | if !strings.Contains(string(resp), "failed to find record for domain - 'foogle.com'") { 179 | t.Errorf("%q doesn't match expected out", resp) 180 | } 181 | 182 | // bad request test 183 | resp, _, err = rest("PUT", "/records/not-real.com", badResource) 184 | if err != nil { 185 | t.Error(err) 186 | } 187 | 188 | if !strings.Contains(string(resp), "Bad JSON syntax received in body") { 189 | t.Errorf("%q doesn't match expected out", resp) 190 | } 191 | } 192 | 193 | // test delete resource 194 | func TestDeleteRecord(t *testing.T) { 195 | // good request test 196 | resp, _, err := rest("DELETE", "/records/google.com", "") 197 | if err != nil { 198 | t.Error(err) 199 | } 200 | 201 | if !strings.Contains(string(resp), "{\"msg\":\"success\"}") { 202 | t.Errorf("%q doesn't match expected out", resp) 203 | } 204 | 205 | // verify gone 206 | resp, code, err := rest("GET", "/records/google.com", "") 207 | if err != nil { 208 | t.Error(err) 209 | } 210 | 211 | if code != 404 { 212 | t.Errorf("%q doesn't match expected out", code) 213 | } 214 | 215 | // bad request test 216 | resp, _, err = rest("DELETE", "/records/not-real.com", "") 217 | if err != nil { 218 | t.Error(err) 219 | } 220 | 221 | if !strings.Contains(string(resp), "{\"msg\":\"success\"}") { 222 | t.Errorf("%q doesn't match expected out", resp) 223 | } 224 | } 225 | 226 | //////////////////////////////////////////////////////////////////////////////// 227 | // PRIVS 228 | //////////////////////////////////////////////////////////////////////////////// 229 | // hit api and return response body 230 | func rest(method, route, data string) ([]byte, int, error) { 231 | body := bytes.NewBuffer([]byte(data)) 232 | http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} 233 | 234 | uri := fmt.Sprintf("https://%s%s", config.ApiListen, route) 235 | 236 | req, _ := http.NewRequest(method, uri, body) 237 | req.Header.Add("X-AUTH-TOKEN", config.ApiToken) 238 | 239 | res, err := http.DefaultClient.Do(req) 240 | if err != nil { 241 | return nil, 500, fmt.Errorf("Unable to %v %v - %v", method, route, err) 242 | } 243 | defer res.Body.Close() 244 | 245 | if res.StatusCode == 401 { 246 | return nil, res.StatusCode, fmt.Errorf("401 Unauthorized. Please specify api token (-t 'token')") 247 | } 248 | 249 | b, err := ioutil.ReadAll(res.Body) 250 | 251 | return b, res.StatusCode, err 252 | } 253 | 254 | // manually configure and start internals 255 | func initialize() { 256 | config.L2Connect = "none://" 257 | config.ApiListen = "127.0.0.1:1633" 258 | config.Log = lumber.NewConsoleLogger(lumber.LvlInt("FATAL")) 259 | config.LogLevel = "FATAL" 260 | } 261 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![shaman logo](http://nano-assets.gopagoda.io/readme-headers/shaman.png)](http://nanobox.io/open-source#shaman) 2 | [![Build Status](https://travis-ci.org/nanopack/shaman.svg)](https://travis-ci.org/nanopack/shaman) 3 | [![GoDoc](https://godoc.org/github.com/nanopack/shaman?status.svg)](https://godoc.org/github.com/nanopack/shaman) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/nanopack/shaman)](https://goreportcard.com/report/github.com/nanopack/shaman) 5 | 6 | 7 | # Shaman 8 | 9 | Small, clusterable, lightweight, api-driven dns server. 10 | 11 | 12 | ## Quickstart: 13 | ```sh 14 | # Start shaman with defaults (requires admin privileges (port 53)) 15 | shaman -s 16 | 17 | # register a new domain 18 | shaman add -d nanopack.io -A 127.0.0.1 19 | 20 | # perform dns lookup 21 | # OR `nslookup -port=53 nanopack.io 127.0.0.1` 22 | dig @localhost nanopack.io +short 23 | # 127.0.0.1 24 | 25 | # Congratulations! 26 | ``` 27 | 28 | 29 | ## Usage: 30 | 31 | ### As a CLI 32 | Simply run `shaman ` 33 | 34 | `shaman` or `shaman -h` will show usage and a list of commands: 35 | 36 | ``` 37 | shaman - api driven dns server 38 | 39 | Usage: 40 | shaman [flags] 41 | shaman [command] 42 | 43 | Available Commands: 44 | add Add a domain to shaman 45 | delete Remove a domain from shaman 46 | list List all domains in shaman 47 | get Get records for a domain 48 | update Update records for a domain 49 | reset Reset all domains in shaman 50 | 51 | Flags: 52 | -C, --api-crt string Path to SSL crt for API access 53 | -a, --api-domain string Domain of generated cert (if none passed) (default "shaman.nanobox.io") 54 | -k, --api-key string Path to SSL key for API access 55 | -p, --api-key-password string Password for SSL key 56 | -H, --api-listen string Listen address for the API (ip:port) (default "127.0.0.1:1632") 57 | -c, --config-file string Configuration file to load 58 | -O, --dns-listen string Listen address for DNS requests (ip:port) (default "127.0.0.1:53") 59 | -d, --domain string Parent domain for requests (default ".") 60 | -f, --fallback-dns Fallback dns server address (ip:port), if not specified fallback is not used 61 | -i, --insecure Disable tls key checking (client) and listen on http (api). Also disables auth-token 62 | -2, --l2-connect string Connection string for the l2 cache (default "scribble:///var/db/shaman") 63 | -l, --log-level string Log level to output [fatal|error|info|debug|trace] (default "INFO") 64 | -s, --server Run in server mode 65 | -t, --token string Token for API Access (default "secret") 66 | -T, --ttl int Default TTL for DNS records (default 60) 67 | -v, --version Print version info and exit 68 | 69 | Use "shaman [command] --help" for more information about a command. 70 | ``` 71 | 72 | For usage examples, see [api](api/README.md) and/or [cli](commands/README.md) readme 73 | 74 | ### As a Server 75 | To start shaman as a server run: 76 | `shaman --server` 77 | An optional config file can also be passed on startup: 78 | `shaman -c config.json` 79 | 80 | >config.json 81 | >```json 82 | >{ 83 | > "api-domain": "shaman.nanobox.io", 84 | > "api-crt": "", 85 | > "api-key": "", 86 | > "api-key-password": "", 87 | > "api-listen": "127.0.0.1:1632", 88 | > "token": "secret", 89 | > "insecure": false, 90 | > "l2-connect": "scribble:///var/db/shaman", 91 | > "ttl": 60, 92 | > "domain": ".", 93 | > "dns-listen": "127.0.0.1:53", 94 | > "log-level": "info", 95 | > "server": true 96 | >} 97 | >``` 98 | 99 | #### L2 connection strings 100 | 101 | ##### Scribble Cacher 102 | The connection string looks like `scribble://localhost/path/to/data/store`. 103 | 104 | 111 | 112 | 113 | ## API: 114 | 115 | | Route | Description | Payload | Output | 116 | | --- | --- | --- | --- | 117 | | **POST** /records | Adds the domain and full record | json domain object | json domain object | 118 | | **PUT** /records | Update all domains and records (replaces all) | json array of domain objects | json array of domain objects | 119 | | **GET** /records | Returns a list of domains we have records for | nil | string array of domains | 120 | | **PUT** /records/{domain} | Update domain's records (replaces all) | json domain object | json domain object | 121 | | **GET** /records/{domain} | Returns the records for that domain | nil | json domain object | 122 | | **DELETE** /records/{domain} | Delete a domain | nil | success message | 123 | 124 | **note:** The API requires a token to be passed for authentication by default and is configurable at server start (`--token`). The token is passed in as a custom header: `X-AUTH-TOKEN`. 125 | 126 | For examples, see [the api's readme](api/README.md) 127 | 128 | 129 | ## Overview 130 | 131 | ```sh 132 | +------------+ +----------+ +-----------------+ 133 | | +-----> +-----> | 134 | | API Server | | | | Short-Term | 135 | | <-----+ Caching <-----+ (in-memory) | 136 | +------------+ | And | +-----------------+ 137 | | Database | 138 | +------------+ | Manager | +-----------------+ 139 | | +-----> +-----> | 140 | | DNS Server | | | | Long-Term (L2) | 141 | | <-----+ <-----+ | 142 | +------------+ +----------+ +-----------------+ 143 | ``` 144 | 145 | 146 | ## Data types: 147 | ### Domain (Resource): 148 | json: 149 | ```json 150 | { 151 | "domain": "nanopack.io.", 152 | "records": [ 153 | { 154 | "ttl": 60, 155 | "class": "IN", 156 | "type": "A", 157 | "address": "127.0.0.1" 158 | }, 159 | { 160 | "ttl": 60, 161 | "class": "IN", 162 | "type": "A", 163 | "address": "127.0.0.2" 164 | } 165 | ] 166 | } 167 | ``` 168 | 169 | Fields: 170 | - **domain**: Domain name to resolve 171 | - **records**: Array of address records 172 | - **ttl**: Seconds a client should cache for 173 | - **class**: Record class 174 | - **type**: Record type 175 | - A - Address record 176 | - CNAME - Canonical name record 177 | - MX - Mail exchange record 178 | - [Many more](https://en.wikipedia.org/wiki/List_of_DNS_record_types) - may or may not work as is 179 | - **address**: Address domain resolves to 180 | - note: Special rules apply in some cases. E.g. MX records require a number "10 mail.google.com" 181 | 182 | ### Error: 183 | json: 184 | ```json 185 | { 186 | "err": "exit status 2: unexpected argument" 187 | } 188 | ``` 189 | 190 | Fields: 191 | - **err**: Error message 192 | 193 | ### Message: 194 | json: 195 | ```json 196 | { 197 | "msg": "Success" 198 | } 199 | ``` 200 | 201 | Fields: 202 | - **msg**: Success message 203 | 204 | 205 | ## Todo 206 | - atomic local cache updates 207 | - export in hosts file format 208 | - improve scribble add (adding before stored in cache overwrites) 209 | 210 | 211 | ## Changelog 212 | - v0.0.2 (May 11, 2016) 213 | - Refactor to allow multiple records per domain and more fully utilize dns library 214 | - v0.0.3 (May 12, 2016) 215 | - Tests for DNS server 216 | - Start Server Insecure 217 | - v0.0.4 (Aug 16, 2016) 218 | - Postgresql as a backend 219 | 220 | 221 | ## Contributing 222 | Contributions to shaman are welcome and encouraged. Shaman is a [Nanobox](https://nanobox.io) project and contributions should follow the [Nanobox Contribution Process & Guidelines](https://docs.nanobox.io/contributing/). 223 | 224 | 225 | [![oss logo](http://nano-assets.gopagoda.io/open-src/nanobox-open-src.png)](http://nanobox.io/open-source) 226 | -------------------------------------------------------------------------------- /vendor/vendor.json: -------------------------------------------------------------------------------- 1 | { 2 | "comment": "", 3 | "ignore": "test", 4 | "package": [ 5 | { 6 | "path": "-v", 7 | "revision": "" 8 | }, 9 | { 10 | "checksumSHA1": "tUZmNMotCGdra/Ep3fB3H1nc4Uw=", 11 | "path": "github.com/armon/go-metrics", 12 | "revision": "7aa49fde808223f8dadfdbfd3a20ff6c19e5f9ec", 13 | "revisionTime": "2017-11-17T18:41:20Z" 14 | }, 15 | { 16 | "checksumSHA1": "7NP1qUMF8Kx1y0zANxx0e+oq9Oo=", 17 | "path": "github.com/fsnotify/fsnotify", 18 | "revision": "c2828203cd70a50dcccfb2761f8b1f8ceef9a8e9", 19 | "revisionTime": "2018-01-10T05:33:47Z" 20 | }, 21 | { 22 | "checksumSHA1": "g/V4qrXjUGG9B+e3hB+4NAYJ5Gs=", 23 | "path": "github.com/gorilla/context", 24 | "revision": "08b5f424b9271eedf6f9f0ce86cb9396ed337a42", 25 | "revisionTime": "2016-08-17T18:46:32Z" 26 | }, 27 | { 28 | "checksumSHA1": "gzYAE/UJ+G7yTqkdJAMActxDdxw=", 29 | "path": "github.com/gorilla/mux", 30 | "revision": "c0091a029979286890368b4c7b301261e448e242", 31 | "revisionTime": "2018-01-20T07:58:19Z" 32 | }, 33 | { 34 | "checksumSHA1": "OThbs8YdhxqAIEEaUHtOxM0hhOA=", 35 | "path": "github.com/gorilla/pat", 36 | "revision": "199c85a7f6d1ed2ecd6f071892d312c00d43b031", 37 | "revisionTime": "2018-01-18T22:20:23Z" 38 | }, 39 | { 40 | "checksumSHA1": "hfyI2np1b0xbIvh091FkDQcNgSc=", 41 | "path": "github.com/hashicorp/consul/api", 42 | "revision": "93b422fcaefed7e32c577f9e18a193886476496f", 43 | "revisionTime": "2018-02-13T22:21:40Z" 44 | }, 45 | { 46 | "checksumSHA1": "YAq1rqZIp+M74Q+jMBQkkMKm3VM=", 47 | "origin": "github.com/hashicorp/consul/vendor/github.com/hashicorp/go-cleanhttp", 48 | "path": "github.com/hashicorp/go-cleanhttp", 49 | "revision": "93b422fcaefed7e32c577f9e18a193886476496f", 50 | "revisionTime": "2018-02-13T22:21:40Z" 51 | }, 52 | { 53 | "checksumSHA1": "y+AeKVZoX0gB+DZW4Arzkb3tTVc=", 54 | "path": "github.com/hashicorp/go-immutable-radix", 55 | "revision": "7f3cd4390caab3250a57f30efdb2a65dd7649ecf", 56 | "revisionTime": "2018-01-29T17:09:00Z" 57 | }, 58 | { 59 | "checksumSHA1": "A1PcINvF3UiwHRKn8UcgARgvGRs=", 60 | "origin": "github.com/hashicorp/consul/vendor/github.com/hashicorp/go-rootcerts", 61 | "path": "github.com/hashicorp/go-rootcerts", 62 | "revision": "93b422fcaefed7e32c577f9e18a193886476496f", 63 | "revisionTime": "2018-02-13T22:21:40Z" 64 | }, 65 | { 66 | "checksumSHA1": "8Z637dcPkbR5HdLQQBp/9jTbx9Y=", 67 | "path": "github.com/hashicorp/golang-lru/simplelru", 68 | "revision": "0fb14efe8c47ae851c0034ed7a448854d3d34cf3", 69 | "revisionTime": "2018-02-01T23:52:37Z" 70 | }, 71 | { 72 | "checksumSHA1": "HtpYAWHvd9mq+mHkpo7z8PGzMik=", 73 | "path": "github.com/hashicorp/hcl", 74 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 75 | "revisionTime": "2017-10-17T18:19:29Z" 76 | }, 77 | { 78 | "checksumSHA1": "XQmjDva9JCGGkIecOgwtBEMCJhU=", 79 | "path": "github.com/hashicorp/hcl/hcl/ast", 80 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 81 | "revisionTime": "2017-10-17T18:19:29Z" 82 | }, 83 | { 84 | "checksumSHA1": "/15SVLnCDzxICSatuYbfctrcpSM=", 85 | "path": "github.com/hashicorp/hcl/hcl/parser", 86 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 87 | "revisionTime": "2017-10-17T18:19:29Z" 88 | }, 89 | { 90 | "checksumSHA1": "WR1BjzDKgv6uE+3ShcDTYz0Gl6A=", 91 | "path": "github.com/hashicorp/hcl/hcl/printer", 92 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 93 | "revisionTime": "2017-10-17T18:19:29Z" 94 | }, 95 | { 96 | "checksumSHA1": "PYDzRc61T0pbwWuLNHgBRp/gJII=", 97 | "path": "github.com/hashicorp/hcl/hcl/scanner", 98 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 99 | "revisionTime": "2017-10-17T18:19:29Z" 100 | }, 101 | { 102 | "checksumSHA1": "oS3SCN9Wd6D8/LG0Yx1fu84a7gI=", 103 | "path": "github.com/hashicorp/hcl/hcl/strconv", 104 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 105 | "revisionTime": "2017-10-17T18:19:29Z" 106 | }, 107 | { 108 | "checksumSHA1": "c6yprzj06ASwCo18TtbbNNBHljA=", 109 | "path": "github.com/hashicorp/hcl/hcl/token", 110 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 111 | "revisionTime": "2017-10-17T18:19:29Z" 112 | }, 113 | { 114 | "checksumSHA1": "PwlfXt7mFS8UYzWxOK5DOq0yxS0=", 115 | "path": "github.com/hashicorp/hcl/json/parser", 116 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 117 | "revisionTime": "2017-10-17T18:19:29Z" 118 | }, 119 | { 120 | "checksumSHA1": "afrZ8VmAwfTdDAYVgNSXbxa4GsA=", 121 | "path": "github.com/hashicorp/hcl/json/scanner", 122 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 123 | "revisionTime": "2017-10-17T18:19:29Z" 124 | }, 125 | { 126 | "checksumSHA1": "fNlXQCQEnb+B3k5UDL/r15xtSJY=", 127 | "path": "github.com/hashicorp/hcl/json/token", 128 | "revision": "23c074d0eceb2b8a5bfdbb271ab780cde70f05a8", 129 | "revisionTime": "2017-10-17T18:19:29Z" 130 | }, 131 | { 132 | "checksumSHA1": "0PeWsO2aI+2PgVYlYlDPKfzCLEQ=", 133 | "origin": "github.com/hashicorp/consul/vendor/github.com/hashicorp/serf/coordinate", 134 | "path": "github.com/hashicorp/serf/coordinate", 135 | "revision": "93b422fcaefed7e32c577f9e18a193886476496f", 136 | "revisionTime": "2018-02-13T22:21:40Z" 137 | }, 138 | { 139 | "checksumSHA1": "40vJyUB4ezQSn/NSadsKEOrudMc=", 140 | "path": "github.com/inconshreveable/mousetrap", 141 | "revision": "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75", 142 | "revisionTime": "2014-10-17T20:07:13Z" 143 | }, 144 | { 145 | "checksumSHA1": "TW1TbIL4RRT4fpdvwVxph25vdKw=", 146 | "path": "github.com/jcelliott/lumber", 147 | "revision": "dd349441af25132d146d7095c6693a15431fc9b1", 148 | "revisionTime": "2016-03-24T20:37:08Z" 149 | }, 150 | { 151 | "checksumSHA1": "KQhA4EQp4Ldwj9nJZnEURlE6aQw=", 152 | "path": "github.com/kr/fs", 153 | "revision": "2788f0dbd16903de03cb8186e5c7d97b69ad387b", 154 | "revisionTime": "2013-11-06T22:25:44Z" 155 | }, 156 | { 157 | "checksumSHA1": "V1a5/Ra9HXKNuArt5WKUqu+Jxt8=", 158 | "path": "github.com/lib/pq", 159 | "revision": "88edab0803230a3898347e77b474f8c1820a1f20", 160 | "revisionTime": "2018-02-01T18:47:07Z" 161 | }, 162 | { 163 | "checksumSHA1": "ATnwV0POluBNQEMjPdylodz0oK0=", 164 | "path": "github.com/lib/pq/oid", 165 | "revision": "88edab0803230a3898347e77b474f8c1820a1f20", 166 | "revisionTime": "2018-02-01T18:47:07Z" 167 | }, 168 | { 169 | "checksumSHA1": "RfDW05IB7Bij4TVZ+vIRuTlTIlM=", 170 | "path": "github.com/magiconair/properties", 171 | "revision": "50a685e9bc1f5bf289dc4d048cb48ec4c85514c7", 172 | "revisionTime": "2018-02-13T21:28:11Z" 173 | }, 174 | { 175 | "checksumSHA1": "XTeOihCDhjG6ltUKExoJ2uEzShk=", 176 | "path": "github.com/miekg/dns", 177 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 178 | "revisionTime": "2018-01-25T10:38:03Z" 179 | }, 180 | { 181 | "checksumSHA1": "V/quM7+em2ByJbWBLOsEwnY3j/Q=", 182 | "origin": "github.com/hashicorp/consul/vendor/github.com/mitchellh/go-homedir", 183 | "path": "github.com/mitchellh/go-homedir", 184 | "revision": "93b422fcaefed7e32c577f9e18a193886476496f", 185 | "revisionTime": "2018-02-13T22:21:40Z" 186 | }, 187 | { 188 | "checksumSHA1": "FpgODaspeA2JtrcagXl9JRY/i88=", 189 | "path": "github.com/mitchellh/mapstructure", 190 | "revision": "a4e142e9c047c904fa2f1e144d9a84e6133024bc", 191 | "revisionTime": "2018-02-03T10:28:30Z" 192 | }, 193 | { 194 | "checksumSHA1": "vl8zkek51ZtDVH8/QuCrieZd4j4=", 195 | "path": "github.com/nanobox-io/golang-nanoauth", 196 | "revision": "3397bf18cb420ef8278a0d5b3572de6b39aa7360", 197 | "revisionTime": "2018-03-13T01:03:18Z" 198 | }, 199 | { 200 | "checksumSHA1": "nzROOnBgMd8I+TknYTTgIAnvW70=", 201 | "path": "github.com/nanobox-io/golang-scribble", 202 | "revision": "ced58d671850da57ce8c11315424513b608083d7", 203 | "revisionTime": "2017-10-26T14:29:21Z" 204 | }, 205 | { 206 | "checksumSHA1": "+qobk0BQiCfqdrhKeoZnwz6hFnE=", 207 | "path": "github.com/pelletier/go-toml", 208 | "revision": "acdc4509485b587f5e675510c4f2c63e90ff68a8", 209 | "revisionTime": "2018-01-18T22:54:55Z" 210 | }, 211 | { 212 | "checksumSHA1": "ljd3FhYRJ91cLZz3wsH9BQQ2JbA=", 213 | "path": "github.com/pkg/errors", 214 | "revision": "30136e27e2ac8d167177e8a583aa4c3fea5be833", 215 | "revisionTime": "2018-01-27T01:58:12Z" 216 | }, 217 | { 218 | "checksumSHA1": "jXHAIyekklDQMwqdluPHs6MUmaU=", 219 | "path": "github.com/pkg/sftp", 220 | "revision": "22e9c1ccc02fc1b9fa3264572e49109b68a86947", 221 | "revisionTime": "2018-02-07T00:43:44Z" 222 | }, 223 | { 224 | "checksumSHA1": "F3JU4T4XXvZTRAcv6rhTao4QGos=", 225 | "path": "github.com/spf13/afero", 226 | "revision": "bbf41cb36dffe15dff5bf7e18c447801e7ffe163", 227 | "revisionTime": "2018-02-11T16:21:14Z" 228 | }, 229 | { 230 | "checksumSHA1": "X6RueW0rO55PbOQ0sMWSQOxVl4I=", 231 | "path": "github.com/spf13/afero/mem", 232 | "revision": "bbf41cb36dffe15dff5bf7e18c447801e7ffe163", 233 | "revisionTime": "2018-02-11T16:21:14Z" 234 | }, 235 | { 236 | "checksumSHA1": "sLyAUiIT7V0DNVp6yBhW4Ms5BEs=", 237 | "path": "github.com/spf13/afero/sftp", 238 | "revision": "52e4a6cfac46163658bd4f123c49b6ee7dc75f78", 239 | "revisionTime": "2016-09-19T21:01:14Z" 240 | }, 241 | { 242 | "checksumSHA1": "Sq0QP4JywTr7UM4hTK1cjCi7jec=", 243 | "path": "github.com/spf13/cast", 244 | "revision": "acbeb36b902d72a7a4c18e8f3241075e7ab763e4", 245 | "revisionTime": "2017-04-13T08:50:28Z" 246 | }, 247 | { 248 | "checksumSHA1": "bk8AIaMyTeY7CW85Zg2HDrZVSo0=", 249 | "path": "github.com/spf13/cobra", 250 | "revision": "be77323fc05148ef091e83b3866c0d47c8e74a8b", 251 | "revisionTime": "2018-02-11T16:22:30Z" 252 | }, 253 | { 254 | "checksumSHA1": "+JFKK0z5Eutk29rUz1lEhLxHMfk=", 255 | "path": "github.com/spf13/jwalterweatherman", 256 | "revision": "7c0cea34c8ece3fbeb2b27ab9b59511d360fb394", 257 | "revisionTime": "2018-01-09T13:55:06Z" 258 | }, 259 | { 260 | "checksumSHA1": "OJsSYyzbBLtTqWayX2cvg/zI68o=", 261 | "path": "github.com/spf13/pflag", 262 | "revision": "6a877ebacf28c5fc79846f4fcd380a5d9872b997", 263 | "revisionTime": "2018-02-08T21:53:15Z" 264 | }, 265 | { 266 | "checksumSHA1": "GWX9W5F1QBqLZsS1bYsG3jXjb3g=", 267 | "path": "github.com/spf13/viper", 268 | "revision": "aafc9e6bc7b7bb53ddaa75a5ef49a17d6e654be5", 269 | "revisionTime": "2017-11-29T09:51:06Z" 270 | }, 271 | { 272 | "checksumSHA1": "IQkUIOnvlf0tYloFx9mLaXSvXWQ=", 273 | "path": "golang.org/x/crypto/curve25519", 274 | "revision": "3a6c3ce65c4f0844ac396b02aebda1496fd2ac9d", 275 | "revisionTime": "2018-02-07T15:37:26Z" 276 | }, 277 | { 278 | "checksumSHA1": "1hwn8cgg4EVXhCpJIqmMbzqnUo0=", 279 | "origin": "github.com/miekg/dns/vendor/golang.org/x/crypto/ed25519", 280 | "path": "golang.org/x/crypto/ed25519", 281 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 282 | "revisionTime": "2018-01-25T10:38:03Z" 283 | }, 284 | { 285 | "checksumSHA1": "LXFcVx8I587SnWmKycSDEq9yvK8=", 286 | "origin": "github.com/miekg/dns/vendor/golang.org/x/crypto/ed25519/internal/edwards25519", 287 | "path": "golang.org/x/crypto/ed25519/internal/edwards25519", 288 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 289 | "revisionTime": "2018-01-25T10:38:03Z" 290 | }, 291 | { 292 | "checksumSHA1": "hfABw6DX9B4Ma+88qDDGz9qY45s=", 293 | "path": "golang.org/x/crypto/internal/chacha20", 294 | "revision": "3a6c3ce65c4f0844ac396b02aebda1496fd2ac9d", 295 | "revisionTime": "2018-02-07T15:37:26Z" 296 | }, 297 | { 298 | "checksumSHA1": "kVKE0OX1Xdw5mG7XKT86DLLKE2I=", 299 | "path": "golang.org/x/crypto/poly1305", 300 | "revision": "3a6c3ce65c4f0844ac396b02aebda1496fd2ac9d", 301 | "revisionTime": "2018-02-07T15:37:26Z" 302 | }, 303 | { 304 | "checksumSHA1": "ZK4HWtg3hJzayz0RRcc6qHpkums=", 305 | "path": "golang.org/x/crypto/ssh", 306 | "revision": "3a6c3ce65c4f0844ac396b02aebda1496fd2ac9d", 307 | "revisionTime": "2018-02-07T15:37:26Z" 308 | }, 309 | { 310 | "checksumSHA1": "uX2McdP4VcQ6zkAF0Q4oyd0rFtU=", 311 | "origin": "github.com/miekg/dns/vendor/golang.org/x/net/bpf", 312 | "path": "golang.org/x/net/bpf", 313 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 314 | "revisionTime": "2018-01-25T10:38:03Z" 315 | }, 316 | { 317 | "checksumSHA1": "YoSf+PgTWvHmFVaF3MrtZz3kX38=", 318 | "origin": "github.com/miekg/dns/vendor/golang.org/x/net/internal/iana", 319 | "path": "golang.org/x/net/internal/iana", 320 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 321 | "revisionTime": "2018-01-25T10:38:03Z" 322 | }, 323 | { 324 | "checksumSHA1": "yBGrvq2Acd3ufxsCVtp8zgV7wF0=", 325 | "origin": "github.com/miekg/dns/vendor/golang.org/x/net/internal/socket", 326 | "path": "golang.org/x/net/internal/socket", 327 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 328 | "revisionTime": "2018-01-25T10:38:03Z" 329 | }, 330 | { 331 | "checksumSHA1": "ZGMENpNTj2hojdJMcrUO+UPKVgE=", 332 | "origin": "github.com/miekg/dns/vendor/golang.org/x/net/ipv4", 333 | "path": "golang.org/x/net/ipv4", 334 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 335 | "revisionTime": "2018-01-25T10:38:03Z" 336 | }, 337 | { 338 | "checksumSHA1": "QUvByKIVmIy9c+8+O1XGyh9ynoY=", 339 | "origin": "github.com/miekg/dns/vendor/golang.org/x/net/ipv6", 340 | "path": "golang.org/x/net/ipv6", 341 | "revision": "5364553f1ee9cddc7ac8b62dce148309c386695b", 342 | "revisionTime": "2018-01-25T10:38:03Z" 343 | }, 344 | { 345 | "checksumSHA1": "osb18zDjd7/RMAMUuN3qP+w0ewE=", 346 | "path": "golang.org/x/sys/unix", 347 | "revision": "37707fdb30a5b38865cfb95e5aab41707daec7fd", 348 | "revisionTime": "2018-02-02T13:35:31Z" 349 | }, 350 | { 351 | "checksumSHA1": "Jl15/27Bbsc70w2cOyVKVwy8BCQ=", 352 | "path": "golang.org/x/text/internal/gen", 353 | "revision": "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1", 354 | "revisionTime": "2018-02-04T03:07:25Z" 355 | }, 356 | { 357 | "checksumSHA1": "47nwiUyVBY2RKoEGXmCSvusY4Js=", 358 | "path": "golang.org/x/text/internal/triegen", 359 | "revision": "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1", 360 | "revisionTime": "2018-02-04T03:07:25Z" 361 | }, 362 | { 363 | "checksumSHA1": "brtRuRoLzfZwY4Bir6gjFZqzSME=", 364 | "path": "golang.org/x/text/internal/ucd", 365 | "revision": "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1", 366 | "revisionTime": "2018-02-04T03:07:25Z" 367 | }, 368 | { 369 | "checksumSHA1": "ziMb9+ANGRJSSIuxYdRbA+cDRBQ=", 370 | "path": "golang.org/x/text/transform", 371 | "revision": "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1", 372 | "revisionTime": "2018-02-04T03:07:25Z" 373 | }, 374 | { 375 | "checksumSHA1": "s5G0noFRbSKD8Jr4FZmFeT4UvW8=", 376 | "path": "golang.org/x/text/unicode/cldr", 377 | "revision": "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1", 378 | "revisionTime": "2018-02-04T03:07:25Z" 379 | }, 380 | { 381 | "checksumSHA1": "lN2xlA6Utu7tXy2iUoMF2+y9EUE=", 382 | "path": "golang.org/x/text/unicode/norm", 383 | "revision": "4e4a3210bb54bb31f6ab2cdca2edcc0b50c420c1", 384 | "revisionTime": "2018-02-04T03:07:25Z" 385 | }, 386 | { 387 | "checksumSHA1": "qOmvuDm+F+2nQQecUZBVkZrTn6Y=", 388 | "path": "gopkg.in/yaml.v2", 389 | "revision": "d670f9405373e636a5a2765eea47fac0c9bc91a4", 390 | "revisionTime": "2018-01-09T11:43:31Z" 391 | } 392 | ], 393 | "rootPath": "github.com/nanopack/shaman" 394 | } 395 | --------------------------------------------------------------------------------