Merge pull request 'feat: save zonefile to permanent directory' (#28) from nds into master

Reviewed-on: https://git.freecumextremist.com/grumbulon/pomme/pulls/28
This commit is contained in:
grumbulon 2023-02-06 14:57:27 +00:00
commit 24b6783fc9
16 changed files with 150 additions and 339 deletions

4
.gitignore vendored
View file

@ -26,4 +26,6 @@ test.db
test.sqlite
pomme.sqlite
.dccache
config.yaml
config.yaml
zones/*

View file

@ -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
testdb: test.sqlite
zonedir: zones

View file

@ -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"
],

View file

@ -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"
],

View file

@ -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:

2
go.mod
View file

@ -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

2
go.sum
View file

@ -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=

View file

@ -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

View file

@ -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)
}
}

57
internal/api/fs.go Normal file
View file

@ -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
}

View file

@ -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")

View file

@ -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)

View file

@ -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 {

15
internal/sys.go Normal file
View file

@ -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
}

View file

@ -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]

View file

@ -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
}