diff --git a/.gitignore b/.gitignore index 6c5e775..c2f184e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,6 @@ test.db test.sqlite pomme.sqlite .dccache -config.yaml \ No newline at end of file +config.yaml + +zones/* \ No newline at end of file diff --git a/config.sample.yaml b/config.sample.yaml index 94d008c..15c7b66 100644 --- a/config.sample.yaml +++ b/config.sample.yaml @@ -2,4 +2,5 @@ server: example.com # does nothing yet hashingsecret: PasswordHashingSecret port: 3008 # port the server runs on db: pomme.sqlite -testdb: test.sqlite \ No newline at end of file +testdb: test.sqlite +zonedir: zones \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 9c7572d..22f2570 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1,5 +1,4 @@ -// Package docs GENERATED BY SWAG; DO NOT EDIT -// This file was generated by swaggo/swag +// Code generated by swaggo/swag. DO NOT EDIT package docs import "github.com/swaggo/swag" @@ -62,56 +61,6 @@ const docTemplate = `{ } } }, - "/api/parse": { - "post": { - "security": [ - { - "Bearer": [] - } - ], - "description": "parse your zonefile\nRate limited: 10 requests every 10 second\nyou must specify \"Bearer\" before entering your token", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DNS" - ], - "summary": "parse your zonefile", - "parameters": [ - { - "type": "string", - "description": "Zonefile name", - "name": "filename", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Bearer Token", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/internal.SwaggerGenericResponse-internal_Response" - } - }, - "500": { - "description": "internalServerError is a 500 server error with a logged error call back", - "schema": { - "$ref": "#/definitions/internal.SwaggerGenericResponse-internal_Response" - } - } - } - } - }, "/api/upload": { "post": { "security": [ @@ -119,7 +68,7 @@ const docTemplate = `{ "Bearer": [] } ], - "description": "upload takes files from the user and stores it locally to be parsed. Uploads are associated with a specific user.\nRate limited: 10 requests every 10 second\nyou must specify \"Bearer\" before entering your token", + "description": "Upload takes a multipart form file as user input. It must not exceed 1 mb and must be of text/plain content type.\nIf a file uploads successfully it will be saved locally and parsed.\nIf parsing is successful pomme will save the file to your ZoneDir defined in your config.\nUploads are associated with a specific user.\nRate limited: 10 requests every 10 second\nyou must specify \"Bearer\" before entering your token", "consumes": [ "multipart/form-data" ], diff --git a/docs/swagger.json b/docs/swagger.json index be72e9e..3a83f84 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -53,56 +53,6 @@ } } }, - "/api/parse": { - "post": { - "security": [ - { - "Bearer": [] - } - ], - "description": "parse your zonefile\nRate limited: 10 requests every 10 second\nyou must specify \"Bearer\" before entering your token", - "consumes": [ - "multipart/form-data" - ], - "produces": [ - "application/json" - ], - "tags": [ - "DNS" - ], - "summary": "parse your zonefile", - "parameters": [ - { - "type": "string", - "description": "Zonefile name", - "name": "filename", - "in": "query", - "required": true - }, - { - "type": "string", - "description": "Bearer Token", - "name": "Authorization", - "in": "header", - "required": true - } - ], - "responses": { - "200": { - "description": "OK", - "schema": { - "$ref": "#/definitions/internal.SwaggerGenericResponse-internal_Response" - } - }, - "500": { - "description": "internalServerError is a 500 server error with a logged error call back", - "schema": { - "$ref": "#/definitions/internal.SwaggerGenericResponse-internal_Response" - } - } - } - } - }, "/api/upload": { "post": { "security": [ @@ -110,7 +60,7 @@ "Bearer": [] } ], - "description": "upload takes files from the user and stores it locally to be parsed. Uploads are associated with a specific user.\nRate limited: 10 requests every 10 second\nyou must specify \"Bearer\" before entering your token", + "description": "Upload takes a multipart form file as user input. It must not exceed 1 mb and must be of text/plain content type.\nIf a file uploads successfully it will be saved locally and parsed.\nIf parsing is successful pomme will save the file to your ZoneDir defined in your config.\nUploads are associated with a specific user.\nRate limited: 10 requests every 10 second\nyou must specify \"Bearer\" before entering your token", "consumes": [ "multipart/form-data" ], diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ea7667b..3e804b4 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -50,48 +50,15 @@ paths: summary: authenticate as a regular user tags: - accounts - /api/parse: - post: - consumes: - - multipart/form-data - description: |- - parse your zonefile - Rate limited: 10 requests every 10 second - you must specify "Bearer" before entering your token - parameters: - - description: Zonefile name - in: query - name: filename - required: true - type: string - - description: Bearer Token - in: header - name: Authorization - required: true - type: string - produces: - - application/json - responses: - "200": - description: OK - schema: - $ref: '#/definitions/internal.SwaggerGenericResponse-internal_Response' - "500": - description: internalServerError is a 500 server error with a logged error - call back - schema: - $ref: '#/definitions/internal.SwaggerGenericResponse-internal_Response' - security: - - Bearer: [] - summary: parse your zonefile - tags: - - DNS /api/upload: post: consumes: - multipart/form-data description: |- - upload takes files from the user and stores it locally to be parsed. Uploads are associated with a specific user. + Upload takes a multipart form file as user input. It must not exceed 1 mb and must be of text/plain content type. + If a file uploads successfully it will be saved locally and parsed. + If parsing is successful pomme will save the file to your ZoneDir defined in your config. + Uploads are associated with a specific user. Rate limited: 10 requests every 10 second you must specify "Bearer" before entering your token parameters: diff --git a/go.mod b/go.mod index 59a8587..125c3a7 100644 --- a/go.mod +++ b/go.mod @@ -53,7 +53,7 @@ require ( github.com/swaggo/http-swagger v1.3.3 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect golang.org/x/net v0.5.0 // indirect - golang.org/x/sys v0.4.0 // indirect + golang.org/x/sys v0.4.0 golang.org/x/tools v0.1.12 // indirect gopkg.in/yaml.v3 v3.0.1 modernc.org/libc v1.21.5 // indirect diff --git a/go.sum b/go.sum index b13400f..118962c 100644 --- a/go.sum +++ b/go.sum @@ -185,8 +185,6 @@ gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg= -gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.24.5 h1:g6OPREKqqlWq4kh/3MCQbZKImeB9e6Xgc4zD+JgNZGE= gorm.io/gorm v1.24.5/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= diff --git a/internal/api/api.go b/internal/api/api.go index e0026ef..f257a6e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -34,7 +34,6 @@ func API() http.Handler { api.Use(jwtauth.Authenticator) api.With(setDBMiddleware).Post("/upload", ReceiveFile) - api.With(setDBMiddleware).Post("/parse", ParseZoneFiles) }) // Open routes diff --git a/internal/api/api_test.go b/internal/api/api_test.go index e894ea9..9500dc7 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -313,40 +313,6 @@ func (a *accountTest) TestUpload(t *testing.T) { assert.Equal(t, tc.expected.response, resp.StatusCode) } - - if tc.name == "Should upload a valid file" { - parse(t, f.Name(), a) - } }) } } - -func parse(t *testing.T, fname string, a *accountTest) { - var target response - - client := http.Client{} - - form := url.Values{} - - form.Add("filename", filepath.Clean(fname)) - - if req, err := http.NewRequest(http.MethodPost, a.url+`/api/parse`, strings.NewReader(form.Encode())); err == nil { - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Authorization", `Bearer:`+a.token) - req.Header.Add("User-Agent", "pomme-api-test-slave") - - resp, err := client.Do(req) - if err != nil { - assert.NotNil(t, err) - } - - respBody, _ := io.ReadAll(resp.Body) - - err = json.Unmarshal(respBody, &target) - if err != nil { - assert.NotNil(t, err) - } - - assert.Equal(t, http.StatusCreated, resp.StatusCode) - } -} diff --git a/internal/api/fs.go b/internal/api/fs.go new file mode 100644 index 0000000..3385ab7 --- /dev/null +++ b/internal/api/fs.go @@ -0,0 +1,57 @@ +package api + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "git.freecumextremist.com/grumbulon/pomme/internal" +) + +var errEmptyFile = errors.New("will not save empty file to FS") + +// makeLocal takes a type path and then saves a zone file to either tmp or a permanent location. +func makeLocal(zone *ZoneRequest) error { + if _, err := os.Stat(fmt.Sprintf(zone.FileName, zone.User)); !os.IsNotExist(err) { + return fmt.Errorf("file %s already exists: %w", zone.FileName, err) + } + + if len(zone.Body) == 0 { + return errEmptyFile + } + + c, err := internal.ReadConfig() + if err != nil { + logHandler(genericResponseFields{"error": err.Error(), "message": "no config file defined"}) + + return fmt.Errorf("unable to parse directory: %w", err) + } + + path := fmt.Sprintf("%s/%s/", c.ZoneDir, zone.FileName) + if err = os.MkdirAll(path, 0o750); err != nil { + logHandler(genericResponseFields{"error": err.Error(), "message": "unable to make directory for zone files"}) + + return fmt.Errorf("unable to make zone directory: %w", err) + } + + f, err := os.Create(filepath.Clean(path + zone.FileName)) //nolint: gosec + if err != nil { + return fmt.Errorf("failed to write file locally: %w", err) + } + + // close and remove the temporary file at the end of the program + defer func() { + if err = f.Close(); err != nil { + return + } + }() + + err = os.WriteFile(f.Name(), zone.Body, 0o600) + + if err != nil { + return fmt.Errorf("failed to write file locally: %w", err) + } + + return nil +} diff --git a/internal/api/helpers.go b/internal/api/helpers.go index cd26208..1918f47 100644 --- a/internal/api/helpers.go +++ b/internal/api/helpers.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "syscall" "time" "git.freecumextremist.com/grumbulon/pomme/internal" @@ -35,7 +34,7 @@ func setDBMiddleware(next http.Handler) http.Handler { if err != nil && !ok { logHandler(genericResponseFields{"error": err.Error(), "message": "Error initializing DB"}) - err = syscall.Kill(syscall.Getpid(), syscall.SIGINT) + err = internal.SysKill() if err != nil { panic("idk what to do with this but syscall.Kill errored out.") } @@ -48,7 +47,7 @@ func setDBMiddleware(next http.Handler) http.Handler { }) } -func APIError[T map[string]any](w http.ResponseWriter, r *http.Request, v map[string]any) { +func APIError[T ~map[string]any](w http.ResponseWriter, r *http.Request, v T) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("Content-Type", "application/json; charset=utf-8") diff --git a/internal/api/types.go b/internal/api/types.go index a56a1cc..4efa452 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -6,25 +6,48 @@ const ( keyPrincipalContextID key = iota ) -type genericResponseFields map[string]any - type key int -// ZoneRequest represents a Zone file request. +// ZoneRequest represents a new zone file request. +// +// Inside it is a pointer to the zone struct, which contains zone file information, and a User field to keep track of whom owns the file/request. type ZoneRequest struct { *Zone User string `json:"user,omitempty" gorm:"foreignKey:username;references:User"` } -// Zone struct represents a zonefile. +// Zone struct represents a zone file. type Zone struct { gorm.Model - FileName string `json:"name"` - RawFileName string `json:"rawname"` - Body string `json:"body,omitempty"` + + // FileName is the file name for an uploaded zone file how it is expected to show up on the filesystem + FileName string `json:"name"` + + // Body is the bytes array of a zone files body for copying and moving it around + Body []byte `json:"body,omitempty"` } +// GenericResponse is a generics wrapper to send responses for API Errors. type GenericResponse[T map[string]any] struct { Response map[string]any `json:"response,omitempty"` } + +// instead of calling map[string]any{...} you can call genericResponseFields{...} when making a generic response above. +type genericResponseFields map[string]any + +// ndr is an interface for new DNS requests. It's methods can be used with a ZoneRequest object. +type ndr interface { + // parse() is a wrapper around miekg's NewZoneParser, which is used to validate uploaded zone files + // + // if no error is raised the zone file can be saved. + parse() error + + // save() is a wrapper around internal.makeLocal() which will save a valid non-empty zone file to the filesystem + // + // the file is saved to a location the user sets up in their config.yaml file, + // once saved it can be consumed by something like NSD. + save() error +} + +var _ ndr = (*ZoneRequest)(nil) diff --git a/internal/api/zone.go b/internal/api/zone.go index 489d719..99b3e2b 100644 --- a/internal/api/zone.go +++ b/internal/api/zone.go @@ -1,16 +1,13 @@ package api import ( - "bytes" "fmt" "io" "log" "net/http" - "os" "strings" "git.freecumextremist.com/grumbulon/pomme/internal" - "git.freecumextremist.com/grumbulon/pomme/internal/util" "github.com/go-chi/jwtauth/v5" "github.com/go-chi/render" "github.com/miekg/dns" @@ -20,7 +17,10 @@ import ( // Upload godoc // // @Summary upload a zonefile -// @Description upload takes files from the user and stores it locally to be parsed. Uploads are associated with a specific user. +// @Description Upload takes a multipart form file as user input. It must not exceed 1 mb and must be of text/plain content type. +// @Description If a file uploads successfully it will be saved locally and parsed. +// @Description If parsing is successful pomme will save the file to your ZoneDir defined in your config. +// @Description Uploads are associated with a specific user. // // @Description Rate limited: 10 requests every 10 second // @Description you must specify "Bearer" before entering your token @@ -39,8 +39,6 @@ import ( func ReceiveFile(w http.ResponseWriter, r *http.Request) { _, claims, _ := jwtauth.FromContext(r.Context()) - var buf bytes.Buffer - r.Body = http.MaxBytesReader(w, r.Body, 1*1024*1024) // approx 1 mb max upload file, header, err := r.FormFile("file") @@ -52,23 +50,24 @@ func ReceiveFile(w http.ResponseWriter, r *http.Request) { defer file.Close() //nolint: errcheck + b, err := io.ReadAll(file) + if err != nil { + APIError(w, r, genericResponseFields{"message": "internal server error", "status": http.StatusInternalServerError, "error": err.Error()}) + + return + } + ok := validateContentType(file) if !ok { - http.Error(w, "file must be text/plain", http.StatusUnsupportedMediaType) + APIError(w, r, genericResponseFields{"message": "file must be text/plain", "status": http.StatusUnsupportedMediaType}) return } - name := strings.Split(header.Filename, ".") + zoneFile := newDNSRequest(header.Filename, claims["username"].(string), b) - if _, err = io.Copy(&buf, file); err != nil { - APIError(w, r, genericResponseFields{"message": "internal server error", "status": http.StatusInternalServerError, "error": err.Error()}) - - return - } - - if err = util.MakeLocal(name[0], claims["username"].(string), buf); err != nil { - APIError(w, r, genericResponseFields{"message": "internal server error", "status": http.StatusInternalServerError, "error": err.Error()}) + if err := zoneFile.parse(); err != nil { + APIError(w, r, genericResponseFields{"message": "Unable to parse zonefile", "status": http.StatusInternalServerError, "error": err.Error()}) return } @@ -84,12 +83,15 @@ func ReceiveFile(w http.ResponseWriter, r *http.Request) { &ZoneRequest{ User: claims["username"].(string), Zone: &Zone{ - FileName: fmt.Sprintf("tmpfile-%s-%s", name[0], claims["username"].(string)), - RawFileName: name[0], + FileName: header.Filename, }, }) - buf.Reset() + if err := zoneFile.save(); err != nil { + APIError(w, r, genericResponseFields{"message": "Unable to save zonefile", "status": http.StatusInternalServerError, "error": err.Error()}) + + return + } w.Header().Set("Content-Type", "application/json") @@ -102,103 +104,18 @@ func ReceiveFile(w http.ResponseWriter, r *http.Request) { render.JSON(w, r, resp) } -// Parse godoc -// -// @Summary parse your zonefile -// @Description parse your zonefile -// -// @Description Rate limited: 10 requests every 10 second -// @Description you must specify "Bearer" before entering your token -// -// @Tags DNS -// @Accept mpfd -// @Produce json -// @Param filename query string true "Zonefile name" -// @Success 200 {object} internal.SwaggerGenericResponse[internal.Response] -// @Failure 500 {object} internal.SwaggerGenericResponse[internal.Response] "internalServerError is a 500 server error with a logged error call back" -// @Param Authorization header string true "Bearer Token" -// -// @Security Bearer -// -// @Router /api/parse [post] -func ParseZoneFiles(w http.ResponseWriter, r *http.Request) { - var result internal.ZoneRequest - - _, claims, _ := jwtauth.FromContext(r.Context()) - - err := r.ParseForm() - if err != nil { - APIError(w, r, genericResponseFields{"message": "internal server error", "status": http.StatusInternalServerError, "error": err.Error()}) - - return - } - - filename := r.Form.Get("filename") - - if filename == "" { - APIError(w, r, genericResponseFields{"message": "no filename provided", "status": http.StatusInternalServerError}) - - return - } - - db, ok := r.Context().Value(keyPrincipalContextID).(*gorm.DB) - if !ok { - APIError(w, r, genericResponseFields{"message": "internal server error", "status": http.StatusInternalServerError, "error": "unable to connect to DB"}) - - return - } - - db.Where(ZoneRequest{ - Zone: &Zone{ - RawFileName: filename, - }, - User: claims["username"].(string), - }).First(&result) - - if result == (internal.ZoneRequest{}) { - APIError(w, r, genericResponseFields{"message": "internal server error", "status": http.StatusInternalServerError}) - - return - } - - zoneFile := newZoneRequest(result.RawFileName, claims["username"].(string)) - - if err := zoneFile.Parse(); err != nil { - APIError(w, r, genericResponseFields{"message": "Unable to parse zonefile", "status": http.StatusInternalServerError, "error": err.Error()}) - - return - } - - w.Header().Set("Content-Type", "application/json; charset=utf-8") - - w.WriteHeader(http.StatusCreated) - - resp := internal.Response{ - Message: "Successfully parsed zonefile", - } - - render.JSON(w, r, resp) -} - -func newZoneRequest(filename string, user string) *ZoneRequest { - dat, err := os.ReadFile(fmt.Sprintf("/tmp/tmpfile-%s-%s", filename, user)) - if err != nil { - return &ZoneRequest{} - } - +func newDNSRequest(filename string, user string, dat []byte) ndr { return &ZoneRequest{ User: user, Zone: &Zone{ - FileName: fmt.Sprintf("tmpfile-%s-%s", filename, user), - RawFileName: filename, - Body: string(dat), + FileName: filename, + Body: dat, }, } } -// Parse will be used to parse zonefiles. -func (zone *ZoneRequest) Parse() error { - zp := dns.NewZoneParser(strings.NewReader(zone.Body), "", "") +func (zone *ZoneRequest) parse() error { + zp := dns.NewZoneParser(strings.NewReader(string(zone.Body)), "", "") for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { log.Println(rr) @@ -211,6 +128,10 @@ func (zone *ZoneRequest) Parse() error { return nil } +func (zone *ZoneRequest) save() error { + return makeLocal(zone) +} + func validateContentType(file io.Reader) bool { bytes, err := io.ReadAll(file) if err != nil { diff --git a/internal/sys.go b/internal/sys.go new file mode 100644 index 0000000..943b7d0 --- /dev/null +++ b/internal/sys.go @@ -0,0 +1,15 @@ +package internal + +import "golang.org/x/sys/unix" + +func SysKill() (err error) { + err = killPomme() + + return +} + +func killPomme() (err error) { + err = unix.Kill(unix.Getpid(), unix.SIGINT) + + return +} diff --git a/internal/types.go b/internal/types.go index 5d049e8..e54431c 100644 --- a/internal/types.go +++ b/internal/types.go @@ -24,9 +24,8 @@ type ZoneRequest struct { // Zone struct represents a zonefile in the database. type Zone struct { gorm.Model - FileName string `json:"name"` - RawFileName string `json:"rawfilename"` - Body string `json:"body"` + FileName string `json:"name"` + Body string `json:"body"` } type Config struct { @@ -35,6 +34,7 @@ type Config struct { Port string DB string TestDB string + ZoneDir string } // SwaggerGenericResponse[T] diff --git a/internal/util/fs.go b/internal/util/fs.go deleted file mode 100644 index c46b092..0000000 --- a/internal/util/fs.go +++ /dev/null @@ -1,36 +0,0 @@ -package util - -import ( - "bytes" - "fmt" - "os" -) - -func MakeLocal(filename, username string, buf bytes.Buffer) error { - if _, err := os.Stat(fmt.Sprintf("/tmp/tmpfile-%s-%s", filename, username)); !os.IsNotExist(err) { - return fmt.Errorf("file %s already exists: %w", filename, err) - } - - defer buf.Reset() - - f, err := os.Create("/tmp/tmpfile-" + filename + "-" + username) //nolint: gosec - // this is set to nolint because I am doing everything os.CreateTemp does but here since I don't like some of the limitations - if err != nil { - return fmt.Errorf("failed to write file locally: %w", err) - } - - // close and remove the temporary file at the end of the program - defer func() { - if err = f.Close(); err != nil { - return - } - }() - - err = os.WriteFile(f.Name(), buf.Bytes(), 0o600) - - if err != nil { - return fmt.Errorf("failed to write file locally: %w", err) - } - - return nil -}