Skip to content

Commit 3a0e6d9

Browse files
committed
Allow set-and-delete flow that is usually used by acme-clients joohoi#270
1 parent 64147e3 commit 3a0e6d9

9 files changed

+848
-121
lines changed

README.md

+32-1
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ With the credentials, you can update the TXT response in the service to match th
7777

7878
### Update endpoint
7979

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

8282
```POST /update```
8383

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

107+
108+
### Delete endpoint
109+
110+
This method allows you to delete TXT records of your unique subdomain after they have been used for validation.
111+
Usually carried automatically by automated ACME client.
112+
113+
```POST /delete```
114+
115+
#### Required headers
116+
| Header name | Description | Example |
117+
| ------------- |--------------------------------------------|-------------------------------------------------------|
118+
| X-Api-User | UUIDv4 username received from registration | `X-Api-User: c36f50e8-4632-44f0-83fe-e070fef28a10` |
119+
| X-Api-Key | Password received from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` |
120+
121+
#### Example input
122+
```json
123+
{
124+
"subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a",
125+
"txt": "___validation_token_received_from_the_ca___"
126+
}
127+
```
128+
129+
#### Response
130+
131+
```Status: 200 OK```
132+
```json
133+
{
134+
"txt": "___validation_token_received_from_the_ca___"
135+
}
136+
```
137+
107138
### Health check endpoint
108139

109140
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.

api.go

+36
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,42 @@ func webUpdatePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params)
107107
_, _ = w.Write(upd)
108108
}
109109

110+
func webDeletePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
111+
var delStatus int
112+
var del []byte
113+
// Get user
114+
a, ok := r.Context().Value(ACMETxtKey).(ACMETxt)
115+
if !ok {
116+
log.WithFields(log.Fields{"error": "context"}).Error("Context error")
117+
}
118+
// NOTE: An invalid subdomain should not happen - the auth handler should
119+
// reject POSTs with an invalid subdomain before this handler. Reject any
120+
// invalid subdomains anyway as a matter of caution.
121+
if !validSubdomain(a.Subdomain) {
122+
log.WithFields(log.Fields{"error": "subdomain", "subdomain": a.Subdomain, "txt": a.Value}).Debug("Bad delete data")
123+
delStatus = http.StatusBadRequest
124+
del = jsonError("bad_subdomain")
125+
} else if !validTXT(a.Value) {
126+
log.WithFields(log.Fields{"error": "txt", "subdomain": a.Subdomain, "txt": a.Value}).Debug("Bad delete data")
127+
delStatus = http.StatusBadRequest
128+
del = jsonError("bad_txt")
129+
} else if validSubdomain(a.Subdomain) && validTXT(a.Value) {
130+
err := DB.Delete(a.ACMETxtPost)
131+
if err != nil {
132+
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error while trying to delete record")
133+
delStatus = http.StatusInternalServerError
134+
del = jsonError("db_error")
135+
} else {
136+
log.WithFields(log.Fields{"subdomain": a.Subdomain, "txt": a.Value}).Debug("TXT deleted")
137+
delStatus = http.StatusOK
138+
del = []byte("{\"txt\": \"" + a.Value + "\"}")
139+
}
140+
}
141+
w.Header().Set("Content-Type", "application/json")
142+
w.WriteHeader(delStatus)
143+
_, _ = w.Write(del)
144+
}
145+
110146
// Endpoint used to check the readiness and/or liveness (health) of the server.
111147
func healthCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
112148
w.WriteHeader(http.StatusOK)

api_test.go

+242-1
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,10 @@ func setupRouter(debug bool, noauth bool) http.Handler {
7474
api.GET("/health", healthCheck)
7575
if noauth {
7676
api.POST("/update", noAuth(webUpdatePost))
77+
api.POST("/delete", noAuth(webDeletePost))
7778
} else {
7879
api.POST("/update", Auth(webUpdatePost))
80+
api.POST("/delete", Auth(webDeletePost))
7981
}
8082
return c.Handler(api)
8183
}
@@ -221,6 +223,36 @@ func TestApiUpdateWithInvalidSubdomain(t *testing.T) {
221223
ValueEqual("error", "forbidden")
222224
}
223225

226+
func TestApiDeleteWithInvalidSubdomain(t *testing.T) {
227+
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
228+
229+
updateJSON := map[string]interface{}{
230+
"subdomain": "",
231+
"txt": ""}
232+
233+
router := setupRouter(false, false)
234+
server := httptest.NewServer(router)
235+
defer server.Close()
236+
e := getExpect(t, server)
237+
newUser, err := DB.Register(cidrslice{})
238+
if err != nil {
239+
t.Errorf("Could not create new user, got error [%v]", err)
240+
}
241+
// Invalid subdomain data
242+
updateJSON["subdomain"] = "example.com"
243+
updateJSON["txt"] = validTxtData
244+
e.POST("/delete").
245+
WithJSON(updateJSON).
246+
WithHeader("X-Api-User", newUser.Username.String()).
247+
WithHeader("X-Api-Key", newUser.Password).
248+
Expect().
249+
Status(http.StatusUnauthorized).
250+
JSON().Object().
251+
ContainsKey("error").
252+
NotContainsKey("txt").
253+
ValueEqual("error", "forbidden")
254+
}
255+
224256
func TestApiUpdateWithInvalidTxt(t *testing.T) {
225257
invalidTXTData := "idk m8 bbl lmao"
226258

@@ -251,6 +283,36 @@ func TestApiUpdateWithInvalidTxt(t *testing.T) {
251283
ValueEqual("error", "bad_txt")
252284
}
253285

286+
func TestApiDeleteWithInvalidTxt(t *testing.T) {
287+
invalidTXTData := "idk m8 bbl lmao"
288+
289+
updateJSON := map[string]interface{}{
290+
"subdomain": "",
291+
"txt": ""}
292+
293+
router := setupRouter(false, false)
294+
server := httptest.NewServer(router)
295+
defer server.Close()
296+
e := getExpect(t, server)
297+
newUser, err := DB.Register(cidrslice{})
298+
if err != nil {
299+
t.Errorf("Could not create new user, got error [%v]", err)
300+
}
301+
updateJSON["subdomain"] = newUser.Subdomain
302+
// Invalid txt data
303+
updateJSON["txt"] = invalidTXTData
304+
e.POST("/delete").
305+
WithJSON(updateJSON).
306+
WithHeader("X-Api-User", newUser.Username.String()).
307+
WithHeader("X-Api-Key", newUser.Password).
308+
Expect().
309+
Status(http.StatusBadRequest).
310+
JSON().Object().
311+
ContainsKey("error").
312+
NotContainsKey("txt").
313+
ValueEqual("error", "bad_txt")
314+
}
315+
254316
func TestApiUpdateWithoutCredentials(t *testing.T) {
255317
router := setupRouter(false, false)
256318
server := httptest.NewServer(router)
@@ -263,6 +325,18 @@ func TestApiUpdateWithoutCredentials(t *testing.T) {
263325
NotContainsKey("txt")
264326
}
265327

328+
func TestApiDeleteWithoutCredentials(t *testing.T) {
329+
router := setupRouter(false, false)
330+
server := httptest.NewServer(router)
331+
defer server.Close()
332+
e := getExpect(t, server)
333+
e.POST("/delete").Expect().
334+
Status(http.StatusUnauthorized).
335+
JSON().Object().
336+
ContainsKey("error").
337+
NotContainsKey("txt")
338+
}
339+
266340
func TestApiUpdateWithCredentials(t *testing.T) {
267341
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
268342

@@ -293,6 +367,36 @@ func TestApiUpdateWithCredentials(t *testing.T) {
293367
ValueEqual("txt", validTxtData)
294368
}
295369

370+
func TestApiDeleteWithCredentials(t *testing.T) {
371+
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
372+
373+
updateJSON := map[string]interface{}{
374+
"subdomain": "",
375+
"txt": ""}
376+
377+
router := setupRouter(false, false)
378+
server := httptest.NewServer(router)
379+
defer server.Close()
380+
e := getExpect(t, server)
381+
newUser, err := DB.Register(cidrslice{})
382+
if err != nil {
383+
t.Errorf("Could not create new user, got error [%v]", err)
384+
}
385+
// Valid data
386+
updateJSON["subdomain"] = newUser.Subdomain
387+
updateJSON["txt"] = validTxtData
388+
e.POST("/delete").
389+
WithJSON(updateJSON).
390+
WithHeader("X-Api-User", newUser.Username.String()).
391+
WithHeader("X-Api-Key", newUser.Password).
392+
Expect().
393+
Status(http.StatusOK).
394+
JSON().Object().
395+
ContainsKey("txt").
396+
NotContainsKey("error").
397+
ValueEqual("txt", validTxtData)
398+
}
399+
296400
func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
297401
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
298402
updateJSON := map[string]interface{}{
@@ -312,7 +416,7 @@ func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
312416
DB.SetBackend(db)
313417
defer db.Close()
314418
mock.ExpectBegin()
315-
mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error"))
419+
mock.ExpectPrepare("INSERT INTO txt").WillReturnError(errors.New("error"))
316420
e.POST("/update").
317421
WithJSON(updateJSON).
318422
Expect().
@@ -322,6 +426,35 @@ func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
322426
DB.SetBackend(oldDb)
323427
}
324428

429+
func TestApiDeleteWithCredentialsMockDB(t *testing.T) {
430+
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
431+
updateJSON := map[string]interface{}{
432+
"subdomain": "",
433+
"txt": ""}
434+
435+
// Valid data
436+
updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8"
437+
updateJSON["txt"] = validTxtData
438+
439+
router := setupRouter(false, true)
440+
server := httptest.NewServer(router)
441+
defer server.Close()
442+
e := getExpect(t, server)
443+
oldDb := DB.GetBackend()
444+
db, mock, _ := sqlmock.New()
445+
DB.SetBackend(db)
446+
defer db.Close()
447+
mock.ExpectBegin()
448+
mock.ExpectPrepare("DELETE FROM txt").WillReturnError(errors.New("error"))
449+
e.POST("/delete").
450+
WithJSON(updateJSON).
451+
Expect().
452+
Status(http.StatusInternalServerError).
453+
JSON().Object().
454+
ContainsKey("error")
455+
DB.SetBackend(oldDb)
456+
}
457+
325458
func TestApiManyUpdateWithCredentials(t *testing.T) {
326459
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
327460

@@ -379,6 +512,63 @@ func TestApiManyUpdateWithCredentials(t *testing.T) {
379512
}
380513
}
381514

515+
func TestApiManyDeleteWithCredentials(t *testing.T) {
516+
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
517+
518+
router := setupRouter(true, false)
519+
server := httptest.NewServer(router)
520+
defer server.Close()
521+
e := getExpect(t, server)
522+
// User without defined CIDR masks
523+
newUser, err := DB.Register(cidrslice{})
524+
if err != nil {
525+
t.Errorf("Could not create new user, got error [%v]", err)
526+
}
527+
528+
// User with defined allow from - CIDR masks, all invalid
529+
// (httpexpect doesn't provide a way to mock remote ip)
530+
newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.1/32", "invalid"})
531+
if err != nil {
532+
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
533+
}
534+
535+
// Another user with valid CIDR mask to match the httpexpect default
536+
newUserWithValidCIDR, err := DB.Register(cidrslice{"10.1.2.3/32", "invalid"})
537+
if err != nil {
538+
t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err)
539+
}
540+
541+
for _, test := range []struct {
542+
user string
543+
pass string
544+
subdomain string
545+
txt interface{}
546+
status int
547+
}{
548+
{"non-uuid-user", "tooshortpass", "non-uuid-subdomain", validTxtData, 401},
549+
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "tooshortpass", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
550+
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "LongEnoughPassButNoUserExists___________", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
551+
{newUser.Username.String(), newUser.Password, "a097455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
552+
{newUser.Username.String(), newUser.Password, newUser.Subdomain, "tooshortfortxt", 400},
553+
{newUser.Username.String(), newUser.Password, newUser.Subdomain, 1234567890, 400},
554+
{newUser.Username.String(), newUser.Password, newUser.Subdomain, validTxtData, 200},
555+
{newUserWithCIDR.Username.String(), newUserWithCIDR.Password, newUserWithCIDR.Subdomain, validTxtData, 401},
556+
{newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200},
557+
{newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401},
558+
} {
559+
updateJSON := map[string]interface{}{
560+
"subdomain": test.subdomain,
561+
"txt": test.txt}
562+
e.POST("/delete").
563+
WithJSON(updateJSON).
564+
WithHeader("X-Api-User", test.user).
565+
WithHeader("X-Api-Key", test.pass).
566+
WithHeader("X-Forwarded-For", "10.1.2.3").
567+
Expect().
568+
Status(test.status)
569+
}
570+
}
571+
382572
func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
383573

384574
router := setupRouter(false, false)
@@ -431,6 +621,57 @@ func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
431621
Config.API.UseHeader = false
432622
}
433623

624+
func TestApiManyDeleteWithIpCheckHeaders(t *testing.T) {
625+
router := setupRouter(false, false)
626+
server := httptest.NewServer(router)
627+
defer server.Close()
628+
e := getExpect(t, server)
629+
// Use header checks from default header (X-Forwarded-For)
630+
Config.API.UseHeader = true
631+
// User without defined CIDR masks
632+
newUser, err := DB.Register(cidrslice{})
633+
if err != nil {
634+
t.Errorf("Could not create new user, got error [%v]", err)
635+
}
636+
637+
newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.2/32", "invalid"})
638+
if err != nil {
639+
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
640+
}
641+
642+
newUserWithIP6CIDR, err := DB.Register(cidrslice{"2002:c0a8::0/32"})
643+
if err != nil {
644+
t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err)
645+
}
646+
647+
for _, test := range []struct {
648+
user ACMETxt
649+
headerValue string
650+
status int
651+
}{
652+
{newUser, "whatever goes", 200},
653+
{newUser, "10.0.0.1, 1.2.3.4 ,3.4.5.6", 200},
654+
{newUserWithCIDR, "127.0.0.1", 401},
655+
{newUserWithCIDR, "10.0.0.1, 10.0.0.2, 192.168.1.3", 401},
656+
{newUserWithCIDR, "10.1.1.1 ,192.168.1.2, 8.8.8.8", 200},
657+
{newUserWithIP6CIDR, "2002:c0a8:b4dc:0d3::0", 200},
658+
{newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401},
659+
{newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200},
660+
} {
661+
updateJSON := map[string]interface{}{
662+
"subdomain": test.user.Subdomain,
663+
"txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
664+
e.POST("/delete").
665+
WithJSON(updateJSON).
666+
WithHeader("X-Api-User", test.user.Username.String()).
667+
WithHeader("X-Api-Key", test.user.Password).
668+
WithHeader("X-Forwarded-For", test.headerValue).
669+
Expect().
670+
Status(test.status)
671+
}
672+
Config.API.UseHeader = false
673+
}
674+
434675
func TestApiHealthCheck(t *testing.T) {
435676
router := setupRouter(false, false)
436677
server := httptest.NewServer(router)

0 commit comments

Comments
 (0)