diff --git a/README.md b/README.md index f6c88ff..a40c709 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,26 @@ With the credentials, you can update the TXT response in the service to match th } ``` +### Unregister endpoint + +This method deletes a registered user and the associated TXT records from the database. The subdomain has to be sent as JSON input. The user's credentials must be provided like with an update update request. + +#### Example input +```json +{ + "subdomain": "0b3736b4-32dd-43c1-8b4f-db117767b3ca" +} +``` + +#### Response + +```Status: 200 OK``` +```json +{ + "unregister": "c2d7e8fd-ff4c-41c6-8f16-1d76628cbfde" +} +``` + ### Update endpoint The method allows you to update the TXT answer contents of your unique subdomain. Usually carried automatically by automated ACME client. diff --git a/api.go b/api.go index edff337..78a3f5b 100644 --- a/api.go +++ b/api.go @@ -6,6 +6,7 @@ import ( "io/ioutil" "net/http" + "github.com/google/uuid" "github.com/julienschmidt/httprouter" log "github.com/sirupsen/logrus" ) @@ -19,6 +20,12 @@ type RegResponse struct { Allowfrom []string `json:"allowfrom"` } +// UnregRequest is a struct providing the data to unregister +type UnregRequest struct { + Username uuid.UUID `json:"username"` + Subdomain string `json:"subdomain"` +} + func webRegisterPost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { var regStatus int var reg []byte @@ -71,6 +78,32 @@ func webRegisterPost(w http.ResponseWriter, r *http.Request, _ httprouter.Params w.Write(reg) } +func webUnregisterPost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { + var unregStatus int + var err error + var upd []byte + + // Get user data + unregData, ok := r.Context().Value(ACMETxtKey).(UnregRequest) + if !ok { + log.WithFields(log.Fields{"error": "context"}).Error("Context error") + } + + // Delete user + err = DB.Unregister(unregData.Username) + if err != nil { + unregStatus = http.StatusInternalServerError + upd = jsonError(fmt.Sprintf("%s (%v)", "delete_error", err)) + } else { + log.WithFields(log.Fields{"user": unregData.Username.String()}).Debug("Deleted user") + upd = []byte("{\"unregister\": \"" + unregData.Username.String() + "\"}") + unregStatus = http.StatusOK + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(unregStatus) + w.Write(upd) +} + func webUpdatePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { var updStatus int var upd []byte diff --git a/api_test.go b/api_test.go index b7cfd0e..4883320 100644 --- a/api_test.go +++ b/api_test.go @@ -73,9 +73,11 @@ func setupRouter(debug bool, noauth bool) http.Handler { api.POST("/register", webRegisterPost) api.GET("/health", healthCheck) if noauth { + api.POST("/unregister", noAuth(webUnregisterPost)) api.POST("/update", noAuth(webUpdatePost)) } else { - api.POST("/update", Auth(webUpdatePost)) + api.POST("/unregister", AuthUnregister(webUnregisterPost)) + api.POST("/update", AuthUpdate(webUpdatePost)) } return c.Handler(api) } @@ -191,6 +193,43 @@ func TestApiRegisterWithMockDB(t *testing.T) { DB.SetBackend(oldDb) } +func TestApiUnregisterWithoutCredentials(t *testing.T) { + router := setupRouter(false, false) + server := httptest.NewServer(router) + defer server.Close() + e := getExpect(t, server) + e.POST("/unregister").Expect(). + Status(http.StatusUnauthorized). + JSON().Object(). + ContainsKey("error") +} + +func TestApiUnregisterInvalidSubdomain(t *testing.T) { + unregisterJSON := map[string]interface{}{ + "subdomain": ""} + + // Invalid username + unregisterJSON["subdomain"] = "example.com" + + 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) + } + e.POST("/unregister"). + WithJSON(unregisterJSON). + WithHeader("X-Api-User", newUser.Username.String()). + WithHeader("X-Api-Key", newUser.Password). + Expect(). + Status(http.StatusUnauthorized). + JSON().Object(). + ContainsKey("error") +} + func TestApiUpdateWithInvalidSubdomain(t *testing.T) { validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" diff --git a/auth.go b/auth.go index 162f927..f95ce47 100644 --- a/auth.go +++ b/auth.go @@ -16,8 +16,8 @@ type key int // ACMETxtKey is a context key for ACMETxt struct const ACMETxtKey key = 0 -// Auth middleware for update request -func Auth(update httprouter.Handle) httprouter.Handle { +// AuthUpdate middleware for update request +func AuthUpdate(update httprouter.Handle) httprouter.Handle { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { postData := ACMETxt{} userOK := false @@ -55,6 +55,40 @@ func Auth(update httprouter.Handle) httprouter.Handle { } } +// AuthUnregister middleware for unregister request +func AuthUnregister(unregister httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + postData := UnregRequest{} + userOK := false + user, err := getUserFromRequest(r) + if err == nil { + dec := json.NewDecoder(r.Body) + err = dec.Decode(&postData) + if err != nil { + log.WithFields(log.Fields{"error": "json_error", "string": err.Error()}).Error("Decode error") + } + if user.Subdomain == postData.Subdomain { + userOK = true + } else { + log.WithFields(log.Fields{"error": "subdomain_mismatch", "name": postData.Subdomain, "expected": user.Subdomain}).Error("Subdomain mismatch") + } + } else { + log.WithFields(log.Fields{"error": err.Error()}).Error("Error while trying to get user") + } + if userOK { + // Set username info to the decoded UnregRequest object + postData.Username = user.Username + // Set the UnregRequest struct to context to pull in from unregister function + ctx := context.WithValue(r.Context(), ACMETxtKey, postData) + unregister(w, r.WithContext(ctx), p) + } else { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write(jsonError("forbidden")) + } + } +} + func getUserFromRequest(r *http.Request) (ACMETxt, error) { uname := r.Header.Get("X-Api-User") passwd := r.Header.Get("X-Api-Key") diff --git a/db.go b/db.go index 358bde7..c76122e 100644 --- a/db.go +++ b/db.go @@ -209,6 +209,58 @@ func (d *acmedb) Register(afrom cidrslice) (ACMETxt, error) { return a, err } +func (d *acmedb) Unregister(username uuid.UUID) error { + var err error + + // Check if user exists + user, err := DB.GetByUsername(username) + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("User doesn't exist") + return errors.New("User doesn't exist") + } + + tx, err := d.DB.Begin() + + // Delete user's TXT records + err = d.DeleteTXTForDomain(user.Subdomain) + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("Could not delete TXT records") + return err + } + + // Rollback if errored, commit if not + defer func() { + if err != nil { + tx.Rollback() + return + } + tx.Commit() + }() + + // Delete user record + d.Lock() + defer d.Unlock() + unregSQL := ` + DELETE FROM records + WHERE Username = $1` + if Config.Database.Engine == "sqlite3" { + unregSQL = getSQLiteStmt(unregSQL) + } + sm, err := tx.Prepare(unregSQL) + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("Database error in prepare") + return errors.New("SQL error") + } + defer sm.Close() + _, err = sm.Exec(username.String()) + if err != nil { + log.WithFields(log.Fields{"error": err.Error()}).Error("Database error in execute") + return errors.New("SQL error") + } + + return nil +} + func (d *acmedb) GetByUsername(u uuid.UUID) (ACMETxt, error) { d.Lock() defer d.Unlock() @@ -309,6 +361,27 @@ func (d *acmedb) Update(a ACMETxtPost) error { return nil } +func (d *acmedb) DeleteTXTForDomain(domain string) error { + d.Lock() + defer d.Unlock() + domain = sanitizeString(domain) + getSQL := `DELETE FROM txt WHERE Subdomain=$1` + if Config.Database.Engine == "sqlite3" { + getSQL = getSQLiteStmt(getSQL) + } + + sm, err := d.DB.Prepare(getSQL) + if err != nil { + return err + } + defer sm.Close() + _, err = sm.Exec(domain) + if err != nil { + return err + } + return nil +} + func getModelFromRow(r *sql.Rows) (ACMETxt, error) { txt := ACMETxt{} afrom := "" diff --git a/main.go b/main.go index e9ca6e2..441f978 100644 --- a/main.go +++ b/main.go @@ -125,8 +125,9 @@ func startHTTPAPI(errChan chan error, config DNSConfig, dnsservers []*DNSServer) } if !Config.API.DisableRegistration { api.POST("/register", webRegisterPost) + api.POST("/unregister", AuthUnregister(webUnregisterPost)) } - api.POST("/update", Auth(webUpdatePost)) + api.POST("/update", AuthUpdate(webUpdatePost)) api.GET("/health", healthCheck) host := Config.API.IP + ":" + Config.API.Port diff --git a/types.go b/types.go index 7ad522c..d4f54f7 100644 --- a/types.go +++ b/types.go @@ -72,6 +72,7 @@ type acmedb struct { type database interface { Init(string, string) error Register(cidrslice) (ACMETxt, error) + Unregister(uuid.UUID) error GetByUsername(uuid.UUID) (ACMETxt, error) GetTXTForDomain(string) ([]string, error) Update(ACMETxtPost) error