Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow set-and-delete flow that is usually used by acme-clients #270

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ With the credentials, you can update the TXT response in the service to match th

### Update endpoint

The method allows you to update the TXT answer contents of your unique subdomain. Usually carried automatically by automated ACME client.
This method allows you to insert TXT records for your unique subdomain. Usually carried automatically by automated ACME client.

```POST /update```

Expand All @@ -104,6 +104,37 @@ The method allows you to update the TXT answer contents of your unique subdomain
}
```


### Delete endpoint

This method allows you to delete TXT records of your unique subdomain after they have been used for validation.
Usually carried automatically by automated ACME client.

```POST /delete```

#### Required headers
| Header name | Description | Example |
| ------------- |--------------------------------------------|-------------------------------------------------------|
| X-Api-User | UUIDv4 username received from registration | `X-Api-User: c36f50e8-4632-44f0-83fe-e070fef28a10` |
| X-Api-Key | Password received from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` |

#### Example input
```json
{
"subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a",
"txt": "___validation_token_received_from_the_ca___"
}
```

#### Response

```Status: 200 OK```
```json
{
"txt": "___validation_token_received_from_the_ca___"
}
```

### Health check endpoint

The method can be used to check readiness and/or liveness of the server. It will return status code 200 on success or won't be reachable.
Expand Down
36 changes: 36 additions & 0 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,42 @@ func webUpdatePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
_, _ = w.Write(upd)
}

func webDeletePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var delStatus int
var del []byte
// Get user
a, ok := r.Context().Value(ACMETxtKey).(ACMETxt)
if !ok {
log.WithFields(log.Fields{"error": "context"}).Error("Context error")
}
// NOTE: An invalid subdomain should not happen - the auth handler should
// reject POSTs with an invalid subdomain before this handler. Reject any
// invalid subdomains anyway as a matter of caution.
if !validSubdomain(a.Subdomain) {
log.WithFields(log.Fields{"error": "subdomain", "subdomain": a.Subdomain, "txt": a.Value}).Debug("Bad delete data")
delStatus = http.StatusBadRequest
del = jsonError("bad_subdomain")
} else if !validTXT(a.Value) {
log.WithFields(log.Fields{"error": "txt", "subdomain": a.Subdomain, "txt": a.Value}).Debug("Bad delete data")
delStatus = http.StatusBadRequest
del = jsonError("bad_txt")
} else if validSubdomain(a.Subdomain) && validTXT(a.Value) {
err := DB.Delete(a.ACMETxtPost)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error while trying to delete record")
delStatus = http.StatusInternalServerError
del = jsonError("db_error")
} else {
log.WithFields(log.Fields{"subdomain": a.Subdomain, "txt": a.Value}).Debug("TXT deleted")
delStatus = http.StatusOK
del = []byte("{\"txt\": \"" + a.Value + "\"}")
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(delStatus)
_, _ = w.Write(del)
}

// Endpoint used to check the readiness and/or liveness (health) of the server.
func healthCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.WriteHeader(http.StatusOK)
Expand Down
243 changes: 242 additions & 1 deletion api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,10 @@ func setupRouter(debug bool, noauth bool) http.Handler {
api.GET("/health", healthCheck)
if noauth {
api.POST("/update", noAuth(webUpdatePost))
api.POST("/delete", noAuth(webDeletePost))
} else {
api.POST("/update", Auth(webUpdatePost))
api.POST("/delete", Auth(webDeletePost))
}
return c.Handler(api)
}
Expand Down Expand Up @@ -221,6 +223,36 @@ func TestApiUpdateWithInvalidSubdomain(t *testing.T) {
ValueEqual("error", "forbidden")
}

func TestApiDeleteWithInvalidSubdomain(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}

router := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
newUser, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// Invalid subdomain data
updateJSON["subdomain"] = "example.com"
updateJSON["txt"] = validTxtData
e.POST("/delete").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(http.StatusUnauthorized).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt").
ValueEqual("error", "forbidden")
}

