From c3ac7a211ce81a9582666fad5dbbc190685d08b1 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Thu, 1 Dec 2016 00:03:08 +0200 Subject: [PATCH 01/10] DB code for CIDR handling --- acmetxt.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++ api.go | 30 +++++++++++++++++---------- api_test.go | 5 +++-- db.go | 26 ++++++++++++++++++----- db_test.go | 47 +++++++++++++++++++++++++++++++++--------- dns_test.go | 2 +- types.go | 17 +--------------- util.go | 10 --------- validation.go | 3 ++- validation_test.go | 21 +++++++++++++++++++ 10 files changed, 156 insertions(+), 56 deletions(-) create mode 100644 acmetxt.go diff --git a/acmetxt.go b/acmetxt.go new file mode 100644 index 0000000..a4a23ed --- /dev/null +++ b/acmetxt.go @@ -0,0 +1,51 @@ +package main + +import ( + "encoding/json" + "net" + + "github.com/satori/go.uuid" +) + +// ACMETxt is the default structure for the user controlled record +type ACMETxt struct { + Username uuid.UUID + Password string + ACMETxtPost + LastActive int64 + AllowFrom cidrslice +} + +// ACMETxtPost holds the DNS part of the ACMETxt struct +type ACMETxtPost struct { + Subdomain string `json:"subdomain"` + Value string `json:"txt"` +} + +// cidrslice is a list of allowed cidr ranges +type cidrslice []string + +func (c *cidrslice) JSON() string { + ret, _ := json.Marshal(c.ValidEntries()) + return string(ret) +} + +func (c *cidrslice) ValidEntries() []string { + valid := []string{} + for _, v := range *c { + _, _, err := net.ParseCIDR(v) + if err == nil { + valid = append(valid, v) + } + } + return valid +} + +func newACMETxt() ACMETxt { + var a = ACMETxt{} + password := generatePassword(40) + a.Username = uuid.NewV4() + a.Password = password + a.Subdomain = uuid.NewV4().String() + return a +} diff --git a/api.go b/api.go index 7226ef9..5e78321 100644 --- a/api.go +++ b/api.go @@ -16,28 +16,36 @@ func (a authMiddleware) Serve(ctx *iris.Context) { username, err := getValidUsername(usernameStr) if err == nil && validKey(password) { au, err := DB.GetByUsername(username) - if err == nil && correctPassword(password, au.Password) { - // Password ok - if err := ctx.ReadJSON(&postData); err == nil { - // Check that the subdomain belongs to the user - if au.Subdomain == postData.Subdomain { - ctx.Next() + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("Error while trying to get user") + // To protect against timed side channel (never gonna give you up) + correctPassword(password, "$2a$10$8JEFVNYYhLoBysjAxe2yBuXrkDojBQBkVpXEQgyQyjn43SvJ4vL36") + } else { + if correctPassword(password, au.Password) { + // Password ok + if err := ctx.ReadJSON(&postData); err == nil { + // Check that the subdomain belongs to the user + if au.Subdomain == postData.Subdomain { + ctx.Next() + return + } + } else { + // JSON error + ctx.JSON(iris.StatusBadRequest, iris.Map{"error": "bad data"}) return } } else { - ctx.JSON(iris.StatusBadRequest, iris.Map{"error": "bad data"}) - return + // Wrong password + log.WithFields(log.Fields{"username": username}).Warning("Failed password check") } } - // To protect against timed side channel (never gonna give you up) - correctPassword(password, "$2a$10$8JEFVNYYhLoBysjAxe2yBuXrkDojBQBkVpXEQgyQyjn43SvJ4vL36") } ctx.JSON(iris.StatusUnauthorized, iris.Map{"error": "unauthorized"}) } func webRegisterPost(ctx *iris.Context) { // Create new user - nu, err := DB.Register() + nu, err := DB.Register(cidrslice{}) var regJSON iris.Map var regStatus int if err != nil { diff --git a/api_test.go b/api_test.go index b067f14..f69f6bf 100644 --- a/api_test.go +++ b/api_test.go @@ -90,7 +90,7 @@ func TestApiUpdateWithCredentials(t *testing.T) { "txt": ""} e := setupIris(t, false, false) - newUser, err := DB.Register() + newUser, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Could not create new user, got error [%v]", err) } @@ -146,7 +146,7 @@ func TestApiManyUpdateWithCredentials(t *testing.T) { "txt": ""} e := setupIris(t, false, false) - newUser, err := DB.Register() + newUser, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Could not create new user, got error [%v]", err) } @@ -164,6 +164,7 @@ func TestApiManyUpdateWithCredentials(t *testing.T) { {newUser.Username.String(), newUser.Password, newUser.Subdomain, "tooshortfortxt", 400}, {newUser.Username.String(), newUser.Password, newUser.Subdomain, 1234567890, 400}, {newUser.Username.String(), newUser.Password, newUser.Subdomain, validTxtData, 200}, + {newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401}, } { updateJSON = map[string]interface{}{ "subdomain": test.subdomain, diff --git a/db.go b/db.go index 868cf6e..3cac758 100644 --- a/db.go +++ b/db.go @@ -2,13 +2,16 @@ package main import ( "database/sql" + "encoding/json" "errors" + "regexp" + "time" + + log "github.com/Sirupsen/logrus" _ "github.com/lib/pq" _ "github.com/mattn/go-sqlite3" "github.com/satori/go.uuid" "golang.org/x/crypto/bcrypt" - "regexp" - "time" ) var recordsTable = ` @@ -43,10 +46,11 @@ func (d *acmedb) Init(engine string, connection string) error { return nil } -func (d *acmedb) Register() (ACMETxt, error) { +func (d *acmedb) Register(afrom cidrslice) (ACMETxt, error) { d.Lock() defer d.Unlock() a := newACMETxt() + a.AllowFrom = cidrslice(afrom.ValidEntries()) passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.Password), 10) timenow := time.Now().Unix() regSQL := ` @@ -63,10 +67,11 @@ func (d *acmedb) Register() (ACMETxt, error) { } sm, err := d.DB.Prepare(regSQL) if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("Database error in prepare") return a, errors.New("SQL error") } defer sm.Close() - _, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, timenow, a.AllowFrom) + _, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, timenow, a.AllowFrom.JSON()) if err != nil { return a, err } @@ -173,13 +178,24 @@ func (d *acmedb) Update(a ACMETxt) error { func getModelFromRow(r *sql.Rows) (ACMETxt, error) { txt := ACMETxt{} + afrom := "" err := r.Scan( &txt.Username, &txt.Password, &txt.Subdomain, &txt.Value, &txt.LastActive, - &txt.AllowFrom) + &afrom) + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("Row scan error") + } + + cslice := cidrslice{} + err = json.Unmarshal([]byte(afrom), &cslice) + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("JSON unmarshall error") + } + txt.AllowFrom = cslice return txt, err } diff --git a/db_test.go b/db_test.go index 665bed3..4580a34 100644 --- a/db_test.go +++ b/db_test.go @@ -41,17 +41,44 @@ func TestDBInit(t *testing.T) { errorDB.Close() } -func TestRegister(t *testing.T) { +func TestRegisterNoCIDR(t *testing.T) { // Register tests - _, err := DB.Register() + _, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Registration failed, got error [%v]", err) } } +func TestRegisterMany(t *testing.T) { + for i, test := range []struct { + input cidrslice + output cidrslice + }{ + {cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}, cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}}, + {cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, cidrslice{}}, + {cidrslice{"7.6.5.4/32", "invalid", "1.0.0.1/2"}, cidrslice{"7.6.5.4/32", "1.0.0.1/2"}}, + } { + user, err := DB.Register(test.input) + if err != nil { + t.Errorf("Test %d: Got error from register method: [%v]", i, err) + } + res, err := DB.GetByUsername(user.Username) + if err != nil { + t.Errorf("Test %d: Got error when fetching username: [%v]", i, err) + } + if len(user.AllowFrom) != len(test.output) { + t.Errorf("Test %d: Expected to recieve struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(user.AllowFrom)) + } + if len(res.AllowFrom) != len(test.output) { + t.Errorf("Test %d: Expected to recieve struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(res.AllowFrom)) + } + + } +} + func TestGetByUsername(t *testing.T) { // Create reg to refer to - reg, err := DB.Register() + reg, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Registration failed, got error [%v]", err) } @@ -76,7 +103,7 @@ func TestGetByUsername(t *testing.T) { } func TestPrepareErrors(t *testing.T) { - reg, _ := DB.Register() + reg, _ := DB.Register(cidrslice{}) tdb, err := sql.Open("testdb", "") if err != nil { t.Errorf("Got error: %v", err) @@ -98,7 +125,7 @@ func TestPrepareErrors(t *testing.T) { } func TestQueryExecErrors(t *testing.T) { - reg, _ := DB.Register() + reg, _ := DB.Register(cidrslice{}) testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) { return testResult{1, 0}, errors.New("Prepared query error") }) @@ -129,7 +156,7 @@ func TestQueryExecErrors(t *testing.T) { t.Errorf("Expected error from exec in GetByDomain, but got none") } - _, err = DB.Register() + _, err = DB.Register(cidrslice{}) if err == nil { t.Errorf("Expected error from exec in Register, but got none") } @@ -142,7 +169,7 @@ func TestQueryExecErrors(t *testing.T) { } func TestQueryScanErrors(t *testing.T) { - reg, _ := DB.Register() + reg, _ := DB.Register(cidrslice{}) testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) { return testResult{1, 0}, errors.New("Prepared query error") @@ -176,7 +203,7 @@ func TestQueryScanErrors(t *testing.T) { } func TestBadDBValues(t *testing.T) { - reg, _ := DB.Register() + reg, _ := DB.Register(cidrslice{}) testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) { columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"} @@ -209,7 +236,7 @@ func TestGetByDomain(t *testing.T) { var regDomain = ACMETxt{} // Create reg to refer to - reg, err := DB.Register() + reg, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Registration failed, got error [%v]", err) } @@ -246,7 +273,7 @@ func TestGetByDomain(t *testing.T) { func TestUpdate(t *testing.T) { // Create reg to refer to - reg, err := DB.Register() + reg, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Registration failed, got error [%v]", err) } diff --git a/dns_test.go b/dns_test.go index e550ec8..63c2701 100644 --- a/dns_test.go +++ b/dns_test.go @@ -139,7 +139,7 @@ func TestResolveTXT(t *testing.T) { resolv := resolver{server: "0.0.0.0:15353"} validTXT := "______________valid_response_______________" - atxt, err := DB.Register() + atxt, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Could not initiate db record: [%v]", err) return diff --git a/types.go b/types.go index c6bdf46..0dc3c79 100644 --- a/types.go +++ b/types.go @@ -66,21 +66,6 @@ type logconfig struct { Format string `toml:"logformat"` } -// ACMETxt is the default structure for the user controlled record -type ACMETxt struct { - Username uuid.UUID - Password string - ACMETxtPost - LastActive int64 - AllowFrom string -} - -// ACMETxtPost holds the DNS part of the ACMETxt struct -type ACMETxtPost struct { - Subdomain string `json:"subdomain"` - Value string `json:"txt"` -} - type acmedb struct { sync.Mutex DB *sql.DB @@ -88,7 +73,7 @@ type acmedb struct { type database interface { Init(string, string) error - Register() (ACMETxt, error) + Register(cidrslice) (ACMETxt, error) GetByUsername(uuid.UUID) (ACMETxt, error) GetByDomain(string) ([]ACMETxt, error) Update(ACMETxt) error diff --git a/util.go b/util.go index 7f51d06..e3d76c0 100644 --- a/util.go +++ b/util.go @@ -5,7 +5,6 @@ import ( "github.com/BurntSushi/toml" log "github.com/Sirupsen/logrus" "github.com/miekg/dns" - "github.com/satori/go.uuid" "math/big" "regexp" "strings" @@ -45,15 +44,6 @@ func sanitizeDomainQuestion(d string) string { return dom } -func newACMETxt() ACMETxt { - var a = ACMETxt{} - password := generatePassword(40) - a.Username = uuid.NewV4() - a.Password = password - a.Subdomain = uuid.NewV4().String() - return a -} - func setupLogging(format string, level string) { if format == "json" { log.SetFormatter(&log.JSONFormatter{}) diff --git a/validation.go b/validation.go index 036b9bc..66e1626 100644 --- a/validation.go +++ b/validation.go @@ -1,9 +1,10 @@ package main import ( + "unicode/utf8" + "github.com/satori/go.uuid" "golang.org/x/crypto/bcrypt" - "unicode/utf8" ) func getValidUsername(u string) (uuid.UUID, error) { diff --git a/validation_test.go b/validation_test.go index 58801ca..e6e3589 100644 --- a/validation_test.go +++ b/validation_test.go @@ -106,3 +106,24 @@ func TestCorrectPassword(t *testing.T) { } } } + +func TestGetValidCIDRMasks(t *testing.T) { + for i, test := range []struct { + input cidrslice + output cidrslice + }{ + {cidrslice{"10.0.0.1/24"}, cidrslice{"10.0.0.1/24"}}, + {cidrslice{"invalid", "127.0.0.1/32"}, cidrslice{"127.0.0.1/32"}}, + } { + ret := test.input.ValidEntries() + if len(ret) == len(test.output) { + for i, v := range ret { + if v != test.output[i] { + t.Errorf("Test %d: Expected %q but got %q", i, test.output, ret) + } + } + } else { + t.Errorf("Test %d: Expected %q but got %q", i, test.output, ret) + } + } +} From 5f68d84ca585aed72937c067d83f3b7526a35647 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Fri, 2 Dec 2016 15:42:10 +0200 Subject: [PATCH 02/10] Removed register GET request in favor of POST, and did required HTTP api changes --- acmetxt.go | 16 ++++++++++++++++ api.go | 31 +++++++++++++++--------------- api_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++---------- main.go | 1 - 4 files changed, 75 insertions(+), 27 deletions(-) diff --git a/acmetxt.go b/acmetxt.go index a4a23ed..e65c842 100644 --- a/acmetxt.go +++ b/acmetxt.go @@ -41,6 +41,22 @@ func (c *cidrslice) ValidEntries() []string { return valid } +// Check if IP belongs to an allowed net +func (a ACMETxt) allowedFrom(ip string) bool { + remoteIP := net.ParseIP(ip) + // Range not limited + if len(a.AllowFrom.ValidEntries()) == 0 { + return true + } + for _, v := range a.AllowFrom.ValidEntries() { + _, vnet, _ := net.ParseCIDR(v) + if vnet.Contains(remoteIP) { + return true + } + } + return false +} + func newACMETxt() ACMETxt { var a = ACMETxt{} password := generatePassword(40) diff --git a/api.go b/api.go index 5e78321..dfb9e2d 100644 --- a/api.go +++ b/api.go @@ -23,16 +23,18 @@ func (a authMiddleware) Serve(ctx *iris.Context) { } else { if correctPassword(password, au.Password) { // Password ok - if err := ctx.ReadJSON(&postData); err == nil { - // Check that the subdomain belongs to the user - if au.Subdomain == postData.Subdomain { - ctx.Next() + if au.allowedFrom(ctx.RequestIP()) { + // Update is allowed from remote addr + if err := ctx.ReadJSON(&postData); err == nil { + if au.Subdomain == postData.Subdomain { + ctx.Next() + return + } + } else { + // JSON error + ctx.JSON(iris.StatusBadRequest, iris.Map{"error": "bad data"}) return } - } else { - // JSON error - ctx.JSON(iris.StatusBadRequest, iris.Map{"error": "bad data"}) - return } } else { // Wrong password @@ -44,17 +46,19 @@ func (a authMiddleware) Serve(ctx *iris.Context) { } func webRegisterPost(ctx *iris.Context) { - // Create new user - nu, err := DB.Register(cidrslice{}) var regJSON iris.Map var regStatus int + cslice := cidrslice{} + _ = ctx.ReadJSON(&cslice) + // Create new user + nu, err := DB.Register(cslice) if err != nil { errstr := fmt.Sprintf("%v", err) regJSON = iris.Map{"error": errstr} regStatus = iris.StatusInternalServerError log.WithFields(log.Fields{"error": err.Error()}).Debug("Error in registration") } else { - regJSON = iris.Map{"username": nu.Username, "password": nu.Password, "fulldomain": nu.Subdomain + "." + DNSConf.General.Domain, "subdomain": nu.Subdomain} + regJSON = iris.Map{"username": nu.Username, "password": nu.Password, "fulldomain": nu.Subdomain + "." + DNSConf.General.Domain, "subdomain": nu.Subdomain, "allowfrom": nu.AllowFrom.JSON()} regStatus = iris.StatusCreated log.WithFields(log.Fields{"user": nu.Username.String()}).Debug("Created new user") @@ -62,11 +66,6 @@ func webRegisterPost(ctx *iris.Context) { ctx.JSON(regStatus, regJSON) } -func webRegisterGet(ctx *iris.Context) { - // This is placeholder for now - webRegisterPost(ctx) -} - func webUpdatePost(ctx *iris.Context) { // User auth done in middleware a := ACMETxt{} diff --git a/api_test.go b/api_test.go index f69f6bf..6a054ee 100644 --- a/api_test.go +++ b/api_test.go @@ -26,7 +26,6 @@ func setupIris(t *testing.T, debug bool, noauth bool) *httpexpect.Expect { } DNSConf = dnscfg var ForceAuth = authMiddleware{} - iris.Get("/register", webRegisterGet) iris.Post("/register", webRegisterPost) if noauth { iris.Post("/update", webUpdatePost) @@ -40,14 +39,6 @@ func setupIris(t *testing.T, debug bool, noauth bool) *httpexpect.Expect { func TestApiRegister(t *testing.T) { e := setupIris(t, false, false) - e.GET("/register").Expect(). - Status(iris.StatusCreated). - JSON().Object(). - ContainsKey("fulldomain"). - ContainsKey("subdomain"). - ContainsKey("username"). - ContainsKey("password"). - NotContainsKey("error") e.POST("/register").Expect(). Status(iris.StatusCreated). JSON().Object(). @@ -56,6 +47,27 @@ func TestApiRegister(t *testing.T) { ContainsKey("username"). ContainsKey("password"). NotContainsKey("error") + + allowfrom := []interface{}{ + "123.123.123.123/32", + "1010.10.10.10/24", + "invalid", + } + + response := e.POST("/register"). + WithJSON(allowfrom). + Expect(). + Status(iris.StatusCreated). + JSON().Object(). + ContainsKey("fulldomain"). + ContainsKey("subdomain"). + ContainsKey("username"). + ContainsKey("password"). + ContainsKey("allowfrom"). + NotContainsKey("error") + + response.Value("allowfrom").String().Equal("[\"123.123.123.123/32\"]") + } func TestApiRegisterWithMockDB(t *testing.T) { @@ -66,7 +78,7 @@ func TestApiRegisterWithMockDB(t *testing.T) { defer db.Close() mock.ExpectBegin() mock.ExpectPrepare("INSERT INTO records").WillReturnError(errors.New("error")) - e.GET("/register").Expect(). + e.POST("/register").Expect(). Status(iris.StatusInternalServerError). JSON().Object(). ContainsKey("error") @@ -146,10 +158,30 @@ func TestApiManyUpdateWithCredentials(t *testing.T) { "txt": ""} e := setupIris(t, false, false) + // User without defined CIDR masks newUser, err := DB.Register(cidrslice{}) if err != nil { t.Errorf("Could not create new user, got error [%v]", err) } + + // User with defined allow from - CIDR masks, all invalid + // (httpexpect doesn't provide a way to mock remote ip) + newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.1/32", "invalid"}) + if err != nil { + t.Errorf("Could not create new user with CIDR, got error [%v]", err) + } + + // Another user with valid CIDR mask to match the httpexpect default + newUserWithValidCIDR, err := DB.Register(cidrslice{"0.0.0.0/32", "invalid"}) + if err != nil { + t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err) + } + + /* newUserWithValidCIDR, err := DB.Register(cidrslice{"192.168.1.1/32", "invalid"}) + if err != nil { + t.Errorf("Could not create new user with CIDR, got error [%v]", err) + } + */ for _, test := range []struct { user string pass string @@ -164,6 +196,8 @@ func TestApiManyUpdateWithCredentials(t *testing.T) { {newUser.Username.String(), newUser.Password, newUser.Subdomain, "tooshortfortxt", 400}, {newUser.Username.String(), newUser.Password, newUser.Subdomain, 1234567890, 400}, {newUser.Username.String(), newUser.Password, newUser.Subdomain, validTxtData, 200}, + {newUserWithCIDR.Username.String(), newUserWithCIDR.Password, newUserWithCIDR.Subdomain, validTxtData, 401}, + {newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200}, {newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401}, } { updateJSON = map[string]interface{}{ diff --git a/main.go b/main.go index d114ad3..2ba08cd 100644 --- a/main.go +++ b/main.go @@ -49,7 +49,6 @@ func startHTTPAPI() { }) api.Use(crs) var ForceAuth = authMiddleware{} - api.Get("/register", webRegisterGet) api.Post("/register", webRegisterPost) api.Post("/update", ForceAuth.Serve, webUpdatePost) switch DNSConf.API.TLS { From 8c99346b01d429ab34955b874c8ab4dbd6e0925b Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Fri, 2 Dec 2016 16:26:31 +0200 Subject: [PATCH 03/10] Remove old commented out code --- api_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/api_test.go b/api_test.go index 6a054ee..87c7e2a 100644 --- a/api_test.go +++ b/api_test.go @@ -177,11 +177,6 @@ func TestApiManyUpdateWithCredentials(t *testing.T) { t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err) } - /* newUserWithValidCIDR, err := DB.Register(cidrslice{"192.168.1.1/32", "invalid"}) - if err != nil { - t.Errorf("Could not create new user with CIDR, got error [%v]", err) - } - */ for _, test := range []struct { user string pass string From bf9eaf2f32ccf3a81cdb3ea0ade683bd786844b6 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Fri, 2 Dec 2016 17:04:16 +0200 Subject: [PATCH 04/10] Added config option to check for a header value for clinet IP --- acmetxt.go | 11 +++++++++++ api.go | 12 +++++++++++- api_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ config.cfg | 4 ++++ main_test.go | 2 ++ types.go | 2 ++ util.go | 18 +++++++++++++++--- util_test.go | 24 ++++++++++++++++++++++++ 8 files changed, 116 insertions(+), 4 deletions(-) diff --git a/acmetxt.go b/acmetxt.go index e65c842..ad3ab82 100644 --- a/acmetxt.go +++ b/acmetxt.go @@ -57,6 +57,17 @@ func (a ACMETxt) allowedFrom(ip string) bool { return false } +// Go through list (most likely from headers) to check for the IP. +// Reason for this is that some setups use reverse proxy in front of acme-dns +func (a ACMETxt) allowedFromList(ips []string) bool { + for _, v := range ips { + if a.allowedFrom(v) { + return true + } + } + return false +} + func newACMETxt() ACMETxt { var a = ACMETxt{} password := generatePassword(40) diff --git a/api.go b/api.go index dfb9e2d..1b6d07c 100644 --- a/api.go +++ b/api.go @@ -9,6 +9,7 @@ import ( // Serve is an authentication middlware function used to authenticate update requests func (a authMiddleware) Serve(ctx *iris.Context) { + allowUpdate := false usernameStr := ctx.RequestHeader("X-Api-User") password := ctx.RequestHeader("X-Api-Key") postData := ACMETxt{} @@ -23,7 +24,16 @@ func (a authMiddleware) Serve(ctx *iris.Context) { } else { if correctPassword(password, au.Password) { // Password ok - if au.allowedFrom(ctx.RequestIP()) { + + // Now test for the possibly limited ranges + if DNSConf.API.UseHeader { + ips := getIPListFromHeader(ctx.RequestHeader(DNSConf.API.HeaderName)) + allowUpdate = au.allowedFromList(ips) + } else { + allowUpdate = au.allowedFrom(ctx.RequestIP()) + } + + if allowUpdate { // Update is allowed from remote addr if err := ctx.ReadJSON(&postData); err == nil { if au.Subdomain == postData.Subdomain { diff --git a/api_test.go b/api_test.go index 87c7e2a..454edf5 100644 --- a/api_test.go +++ b/api_test.go @@ -19,6 +19,8 @@ func setupIris(t *testing.T, debug bool, noauth bool) *httpexpect.Expect { Port: "8080", TLS: "none", CorsOrigins: []string{"*"}, + UseHeader: false, + HeaderName: "X-Forwarded-For", } var dnscfg = DNSConfig{ API: httpapicfg, @@ -206,3 +208,48 @@ func TestApiManyUpdateWithCredentials(t *testing.T) { Status(test.status) } } + +func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) { + + updateJSON := map[string]interface{}{ + "subdomain": "", + "txt": ""} + + e := setupIris(t, false, false) + // Use header checks from default header (X-Forwarded-For) + DNSConf.API.UseHeader = true + // User without defined CIDR masks + newUser, err := DB.Register(cidrslice{}) + if err != nil { + t.Errorf("Could not create new user, got error [%v]", err) + } + + newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.2/32", "invalid"}) + if err != nil { + t.Errorf("Could not create new user with CIDR, got error [%v]", err) + } + + for _, test := range []struct { + user ACMETxt + headerValue string + status int + }{ + {newUser, "whatever goes", 200}, + {newUser, "10.0.0.1, 1.2.3.4 ,3.4.5.6", 200}, + {newUserWithCIDR, "127.0.0.1", 401}, + {newUserWithCIDR, "10.0.0.1, 10.0.0.2, 192.168.1.3", 401}, + {newUserWithCIDR, "10.1.1.1 ,192.168.1.2, 8.8.8.8", 200}, + } { + updateJSON = map[string]interface{}{ + "subdomain": test.user.Subdomain, + "txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + e.POST("/update"). + WithJSON(updateJSON). + WithHeader("X-Api-User", test.user.Username.String()). + WithHeader("X-Api-Key", test.user.Password). + WithHeader("X-Forwarded-For", test.headerValue). + Expect(). + Status(test.status) + } + DNSConf.API.UseHeader = false +} diff --git a/config.cfg b/config.cfg index b0d593f..dbe82f9 100644 --- a/config.cfg +++ b/config.cfg @@ -44,6 +44,10 @@ tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem" corsorigins = [ "*" ] +# use HTTP header to get the client ip +use_header = false +# header name to pull the ip address / list of ip addresses from +header_name = "X-Forwarded-For" [logconfig] # logging level: "error", "warning", "info" or "debug" diff --git a/main_test.go b/main_test.go index 980d0d1..c417816 100644 --- a/main_test.go +++ b/main_test.go @@ -68,6 +68,8 @@ func setupConfig() { Port: "8080", TLS: "none", CorsOrigins: []string{"*"}, + UseHeader: false, + HeaderName: "X-Forwarded-For", } var dnscfg = DNSConfig{ diff --git a/types.go b/types.go index 0dc3c79..0a41a3e 100644 --- a/types.go +++ b/types.go @@ -56,6 +56,8 @@ type httpapi struct { TLSCertPrivkey string `toml:"tls_cert_privkey"` TLSCertFullchain string `toml:"tls_cert_fullchain"` CorsOrigins []string + UseHeader bool `toml:"use_header"` + HeaderName string `toml:"header_name"` } // Logging config diff --git a/util.go b/util.go index e3d76c0..44a5f3e 100644 --- a/util.go +++ b/util.go @@ -2,12 +2,13 @@ package main import ( "crypto/rand" - "github.com/BurntSushi/toml" - log "github.com/Sirupsen/logrus" - "github.com/miekg/dns" "math/big" "regexp" "strings" + + "github.com/BurntSushi/toml" + log "github.com/Sirupsen/logrus" + "github.com/miekg/dns" ) func readConfig(fname string) DNSConfig { @@ -68,3 +69,14 @@ func startDNS(listen string, proto string) *dns.Server { go server.ListenAndServe() return server } + +func getIPListFromHeader(header string) []string { + iplist := []string{} + for _, v := range strings.Split(header, ",") { + if len(v) > 0 { + // Ignore empty values + iplist = append(iplist, strings.TrimSpace(v)) + } + } + return iplist +} diff --git a/util_test.go b/util_test.go index 3d8d9fd..621918c 100644 --- a/util_test.go +++ b/util_test.go @@ -71,3 +71,27 @@ func TestReadConfig(t *testing.T) { } } } + +func TestGetIPListFromHeader(t *testing.T) { + for i, test := range []struct { + input string + output []string + }{ + {"1.1.1.1, 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}}, + {" 1.1.1.1 , 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}}, + {",1.1.1.1 ,2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}}, + } { + res := getIPListFromHeader(test.input) + if len(res) != len(test.output) { + t.Errorf("Test %d: Expected [%d] items in return list, but got [%d]", i, len(test.output), len(res)) + } else { + + for j, vv := range test.output { + if res[j] != vv { + t.Errorf("Test %d: Expected return value [%v] but got [%v]", j, test.output, res) + } + + } + } + } +} From cb050c2c92892f138e79d328c1c5753a7e682927 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 3 Dec 2016 10:31:15 +0200 Subject: [PATCH 05/10] Modified returned JSON structure --- api.go | 8 ++++---- api_test.go | 11 +++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/api.go b/api.go index 1b6d07c..7ed5b3b 100644 --- a/api.go +++ b/api.go @@ -58,17 +58,17 @@ func (a authMiddleware) Serve(ctx *iris.Context) { func webRegisterPost(ctx *iris.Context) { var regJSON iris.Map var regStatus int - cslice := cidrslice{} - _ = ctx.ReadJSON(&cslice) + aTXT := ACMETxt{} + _ = ctx.ReadJSON(&aTXT) // Create new user - nu, err := DB.Register(cslice) + nu, err := DB.Register(aTXT.AllowFrom) if err != nil { errstr := fmt.Sprintf("%v", err) regJSON = iris.Map{"error": errstr} regStatus = iris.StatusInternalServerError log.WithFields(log.Fields{"error": err.Error()}).Debug("Error in registration") } else { - regJSON = iris.Map{"username": nu.Username, "password": nu.Password, "fulldomain": nu.Subdomain + "." + DNSConf.General.Domain, "subdomain": nu.Subdomain, "allowfrom": nu.AllowFrom.JSON()} + regJSON = iris.Map{"username": nu.Username, "password": nu.Password, "fulldomain": nu.Subdomain + "." + DNSConf.General.Domain, "subdomain": nu.Subdomain, "allowfrom": nu.AllowFrom.ValidEntries()} regStatus = iris.StatusCreated log.WithFields(log.Fields{"user": nu.Username.String()}).Debug("Created new user") diff --git a/api_test.go b/api_test.go index 454edf5..0e7bf88 100644 --- a/api_test.go +++ b/api_test.go @@ -50,10 +50,10 @@ func TestApiRegister(t *testing.T) { ContainsKey("password"). NotContainsKey("error") - allowfrom := []interface{}{ - "123.123.123.123/32", - "1010.10.10.10/24", - "invalid", + allowfrom := map[string][]interface{}{ + "allowfrom": []interface{}{"123.123.123.123/32", + "1010.10.10.10/24", + "invalid"}, } response := e.POST("/register"). @@ -68,8 +68,7 @@ func TestApiRegister(t *testing.T) { ContainsKey("allowfrom"). NotContainsKey("error") - response.Value("allowfrom").String().Equal("[\"123.123.123.123/32\"]") - + response.Value("allowfrom").Array().Elements("123.123.123.123/32") } func TestApiRegisterWithMockDB(t *testing.T) { From 8d0b08229d7dfe8aeecc78eee219b1beb41bf2dc Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 3 Dec 2016 11:33:43 +0200 Subject: [PATCH 06/10] Readme changes --- README.md | 45 ++++++++++++++++++++++++++++++++------------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6e8a845..969b3b2 100644 --- a/README.md +++ b/README.md @@ -8,14 +8,17 @@ A simplified DNS server with a RESTful HTTP API to provide a simple way to autom Many DNS servers do not provide an API to enable automation for the ACME DNS challenges. Those which do, give the keys way too much power. Leaving the keys laying around your random boxes is too often a requirement to have a meaningful process automation. +Acme-dns provides a simple API exclusively for TXT record updates and should be used with ACME magic "\_acme-challenge" - subdomain CNAME records. This way in the unfortunate exposure of API keys, the effetcs are limited to the subdomain TXT record in question. + So basically it boils down to **accessibility** and **security** ## Features - Simplified DNS server, serving your ACME DNS challenges (TXT) - Custom records (have your required A, AAAA, NS, etc. records served) - HTTP API automatically acquires and uses Let's Encrypt TLS certificate -- Simple deployment (it's Go after all) +- Limit /update API endpoint access to specific CIDR mask(s), defined in the /register request - Supports SQLite & PostgreSQL as DB backends +- Simple deployment (it's Go after all) ## Usage @@ -23,7 +26,7 @@ So basically it boils down to **accessibility** and **security** Using acme-dns is a three-step process (provided you already have the self-hosted server set up, or are using a service like acme-dns.io): -- Get credentials and unique subdomain (simple GET request to https://auth.exmaple.org/register) +- Get credentials and unique subdomain (simple POST request to eg. https://auth.acme-dns.io/register) - Create a (ACME magic) CNAME record to your existing zone, pointing to the subdomain you got from the registration. (eg. `_acme-challenge.domainiwantcertfor.tld. CNAME a097455b-52cc-4569-90c8-7a4b97c6eba8.auth.example.org` ) - Use your credentials to POST a new DNS challenge values to an acme-dns server for the CA to validate them off of. - Crontab and forget. @@ -33,18 +36,30 @@ Using acme-dns is a three-step process (provided you already have the self-hoste ### Register endpoint The method returns a new unique subdomain and credentials needed to update your record. -Subdomain is where you can point your own `_acme-challenge` subdomain CNAME record to. -With the credentials, you can update the TXT response in the service to match the challenge token, later referred as ______my_43_char_dns_validation_token______, given out by the Certificate Authority. +Fulldomain is where you can point your own `_acme-challenge` subdomain CNAME record to. +With the credentials, you can update the TXT response in the service to match the challenge token, later referred as \_\_\_validation\_token\_recieved\_from\_the\_ca\_\_\_, given out by the Certificate Authority. -```GET /register``` +**Optional:**: You can POST JSON data to limit the /update requests to predefined source networks using CIDR notation. -#### Parameters +```POST /register``` + +#### OPTIONAL Example input +```json +{ + "allowfrom": [ + "192.168.100.1/24", + "1.2.3.4/32", +} +``` -None ```Status: 201 Created``` -``` +```json { + "allowfrom": [ + "192.168.100.1/24", + "1.2.3.4/32" + ], "fulldomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a.auth.acme-dns.io", "password": "htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z", "subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a", @@ -65,10 +80,10 @@ The method allows you to update the TXT answer contents of your unique subdomain | X-Api-Key | Password recieved from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` | #### Example input -``` +```json { "subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a", - "txt": "______my_43_char_dns_validation_token______" + "txt": "___validation_token_recieved_from_the_ca___", } ``` @@ -77,7 +92,7 @@ The method allows you to update the TXT answer contents of your unique subdomain ```Status: 200 OK``` ```json { - "txt": "______my_43_char_dns_validation_token______" + "txt": "___validation_token_recieved_from_the_ca___", } ``` @@ -90,7 +105,7 @@ Check out how in the INSTALL section. ## As a service Acme-dns instance is running as a service for everyone wanting to get on in fast. You can find it at `auth.acme-dns.io`, so to get started, try: -```curl -X GET https://auth.acme-dns.io/register``` +```curl -X POST https://auth.acme-dns.io/register``` ## Installation @@ -169,11 +184,15 @@ logtype = "stdout" # logfile = "./acme-dns.log" # format, either "json" or "text" logformat = "text" +# use HTTP header to get the client ip +use_header = false +# header name to pull the ip address / list of ip addresses from +header_name = "X-Forwarded-For" ``` ## TODO -- Ability to define the CIDR mask in POST request to /register endpoint which is authorized to make /update requests with the created user-key-pair. +- Logging to a file - Want to see something implemented, make a feature request! ## Contributing From ad4decb0bb73a9cafb6b5c02d4a72aeadeee5ebe Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sat, 3 Dec 2016 15:44:50 +0200 Subject: [PATCH 07/10] Added new video with the new API --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index 969b3b2..eabba4b 100644 --- a/README.md +++ b/README.md @@ -21,8 +21,7 @@ So basically it boils down to **accessibility** and **security** - Simple deployment (it's Go after all) ## Usage - -[![asciicast](https://asciinema.org/a/94462.png)](https://asciinema.org/a/94462) +[![asciicast](https://asciinema.org/a/94903.png)](https://asciinema.org/a/94903) Using acme-dns is a three-step process (provided you already have the self-hosted server set up, or are using a service like acme-dns.io): From 7510368a46c74c7ff0b157a1fde66606b898f44f Mon Sep 17 00:00:00 2001 From: Santeri Toikka Date: Sat, 3 Dec 2016 19:36:46 +0200 Subject: [PATCH 08/10] Comma tuning and added ipv6 example to JSON structure. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index eabba4b..83189bf 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A simplified DNS server with a RESTful HTTP API to provide a simple way to autom Many DNS servers do not provide an API to enable automation for the ACME DNS challenges. Those which do, give the keys way too much power. Leaving the keys laying around your random boxes is too often a requirement to have a meaningful process automation. -Acme-dns provides a simple API exclusively for TXT record updates and should be used with ACME magic "\_acme-challenge" - subdomain CNAME records. This way in the unfortunate exposure of API keys, the effetcs are limited to the subdomain TXT record in question. +Acme-dns provides a simple API exclusively for TXT record updates and should be used with ACME magic "\_acme-challenge" - subdomain CNAME records. This way, in the unfortunate exposure of API keys, the effetcs are limited to the subdomain TXT record in question. So basically it boils down to **accessibility** and **security** @@ -48,6 +48,7 @@ With the credentials, you can update the TXT response in the service to match th "allowfrom": [ "192.168.100.1/24", "1.2.3.4/32", + "2002:c0a8:2a01::0/32", } ``` From 8ddb845ca5fb8a5414192f2960a955dcb9217b33 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sun, 4 Dec 2016 14:19:17 +0200 Subject: [PATCH 09/10] Added IPv6 testcases --- api_test.go | 8 ++++++++ validation_test.go | 1 + 2 files changed, 9 insertions(+) diff --git a/api_test.go b/api_test.go index 0e7bf88..99fe655 100644 --- a/api_test.go +++ b/api_test.go @@ -228,6 +228,11 @@ func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) { t.Errorf("Could not create new user with CIDR, got error [%v]", err) } + newUserWithIP6CIDR, err := DB.Register(cidrslice{"2002:c0a8::0/32"}) + if err != nil { + t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err) + } + for _, test := range []struct { user ACMETxt headerValue string @@ -238,6 +243,9 @@ func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) { {newUserWithCIDR, "127.0.0.1", 401}, {newUserWithCIDR, "10.0.0.1, 10.0.0.2, 192.168.1.3", 401}, {newUserWithCIDR, "10.1.1.1 ,192.168.1.2, 8.8.8.8", 200}, + {newUserWithIP6CIDR, "2002:c0a8:b4dc:0d3::0", 200}, + {newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401}, + {newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200}, } { updateJSON = map[string]interface{}{ "subdomain": test.user.Subdomain, diff --git a/validation_test.go b/validation_test.go index e6e3589..fd63cf4 100644 --- a/validation_test.go +++ b/validation_test.go @@ -114,6 +114,7 @@ func TestGetValidCIDRMasks(t *testing.T) { }{ {cidrslice{"10.0.0.1/24"}, cidrslice{"10.0.0.1/24"}}, {cidrslice{"invalid", "127.0.0.1/32"}, cidrslice{"127.0.0.1/32"}}, + {cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}, cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}}, } { ret := test.input.ValidEntries() if len(ret) == len(test.output) { From 174cf59fcca7f1dea07f95820d8c77ce94659931 Mon Sep 17 00:00:00 2001 From: Santeri Toikka Date: Sun, 4 Dec 2016 19:17:31 +0200 Subject: [PATCH 10/10] Fixed ipv6 mask. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 83189bf..c528f71 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ With the credentials, you can update the TXT response in the service to match th "allowfrom": [ "192.168.100.1/24", "1.2.3.4/32", - "2002:c0a8:2a01::0/32", + "2002:c0a8:2a00::0/40", } ```