func TestApiUpdateWithInvalidTxt(t *testing.T) {
invalidTXTData := "idk m8 bbl lmao"

Expand Down Expand Up @@ -251,6 +283,36 @@ func TestApiUpdateWithInvalidTxt(t *testing.T) {
ValueEqual("error", "bad_txt")
}

func TestApiDeleteWithInvalidTxt(t *testing.T) {
invalidTXTData := "idk m8 bbl lmao"

updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}

router := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
newUser, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
updateJSON["subdomain"] = newUser.Subdomain
// Invalid txt data
updateJSON["txt"] = invalidTXTData
e.POST("/delete").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(http.StatusBadRequest).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt").
ValueEqual("error", "bad_txt")
}

func TestApiUpdateWithoutCredentials(t *testing.T) {
router := setupRouter(false, false)
server := httptest.NewServer(router)
Expand All @@ -263,6 +325,18 @@ func TestApiUpdateWithoutCredentials(t *testing.T) {
NotContainsKey("txt")
}

func TestApiDeleteWithoutCredentials(t *testing.T) {
router := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
e.POST("/delete").Expect().
Status(http.StatusUnauthorized).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt")
}

func TestApiUpdateWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

Expand Down Expand Up @@ -293,6 +367,36 @@ func TestApiUpdateWithCredentials(t *testing.T) {
ValueEqual("txt", validTxtData)
}

func TestApiDeleteWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}

router := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
newUser, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// Valid data
updateJSON["subdomain"] = newUser.Subdomain
updateJSON["txt"] = validTxtData
e.POST("/delete").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(http.StatusOK).
JSON().Object().
ContainsKey("txt").
NotContainsKey("error").
ValueEqual("txt", validTxtData)
}

func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
Expand All @@ -312,7 +416,7 @@ func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
DB.SetBackend(db)
defer db.Close()
mock.ExpectBegin()
mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error"))
mock.ExpectPrepare("INSERT INTO txt").WillReturnError(errors.New("error"))
e.POST("/update").
WithJSON(updateJSON).
Expect().
Expand All @@ -322,6 +426,35 @@ func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
DB.SetBackend(oldDb)
}

func TestApiDeleteWithCredentialsMockDB(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}

// Valid data
updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8"
updateJSON["txt"] = validTxtData

router := setupRouter(false, true)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
oldDb := DB.GetBackend()
db, mock, _ := sqlmock.New()
DB.SetBackend(db)
defer db.Close()
mock.ExpectBegin()
mock.ExpectPrepare("DELETE FROM txt").WillReturnError(errors.New("error"))
e.POST("/delete").
WithJSON(updateJSON).
Expect().
Status(http.StatusInternalServerError).
JSON().Object().
ContainsKey("error")
DB.SetBackend(oldDb)
}

func TestApiManyUpdateWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

Expand Down Expand Up @@ -379,6 +512,63 @@ func TestApiManyUpdateWithCredentials(t *testing.T) {
}
}

func TestApiManyDeleteWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"

router := setupRouter(true, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
// 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{"10.1.2.3/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err)
}

for _, test := range []struct {
user string
pass string
subdomain string
txt interface{}
status int
}{
{"non-uuid-user", "tooshortpass", "non-uuid-subdomain", validTxtData, 401},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "tooshortpass", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "LongEnoughPassButNoUserExists___________", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{newUser.Username.String(), newUser.Password, "a097455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{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{}{
"subdomain": test.subdomain,
"txt": test.txt}
e.POST("/delete").
WithJSON(updateJSON).
WithHeader("X-Api-User", test.user).
WithHeader("X-Api-Key", test.pass).
WithHeader("X-Forwarded-For", "10.1.2.3").
Expect().
Status(test.status)
}
}

func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {

router := setupRouter(false, false)
Expand Down Expand Up @@ -431,6 +621,57 @@ func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
Config.API.UseHeader = false
}

func TestApiManyDeleteWithIpCheckHeaders(t *testing.T) {
router := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
// Use header checks from default header (X-Forwarded-For)
Config.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)
}

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
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},
{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,
"txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
e.POST("/delete").
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)
}
Config.API.UseHeader = false
}

func TestApiHealthCheck(t *testing.T) {
router := setupRouter(false, false)
server := httptest.NewServer(router)
Expand Down
Loading