Initial Upload

This commit is contained in:
Pacerino
2022-09-26 15:41:00 +02:00
commit 15842e3c45
51 changed files with 40124 additions and 0 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
frontend/node_modules
frontend/.pnp
frontend/.pnp.js
# testing
frontend/coverage
# production
frontend/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
frontend/npm-debug.log*
frontend/yarn-debug.log*
frontend/yarn-error.log*
backend/embed/assets

38
README.md Normal file
View File

@@ -0,0 +1,38 @@
# Caddy Proxy Manager - CPM
## Shoutout
Much was copied from the [original Nginx Proxy Manager](https://github.com/NginxProxyManager/nginx-proxy-manager) and implemented for Caddy. The complete basic idea therefore goes back to the repo of [jc21](https://github.com/jc21) and so the honor goes to him! So please have a look at his repo and his Nginx Proxy Manager too!
<br>
## Idea
This project tries to implement the basic idea of the Nginx Proxy Manager for Caddy and thus provide a web interface for Caddy.
Currently the version is completely unstable and untidy.
Caddy is installed normally on the system and integrates further Caddyfiles via Caddyfile. So a hotreload and caddyfiles per host is possible.
## Current features
- Adding hosts with multiple domains/upstreams
- Delete hosts
## Planned features
- Login with third-party Services like Authelia, Keycloak etc.
- Editing hosts
- Logview
- Manage Plugins
## FAQ
> Why don't you use the API from Caddy itself?
The API of Caddy is not documented and quite complicated for a simple web interface. Many features like HSTS, HTTP/2, SSL etc. are already included in Caddy and don't need to be specially configured.
> How can I use CPM?
You have to compile CPM yourself. The frontend is based on ReactJS with Typescript, the compiled frontend must then be added under ``backend/assets``. The backend is written in GoLang and can be easily compiled using ``go build cmd/main.go``.
## Contribution
If you want to help with the development pull requests etc. are welcome!

38
backend/cmd/main.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"os"
"os/signal"
"syscall"
"github.com/Pacerino/cpm/internal/api"
"github.com/Pacerino/cpm/internal/config"
"github.com/Pacerino/cpm/internal/database"
"github.com/Pacerino/cpm/internal/jobqueue"
"github.com/Pacerino/cpm/internal/logger"
)
var (
version = "3.0.0"
commit = "abcdefgh"
)
func main() {
config.Init(&version, &commit)
database.NewDB()
jobqueue.Start()
// HTTP Server
api.StartServer()
// Clean Quit
irqchan := make(chan os.Signal, 1)
signal.Notify(irqchan, syscall.SIGINT, syscall.SIGTERM)
for irq := range irqchan {
if irq == syscall.SIGINT || irq == syscall.SIGTERM {
logger.Info("Got ", irq, " shutting server down ...")
break
}
}
}

View File

@@ -0,0 +1,15 @@
{{host.domains}} {
{{#if host.upstreams}}
{{#if host.matcher}}
reverse_proxy {{host.matcher}} {{#each host.upstreams}} {{backend}} {{/each}}
{{else}}
reverse_proxy {{#each host.upstreams}} {{backend}} {{/each}}
{{/if}}
{{/if}}
{{#if LogPath}}
log {
output file {{LogPath}}
}
{{/if}}
}

13
backend/embed/main.go Normal file
View File

@@ -0,0 +1,13 @@
package embed
import "embed"
// Frontend Files served by this app
//
//go:embed all:assets/**
var Assets embed.FS
// CaddyFiles hold all template for caddy
//
//go:embed caddy
var CaddyFiles embed.FS

34
backend/go.mod Normal file
View File

@@ -0,0 +1,34 @@
module github.com/Pacerino/cpm
go 1.19
require (
github.com/aymerick/raymond v2.0.2+incompatible
github.com/go-playground/validator/v10 v10.11.1
gorm.io/driver/sqlite v1.3.6
gorm.io/gorm v1.23.9
)
require (
github.com/go-playground/locales v0.14.0 // indirect
github.com/go-playground/universal-translator v0.18.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/leodido/go-urn v1.2.1 // indirect
github.com/mattn/go-colorable v0.1.9 // indirect
github.com/mattn/go-isatty v0.0.14 // indirect
github.com/qri-io/jsonpointer v0.1.1 // indirect
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
golang.org/x/text v0.3.7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
)
require (
github.com/fatih/color v1.13.0
github.com/go-chi/chi/v5 v5.0.7
github.com/go-chi/cors v1.2.1
github.com/mattn/go-sqlite3 v1.14.15 // indirect
github.com/qri-io/jsonschema v0.2.1
github.com/vrischmann/envconfig v1.3.0
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 // indirect
)

93
backend/go.sum Normal file
View File

@@ -0,0 +1,93 @@
github.com/aymerick/raymond v2.0.2+incompatible h1:VEp3GpgdAnv9B2GFyTvqgcKvY+mfKMjPOA3SbKLtnU0=
github.com/aymerick/raymond v2.0.2+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/go-chi/chi/v5 v5.0.7 h1:rDTPXLDHGATaeHvVlLcR4Qe0zftYethFucbjVQ1PxU8=
github.com/go-chi/chi/v5 v5.0.7/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4=
github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/validator/v10 v10.11.1 h1:prmOlTVv+YjZjmRmNSF3VmspqJIxJWXmqUsHwfTRRkQ=
github.com/go-playground/validator/v10 v10.11.1/go.mod h1:i+3WkQ1FvaUjjxh1kSvIA4dMGDBiPU55YFDl0WbKdWU=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qri-io/jsonpointer v0.1.1 h1:prVZBZLL6TW5vsSB9fFHFAMBLI4b0ri5vribQlTJiBA=
github.com/qri-io/jsonpointer v0.1.1/go.mod h1:DnJPaYgiKu56EuDp8TU5wFLdZIcAnb/uH9v37ZaMV64=
github.com/qri-io/jsonschema v0.2.1 h1:NNFoKms+kut6ABPf6xiKNM5214jzxAhDBrPHCJ97Wg0=
github.com/qri-io/jsonschema v0.2.1/go.mod h1:g7DPkiOsK1xv6T/Ao5scXRkd+yTFygcANPBaaqW+VrI=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/vrischmann/envconfig v1.3.0 h1:4XIvQTXznxmWMnjouj0ST5lFo/WAYf5Exgl3x82crEk=
github.com/vrischmann/envconfig v1.3.0/go.mod h1:bbvxFYJdRSpXrhS63mBFtKJzkDiNkyArOLXtY6q0kuI=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+WrnIIS6dNnNRe0WB02W0F4M=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41 h1:ohgcoMbSofXygzo6AD2I1kz3BFmW1QArPYTtwEM3UXc=
golang.org/x/sys v0.0.0-20220915200043-7b5979e65e41/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
gorm.io/gorm v1.23.9 h1:NSHG021i+MCznokeXR3udGaNyFyBQJW8MbjrJMVCfGw=
gorm.io/gorm v1.23.9/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=

View File

@@ -0,0 +1,19 @@
package context
var (
// BodyCtxKey is the name of the Body value on the context
BodyCtxKey = &contextKey{"Body"}
// PrettyPrintCtxKey is the name of the pretty print context
PrettyPrintCtxKey = &contextKey{"Pretty"}
)
// contextKey is a value for use with context.WithValue. It's used as
// a pointer so it fits in an interface{} without allocation. This technique
// for defining context keys was copied from Go 1.7's new use of context in net/http.
type contextKey struct {
name string
}
func (k *contextKey) String() string {
return "context value: " + k.name
}

View File

@@ -0,0 +1,19 @@
package handler
import (
"github.com/Pacerino/cpm/internal/database"
"gorm.io/gorm"
)
type Handler struct {
DB *gorm.DB
}
func NewHandler() *Handler {
db := database.GetInstance()
handler := &Handler{
DB: db,
}
return handler
}

View File

@@ -0,0 +1,29 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
)
func getURLParamInt(r *http.Request, varName string) (int, error) {
required := true
defaultValue := 0
paramStr := chi.URLParam(r, varName)
var err error
var paramInt int
if paramStr == "" && required {
return 0, fmt.Errorf("%v was not supplied in the request", varName)
} else if paramStr == "" {
return defaultValue, nil
}
if paramInt, err = strconv.Atoi(paramStr); err != nil {
return 0, fmt.Errorf("%v is not a valid number", varName)
}
return paramInt, nil
}

View File

@@ -0,0 +1,152 @@
package handler
import (
"encoding/json"
"net/http"
h "github.com/Pacerino/cpm/internal/api/http"
"github.com/Pacerino/cpm/internal/caddy"
"github.com/Pacerino/cpm/internal/database"
"github.com/Pacerino/cpm/internal/jobqueue"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
)
// GetHosts will return a list of Hosts
// Route: GET /hosts
func (s Handler) GetHosts() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var hosts []database.Host
if err := s.DB.Preload("Upstreams").Find(&hosts).Error; err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
}
h.ResultResponseJSON(w, r, http.StatusOK, hosts)
}
}
// GetHost will return a single Host
// Route: GET /hosts/{hostID}
func (s Handler) GetHost() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var err error
var hostID int
var host database.Host
if hostID, err = getURLParamInt(r, "hostID"); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
if err = s.DB.Where("id = ?", hostID).Preload("Upstreams").First(&host).Error; err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, host)
}
}
}
// CreateHost will create a Host
// Route: POST /hosts
func (s Handler) CreateHost() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var newHost database.Host
err := json.NewDecoder(r.Body).Decode(&newHost)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(newHost); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
result := s.DB.Create(&newHost)
if result.Error != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil)
return
}
if err := jobqueue.AddJob(jobqueue.Job{
Name: "CaddyConfigureHost",
Action: func() error {
return caddy.WriteHost(newHost)
},
}); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
h.ResultResponseJSON(w, r, http.StatusOK, newHost)
}
}
// UpdateHost updates a host
// Route: PUT /hosts
func (s Handler) UpdateHost() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var newHost database.Host
err := json.NewDecoder(r.Body).Decode(&newHost)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
validate := validator.New()
if err := validate.Struct(newHost); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
if err := jobqueue.AddJob(jobqueue.Job{
Name: "CaddyConfigureHost",
Action: func() error {
return caddy.WriteHost(newHost)
},
}); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
result := s.DB.Session(&gorm.Session{FullSaveAssociations: true}).Save(&newHost)
if result.Error != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil)
return
}
h.ResultResponseJSON(w, r, http.StatusOK, newHost)
}
}
// DeleteHost removes a host
// Route: DELETE /hosts/{hostID}
func (s Handler) DeleteHost() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var err error
var hostID int
if hostID, err = getURLParamInt(r, "hostID"); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
if err := jobqueue.AddJob(jobqueue.Job{
Name: "CaddyConfigureHost",
Action: func() error {
return caddy.RemoveHost(hostID)
},
}); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
result := s.DB.Delete(&database.Host{}, hostID)
if result.Error != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, result.Error.Error(), nil)
return
}
if result.RowsAffected > 0 {
h.ResultResponseJSON(w, r, http.StatusOK, true)
} else {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrIDNotFound.Error(), nil)
}
}
}

View File

@@ -0,0 +1,48 @@
package http
import (
"context"
"encoding/json"
"errors"
"github.com/qri-io/jsonschema"
)
var (
// ErrInvalidJSON is an error for invalid json
ErrInvalidJSON = errors.New("json is invalid")
// ErrInvalidPayload is an error for invalid incoming data
ErrInvalidPayload = errors.New("payload is invalid")
// ErrIDNotFound is an error for invalid or missing ID in database
ErrIDNotFound = errors.New("id is missing in db")
)
// ValidateRequestSchema takes a Schema and the Content to validate against it
func ValidateRequestSchema(schema string, requestBody []byte) ([]jsonschema.KeyError, error) {
var jsonErrors []jsonschema.KeyError
var schemaBytes = []byte(schema)
// Make sure the body is valid JSON
if !isJSON(requestBody) {
return jsonErrors, ErrInvalidJSON
}
rs := &jsonschema.Schema{}
if err := json.Unmarshal(schemaBytes, rs); err != nil {
return jsonErrors, err
}
var validationErr error
ctx := context.TODO()
if jsonErrors, validationErr = rs.ValidateBytes(ctx, requestBody); len(jsonErrors) > 0 {
return jsonErrors, validationErr
}
// Valid
return nil, nil
}
func isJSON(bytes []byte) bool {
var js map[string]interface{}
return json.Unmarshal(bytes, &js) == nil
}

View File

@@ -0,0 +1,91 @@
package http
import (
"encoding/json"
"fmt"
"net/http"
"reflect"
c "github.com/Pacerino/cpm/internal/api/context"
"github.com/Pacerino/cpm/internal/errors"
"github.com/Pacerino/cpm/internal/logger"
"github.com/qri-io/jsonschema"
)
// Response interface for standard API results
type Response struct {
Result interface{} `json:"result"`
Error interface{} `json:"error,omitempty"`
}
// ErrorResponse interface for errors returned via the API
type ErrorResponse struct {
Code interface{} `json:"code"`
Message interface{} `json:"message"`
Invalid interface{} `json:"invalid,omitempty"`
}
// ResultResponseJSON will write the result as json to the http output
func ResultResponseJSON(w http.ResponseWriter, r *http.Request, status int, result interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
var response Response
resultClass := fmt.Sprintf("%v", reflect.TypeOf(result))
if resultClass == "http.ErrorResponse" {
response = Response{
Error: result,
}
} else {
response = Response{
Result: result,
}
}
var payload []byte
var err error
if getPrettyPrintFromContext(r) {
payload, err = json.MarshalIndent(response, "", " ")
} else {
payload, err = json.Marshal(response)
}
if err != nil {
logger.Error("ResponseMarshalError", err)
}
fmt.Fprint(w, string(payload))
}
// ResultSchemaErrorJSON will format the result as a standard error object and send it for output
func ResultSchemaErrorJSON(w http.ResponseWriter, r *http.Request, errs []jsonschema.KeyError) {
errorResponse := ErrorResponse{
Code: http.StatusBadRequest,
Message: errors.ErrValidationFailed,
Invalid: errs,
}
ResultResponseJSON(w, r, http.StatusBadRequest, errorResponse)
}
// ResultErrorJSON will format the result as a standard error object and send it for output
func ResultErrorJSON(w http.ResponseWriter, r *http.Request, status int, message string, extended interface{}) {
errorResponse := ErrorResponse{
Code: status,
Message: message,
Invalid: extended,
}
ResultResponseJSON(w, r, status, errorResponse)
}
// getPrettyPrintFromContext returns the PrettyPrint setting
func getPrettyPrintFromContext(r *http.Request) bool {
pretty, ok := r.Context().Value(c.PrettyPrintCtxKey).(bool)
if !ok {
return false
}
return pretty
}

View File

@@ -0,0 +1,61 @@
package api
import (
"io/fs"
"net/http"
"strings"
"github.com/Pacerino/cpm/embed"
"github.com/Pacerino/cpm/internal/api/handler"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
)
func NewRouter() http.Handler {
cors := cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-Requested-With"},
AllowCredentials: true,
MaxAge: 300,
})
r := chi.NewRouter()
h := handler.NewHandler()
r.Use(
cors,
)
return generateRoutes(r, h)
}
func generateRoutes(r chi.Router, h *handler.Handler) chi.Router {
r.Route("/api", func(r chi.Router) {
//Hosts
r.Route("/hosts", func(r chi.Router) {
r.Get("/", h.GetHosts()) // Get List of Hosts
r.Post("/", h.CreateHost()) // Create Host & save to the DB
r.Get("/{hostID:[0-9]+}", h.GetHost()) // Get specific Host by ID
r.Delete("/{hostID:[0-9]+}", h.DeleteHost()) // Delete Host by ID
r.Put("/", h.UpdateHost()) // Update Host by ID
})
})
fileServer(r)
return r
}
func fileServer(r chi.Router) {
r.Get("/*", func(w http.ResponseWriter, r *http.Request) {
rctx := chi.RouteContext(r.Context())
pathPrefix := strings.TrimSuffix(rctx.RoutePattern(), "/*")
fSys, err := fs.Sub(embed.Assets, "assets")
if err != nil {
panic(err)
}
fs := http.StripPrefix(pathPrefix, http.FileServer(http.FS(fSys)))
fs.ServeHTTP(w, r)
})
}

View File

@@ -0,0 +1,19 @@
package api
import (
"fmt"
"net/http"
"github.com/Pacerino/cpm/internal/logger"
)
const httpPort = 3001
// StartServer creates a http server
func StartServer() {
logger.Info("Server starting on port %v", httpPort)
err := http.ListenAndServe(fmt.Sprintf("0.0.0.0:%v", httpPort), NewRouter())
if err != nil {
logger.Error("HttpListenError", err)
}
}

View File

@@ -0,0 +1,53 @@
package caddy
import (
"fmt"
"os"
"github.com/Pacerino/cpm/embed"
"github.com/Pacerino/cpm/internal/config"
"github.com/Pacerino/cpm/internal/database"
"github.com/Pacerino/cpm/internal/logger"
"github.com/aymerick/raymond"
)
type hostEntity struct {
Host database.Host
LogPath string
}
func WriteHost(h database.Host) error {
data := &hostEntity{
Host: h,
LogPath: fmt.Sprintf("%s/host_%d.log", config.Configuration.LogFolder, h.ID),
}
filename := fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, h.ID)
// Read Template from Embed FS
template, err := embed.CaddyFiles.ReadFile("caddy/host.hbs")
if err != nil {
logger.Error(err.Error(), err)
return err
}
// Parse Data into Template
tmplOutput, err := raymond.Render(string(template), data)
if err != nil {
logger.Error(err.Error(), err)
return err
}
// Write filled out template to the config folder
if err := os.WriteFile(filename, []byte(tmplOutput), 0644); err != nil {
return err
}
return ReloadCaddy()
}
func RemoveHost(hostID int) error {
filename := fmt.Sprintf("%s/host_%d.conf", config.Configuration.DataFolder, hostID)
if err := os.Remove(filename); err != nil {
return err
}
return ReloadCaddy()
}

View File

@@ -0,0 +1,44 @@
package caddy
import (
"fmt"
"os/exec"
"github.com/Pacerino/cpm/internal/config"
"github.com/Pacerino/cpm/internal/logger"
)
func ReloadCaddy() error {
_, err := shExec([]string{"reload", "--config", config.Configuration.CaddyFile})
return err
}
func getCaddyFilePath() (string, error) {
path, err := exec.LookPath("caddy")
if err != nil {
return path, fmt.Errorf("cannot find caddy execuatable script in PATH")
}
return path, nil
}
// shExec executes caddy with arguments
func shExec(args []string) (string, error) {
ng, err := getCaddyFilePath()
if err != nil {
logger.Error("CaddyError", err)
return "", err
}
logger.Debug("CMD: %s %v", ng, args)
// nolint: gosec
c := exec.Command(ng, args...)
b, e := c.Output()
if e != nil {
logger.Error("CaddyError", fmt.Errorf("command error: %s -- %v\n%+v", ng, args, e))
logger.Warn(string(b))
}
return string(b), e
}

View File

@@ -0,0 +1,70 @@
package config
import (
"fmt"
golog "log"
"github.com/Pacerino/cpm/internal/logger"
"github.com/vrischmann/envconfig"
)
// Version is the version set by ldflags
var Version string
// Commit is the git commit set by ldflags
var Commit string
// Init will parse environment variables into the Env struct
func Init(version, commit *string) {
Version = *version
Commit = *commit
if err := envconfig.InitWithPrefix(&Configuration, "CPM"); err != nil {
fmt.Printf("%+v\n", err)
}
initLogger()
logger.Info("Build Version: %s (%s)", Version, Commit)
createDataFolders()
}
// Init initialises the Log object and return it
func initLogger() {
// this removes timestamp prefixes from logs
golog.SetFlags(0)
switch Configuration.Log.Level {
case "debug":
logLevel = logger.DebugLevel
case "warn":
logLevel = logger.WarnLevel
case "error":
logLevel = logger.ErrorLevel
default:
logLevel = logger.InfoLevel
}
err := logger.Configure(&logger.Config{
LogThreshold: logLevel,
Formatter: Configuration.Log.Format,
})
if err != nil {
logger.Error("LoggerConfigurationError", err)
}
}
// GetLogLevel returns the logger const level
func GetLogLevel() logger.Level {
return logLevel
}
/* func isError(errorClass string, err error) bool {
if err != nil {
logger.Error(errorClass, err)
return true
}
return false
}
*/

View File

@@ -0,0 +1,28 @@
package config
import (
"fmt"
"os"
"github.com/Pacerino/cpm/internal/logger"
)
func createDataFolders() {
folders := []string{
"hosts",
}
for _, folder := range folders {
path := folder
if path[0:1] != "/" {
path = fmt.Sprintf("%s/%s", Configuration.DataFolder, folder)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
// folder does not exist
logger.Debug("Creating folder: %s", path)
if err := os.MkdirAll(path, os.ModePerm); err != nil {
logger.Error("CreateDataFolderError", err)
}
}
}
}

View File

@@ -0,0 +1,21 @@
package config
import "github.com/Pacerino/cpm/internal/logger"
// IsSetup defines whether we have an admin user or not
var IsSetup bool
var logLevel logger.Level
type log struct {
Level string `json:"level" envconfig:"optional,default=debug"`
Format string `json:"format" envconfig:"optional,default=nice"`
}
// Configuration is the main configuration object
var Configuration struct {
DataFolder string `json:"data_folder" envconfig:"optional,default=/etc/caddy/"`
LogFolder string `json:"log_folder" envconfig:"optional,default=/var/log/caddy"`
CaddyFile string `json:"caddy_file" envconfig:"optional,default=/etc/caddy/Caddyfile"`
Log log `json:"log"`
}

View File

@@ -0,0 +1,16 @@
package database
import "gorm.io/gorm"
type Host struct {
gorm.Model
Domains string `json:"domains" validate:"required,fqdn|hostname_port"`
Matcher string `json:"matcher"`
Upstreams []Upstream
}
type Upstream struct {
gorm.Model
HostID uint `json:"hostId"`
Backend string `json:"backend" validate:"required,hostname_port"`
}

View File

@@ -0,0 +1,40 @@
package database
import (
"fmt"
"github.com/Pacerino/cpm/internal/config"
"github.com/Pacerino/cpm/internal/logger"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var dbInstance *gorm.DB
// NewDB creates a new connection
func NewDB() {
logger.Info("Creating new DB instance")
dbInstance = SqliteDB()
dbInstance.AutoMigrate(&Host{}, &Upstream{})
}
// GetInstance returns an existing or new instance
func GetInstance() *gorm.DB {
if dbInstance == nil {
NewDB()
}
return dbInstance
}
// SqliteDB Create sqlite client
func SqliteDB() *gorm.DB {
dbFile := fmt.Sprintf("%s/caddyproxymanager.db", config.Configuration.DataFolder)
db, err := gorm.Open(sqlite.Open(dbFile), &gorm.Config{})
if err != nil {
logger.Error("SqliteError", err)
return nil
}
return db
}

View File

@@ -0,0 +1,16 @@
package errors
import "errors"
// All error messages used by the service package to report
// problems back to calling clients
var (
ErrDatabaseUnavailable = errors.New("database-unavailable")
ErrDuplicateEmailUser = errors.New("email-already-exists")
ErrInvalidLogin = errors.New("invalid-login-credentials")
ErrUserDisabled = errors.New("user-disabled")
ErrSystemUserReadonly = errors.New("cannot-save-system-users")
ErrValidationFailed = errors.New("request-failed-validation")
ErrCurrentPasswordInvalid = errors.New("current-password-invalid")
ErrCABundleDoesNotExist = errors.New("ca-bundle-does-not-exist")
)

View File

@@ -0,0 +1,46 @@
package jobqueue
import (
"context"
"errors"
)
var (
ctx context.Context
cancel context.CancelFunc
worker *Worker
)
// Start will intantiate the queue and start doing work
func Start() {
ctx, cancel = context.WithCancel(context.Background())
q := &Queue{
jobs: make(chan Job),
ctx: ctx,
cancel: cancel,
}
// Defines a queue worker, which will execute our queue.
worker = newWorker(q)
// Execute jobs in queue.
go worker.doWork()
}
// Shutdown will gracefully stop the queue
func Shutdown() error {
if cancel == nil {
return errors.New("unable to shutdown, jobqueue has not been started")
}
cancel()
return nil
}
// AddJob adds a job to the queue for processing
func AddJob(j Job) error {
if worker == nil {
return errors.New("unable to add job, jobqueue has not been started")
}
worker.Queue.AddJob(j)
return nil
}

View File

@@ -0,0 +1,54 @@
package jobqueue
import (
"context"
"sync"
)
// Queue holds name, list of jobs and context with cancel.
type Queue struct {
jobs chan Job
ctx context.Context
cancel context.CancelFunc
}
// Job - holds logic to perform some operations during queue execution.
type Job struct {
Name string
Action func() error // A function that should be executed when the job is running.
}
// AddJobs adds jobs to the queue and cancels channel.
func (q *Queue) AddJobs(jobs []Job) {
var wg sync.WaitGroup
wg.Add(len(jobs))
for _, job := range jobs {
// Goroutine which adds job to the queue.
go func(job Job) {
q.AddJob(job)
wg.Done()
}(job)
}
go func() {
wg.Wait()
// Cancel queue channel, when all goroutines were done.
q.cancel()
}()
}
// AddJob sends job to the channel.
func (q *Queue) AddJob(job Job) {
q.jobs <- job
}
// Run performs job execution.
func (j Job) Run() error {
err := j.Action()
if err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,37 @@
package jobqueue
import (
"fmt"
"github.com/Pacerino/cpm/internal/logger"
)
// Worker responsible for queue serving.
type Worker struct {
Queue *Queue
}
func newWorker(queue *Queue) *Worker {
return &Worker{
Queue: queue,
}
}
// doWork processes jobs from the queue (jobs channel).
func (w *Worker) doWork() bool {
for {
select {
// if context was canceled.
case <-w.Queue.ctx.Done():
logger.Info("JobQueue worker graceful shutdown")
return true
// if job received.
case job := <-w.Queue.jobs:
err := job.Run()
if err != nil {
logger.Error(fmt.Sprintf("%sError", job.Name), err)
continue
}
}
}
}

View File

@@ -0,0 +1,37 @@
package logger
// Level type
type Level int
// Log level definitions
const (
// DebugLevel usually only enabled when debugging. Very verbose logging.
DebugLevel Level = 10
// InfoLevel general operational entries about what's going on inside the application.
InfoLevel Level = 20
// WarnLevel non-critical entries that deserve eyes.
WarnLevel Level = 30
// ErrorLevel used for errors that should definitely be noted.
ErrorLevel Level = 40
)
// Config options for the logger.
type Config struct {
LogThreshold Level
Formatter string
}
// Interface for a logger
type Interface interface {
GetLogLevel() Level
Debug(format string, args ...interface{})
Info(format string, args ...interface{})
Warn(format string, args ...interface{})
Error(errorClass string, err error, args ...interface{})
Errorf(errorClass, format string, err error, args ...interface{})
}
// ConfigurableLogger is an interface for a logger that can be configured
type ConfigurableLogger interface {
Configure(c *Config) error
}

View File

@@ -0,0 +1,243 @@
package logger
import (
"encoding/json"
"fmt"
stdlog "log"
"os"
"runtime/debug"
"sync"
"time"
"github.com/fatih/color"
/* "github.com/getsentry/sentry-go" */)
var colorReset, colorGray, colorYellow, colorBlue, colorRed, colorMagenta, colorBlack, colorWhite *color.Color
// Log message structure.
type Log struct {
Timestamp string `json:"timestamp"`
Level string `json:"level"`
Message string `json:"message"`
Pid int `json:"pid"`
Summary string `json:"summary,omitempty"`
Caller string `json:"caller,omitempty"`
StackTrace []string `json:"stack_trace,omitempty"`
}
// Logger instance
type Logger struct {
Config
mux sync.Mutex
}
// global logging configuration.
var logger = NewLogger()
// NewLogger creates a new logger instance
func NewLogger() *Logger {
color.NoColor = false
colorReset = color.New(color.Reset)
colorGray = color.New(color.FgWhite)
colorYellow = color.New(color.Bold, color.FgYellow)
colorBlue = color.New(color.Bold, color.FgBlue)
colorRed = color.New(color.Bold, color.FgRed)
colorMagenta = color.New(color.Bold, color.FgMagenta)
colorBlack = color.New(color.Bold, color.FgBlack)
colorWhite = color.New(color.Bold, color.FgWhite)
return &Logger{
Config: NewConfig(),
}
}
// NewConfig returns the default config
func NewConfig() Config {
return Config{
LogThreshold: InfoLevel,
Formatter: "json",
}
}
// Configure logger and will return error if missing required fields.
func Configure(c *Config) error {
return logger.Configure(c)
}
// GetLogLevel currently configured
func GetLogLevel() Level {
return logger.GetLogLevel()
}
// Debug logs if the log level is set to DebugLevel or below. Arguments are handled in the manner of fmt.Printf.
func Debug(format string, args ...interface{}) {
logger.Debug(format, args...)
}
// Info logs if the log level is set to InfoLevel or below. Arguments are handled in the manner of fmt.Printf.
func Info(format string, args ...interface{}) {
logger.Info(format, args...)
}
// Warn logs if the log level is set to WarnLevel or below. Arguments are handled in the manner of fmt.Printf.
func Warn(format string, args ...interface{}) {
logger.Warn(format, args...)
}
// Error logs error given if the log level is set to ErrorLevel or below. Arguments are not logged.
// Attempts to log to bugsang.
func Error(errorClass string, err error) {
logger.Error(errorClass, err)
os.Exit(1)
}
// Configure logger and will return error if missing required fields.
func (l *Logger) Configure(c *Config) error {
// ensure updates to the config are atomic
l.mux.Lock()
defer l.mux.Unlock()
if c == nil {
return fmt.Errorf("a non nil Config is mandatory")
}
if err := c.LogThreshold.validate(); err != nil {
return err
}
l.LogThreshold = c.LogThreshold
l.Formatter = c.Formatter
/* l.SentryConfig = c.SentryConfig */
/* if c.SentryConfig.Dsn != "" {
if sentryErr := sentry.Init(c.SentryConfig); sentryErr != nil {
fmt.Printf("Sentry initialization failed: %v\n", sentryErr)
}
} */
stdlog.SetFlags(0) // this removes timestamp prefixes from logs
return nil
}
// validate the log level is in the accepted list.
func (l Level) validate() error {
switch l {
case DebugLevel, InfoLevel, WarnLevel, ErrorLevel:
return nil
default:
return fmt.Errorf("invalid \"Level\" %d", l)
}
}
var logLevels = map[Level]string{
DebugLevel: "DEBUG",
InfoLevel: "INFO",
WarnLevel: "WARN",
ErrorLevel: "ERROR",
}
func (l *Logger) logLevel(logLevel Level, format string, args ...interface{}) {
if logLevel < l.LogThreshold {
return
}
errorClass := ""
if logLevel == ErrorLevel {
// First arg is the errorClass
errorClass = args[0].(string)
if len(args) > 1 {
args = args[1:]
} else {
args = []interface{}{}
}
}
stringMessage := fmt.Sprintf(format, args...)
if l.Formatter == "json" {
// JSON Log Format
jsonLog, _ := json.Marshal(
Log{
Timestamp: time.Now().Format(time.RFC3339Nano),
Level: logLevels[logLevel],
Message: stringMessage,
Pid: os.Getpid(),
},
)
stdlog.Println(string(jsonLog))
} else {
// Nice Log Format
var colorLevel *color.Color
switch logLevel {
case DebugLevel:
colorLevel = colorMagenta
case InfoLevel:
colorLevel = colorBlue
case WarnLevel:
colorLevel = colorYellow
case ErrorLevel:
colorLevel = colorRed
stringMessage = fmt.Sprintf("%s: %s", errorClass, stringMessage)
}
t := time.Now()
stdlog.Println(
colorBlack.Sprint("["),
colorWhite.Sprint(t.Format("2006-01-02 15:04:05")),
colorBlack.Sprint("] "),
colorLevel.Sprintf("%-8v", logLevels[logLevel]),
colorGray.Sprint(stringMessage),
colorReset.Sprint(""),
)
if logLevel == ErrorLevel && l.LogThreshold == DebugLevel {
// Print a stack trace too
debug.PrintStack()
}
}
}
// GetLogLevel currently configured
func (l *Logger) GetLogLevel() Level {
return l.LogThreshold
}
// Debug logs if the log level is set to DebugLevel or below. Arguments are handled in the manner of fmt.Printf.
func (l *Logger) Debug(format string, args ...interface{}) {
l.logLevel(DebugLevel, format, args...)
}
// Info logs if the log level is set to InfoLevel or below. Arguments are handled in the manner of fmt.Printf.
func (l *Logger) Info(format string, args ...interface{}) {
l.logLevel(InfoLevel, format, args...)
}
// Warn logs if the log level is set to WarnLevel or below. Arguments are handled in the manner of fmt.Printf.
func (l *Logger) Warn(format string, args ...interface{}) {
l.logLevel(WarnLevel, format, args...)
}
// Error logs error given if the log level is set to ErrorLevel or below. Arguments are not logged.
// Attempts to log to bugsang.
func (l *Logger) Error(errorClass string, err error) {
l.logLevel(ErrorLevel, err.Error(), errorClass)
/* l.notifySentry(errorClass, err) */
}
/* func (l *Logger) notifySentry(errorClass string, err error) {
if l.SentryConfig.Dsn != "" && l.SentryConfig.Dsn != "-" {
sentry.ConfigureScope(func(scope *sentry.Scope) {
scope.SetLevel(sentry.LevelError)
scope.SetTag("service", "backend")
scope.SetTag("error_class", errorClass)
})
sentry.CaptureException(err)
// Since sentry emits events in the background we need to make sure
// they are sent before we shut down
sentry.Flush(time.Second * 5)
}
}
*/

View File

@@ -0,0 +1,36 @@
package util
// FindItemInInterface Find key in interface (recursively) and return value as interface
func FindItemInInterface(key string, obj interface{}) (interface{}, bool) {
// if the argument is not a map, ignore it
mobj, ok := obj.(map[string]interface{})
if !ok {
return nil, false
}
for k, v := range mobj {
// key match, return value
if k == key {
return v, true
}
// if the value is a map, search recursively
if m, ok := v.(map[string]interface{}); ok {
if res, ok := FindItemInInterface(key, m); ok {
return res, true
}
}
// if the value is an array, search recursively
// from each element
if va, ok := v.([]interface{}); ok {
for _, a := range va {
if res, ok := FindItemInInterface(key, a); ok {
return res, true
}
}
}
}
// element not found
return nil, false
}

View File

@@ -0,0 +1,44 @@
package util
import (
"strconv"
"strings"
)
// SliceContainsItem returns whether the slice given contains the item given
func SliceContainsItem(slice []string, item string) bool {
for _, a := range slice {
if a == item {
return true
}
}
return false
}
// SliceContainsInt returns whether the slice given contains the item given
func SliceContainsInt(slice []int, item int) bool {
for _, a := range slice {
if a == item {
return true
}
}
return false
}
// ConvertIntSliceToString returns a comma separated string of all items in the slice
func ConvertIntSliceToString(slice []int) string {
strs := []string{}
for _, item := range slice {
strs = append(strs, strconv.Itoa(item))
}
return strings.Join(strs, ",")
}
// ConvertStringSliceToInterface is required in some special cases
func ConvertStringSliceToInterface(slice []string) []interface{} {
res := make([]interface{}, len(slice))
for i := range slice {
res[i] = slice[i]
}
return res
}

46
frontend/README.md Normal file
View File

@@ -0,0 +1,46 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
The page will reload if you make edits.\
You will also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).

28308
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View File

@@ -0,0 +1,55 @@
{
"name": "frontend",
"version": "0.1.0",
"private": true,
"dependencies": {
"@hookform/resolvers": "^2.9.8",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.11.59",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"flowbite": "^1.5.3",
"flowbite-react": "^0.1.11",
"joi": "^17.6.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.36.0",
"react-router-dom": "^6.4.0",
"react-scripts": "5.0.1",
"typescript": "^4.8.3",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"@types/joi": "^17.2.3",
"autoprefixer": "^10.4.11",
"postcss": "^8.4.16",
"tailwindcss": "^3.1.8"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>

BIN
frontend/public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
frontend/public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,25 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

21
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,21 @@
import React from "react";
import Layout from "./components/Layout"
import { Routes, Route } from "react-router-dom";
// Pages
import HostsPage from "./pages/Hosts"
function App() {
return (
<>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<HostsPage />} />
</Route>
</Routes>
</>
);
}
export default App;

View File

@@ -0,0 +1,29 @@
import { Navbar } from "flowbite-react";
import { Outlet } from "react-router-dom";
function Layout() {
return (
<div>
<Navbar fluid={true} rounded={false}>
<Navbar.Brand href="https://flowbite.com/">
<img
src="https://flowbite.com/docs/images/logo.svg"
className="mr-3 h-6 sm:h-9"
alt="Flowbite Logo"
/>
<span className="self-center whitespace-nowrap text-xl font-semibold dark:text-white">
Caddy Proxy Manager
</span>
</Navbar.Brand>
<Navbar.Collapse>
{/* <Navbar.Link href="/home">Home</Navbar.Link>
<Navbar.Link href="/hosts">Hosts</Navbar.Link> */}
</Navbar.Collapse>
</Navbar>
<div className="container mx-auto py-4">
<Outlet />
</div>
</div>
);
}
export default Layout;

3
frontend/src/index.css Normal file
View File

@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

25
frontend/src/index.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from "react";
import ReactDOM from "react-dom/client";
import "./index.css";
import App from "./App";
import { BrowserRouter } from "react-router-dom";
import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement
);
document.body.classList.add('bg-slate-900');
root.render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

View File

@@ -0,0 +1,308 @@
import {
Table,
Button,
Modal,
TextInput,
Label,
Spinner,
} from "flowbite-react";
import { useForm, useFieldArray, SubmitHandler } from "react-hook-form";
import { joiResolver } from "@hookform/resolvers/joi";
import Joi from "joi";
import { HiAdjustments, HiTrash, HiDocumentAdd } from "react-icons/hi";
import React from "react";
type Domain = {
fqdn: string;
};
type UpstreamForm = {
backend: string;
};
type FormValues = {
domains: Domain[];
upstreams: UpstreamForm[];
matcher: string | undefined;
};
interface Upstream {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: any;
hostId: number;
backend: string;
}
interface Hosts {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt?: any;
domains: string;
matcher: string;
Upstreams: Upstream[];
}
interface RootObject {
result: Hosts[];
}
function HostsPage() {
const [modal, setModal] = React.useState(false);
const [hostData, setHostData] = React.useState<RootObject>();
const [loading, setLoading] = React.useState(false);
const schema = Joi.object<FormValues>({
matcher: Joi.any().optional(),
upstreams: Joi.array().items(
Joi.object().keys({
backend: Joi.string().required(),
})
),
domains: Joi.array().items(
Joi.object().keys({
fqdn: Joi.string().required(),
})
),
});
const {
register,
handleSubmit,
control,
formState: { errors },
} = useForm({
resolver: joiResolver(schema),
defaultValues: {
matcher: "",
domains: [{ fqdn: "" }],
upstreams: [{ backend: "" }],
},
});
const {
fields: upstreamFields,
append: upstreamAppend,
remove: upstreamRemove,
} = useFieldArray({
control,
name: "upstreams",
});
const {
fields: domainFields,
append: domainAppend,
remove: domainRemove,
} = useFieldArray({
control,
name: "domains",
});
React.useEffect(() => {
getHosts();
}, []);
const getHosts = async () => {
return await fetch(`http://${window.location.hostname}:3001/api/hosts`)
.then((res) => res.json())
.then((json) => {
setHostData(json);
});
};
const createHost: SubmitHandler<FormValues> = async (data) => {
const jsonBody = {
matcher: data.matcher,
domains: data.domains
.map((e) => {
return e.fqdn;
})
.join(","),
upstreams: data.upstreams,
};
setLoading(true);
const res = await fetch(`http://${window.location.hostname}:3001/api/hosts`, {
body: JSON.stringify(jsonBody),
method: "POST",
});
if (res.status !== 200) {
// handle Error
}
setLoading(false);
setModal(false);
getHosts();
};
const deleteHost = async (hostID: number) => {
await fetch(`http://${window.location.hostname}:3001/api/hosts/${hostID}`, {
method: "DELETE",
});
getHosts();
};
return (
<>
<div className="pb-4 bg-white dark:bg-gray-900">
<Button size="xs" color="gray" onClick={() => setModal(true)}>
<HiDocumentAdd className="mr-3 h-4 w-4" /> Add Host
</Button>
</div>
<Table>
<Table.Head>
<Table.HeadCell>ID</Table.HeadCell>
<Table.HeadCell>Created</Table.HeadCell>
<Table.HeadCell>Updated</Table.HeadCell>
<Table.HeadCell>Domains</Table.HeadCell>
<Table.HeadCell>Matcher</Table.HeadCell>
<Table.HeadCell>Upstreams</Table.HeadCell>
<Table.HeadCell>
<span className="sr-only">Edit</span>
</Table.HeadCell>
</Table.Head>
<Table.Body className="divide-y">
{hostData?.result.map((entry, i) => {
return (
<Table.Row
key={entry.ID}
className="bg-white dark:border-gray-700 dark:bg-gray-800"
>
<Table.Cell>{entry.ID}</Table.Cell>
<Table.Cell>{entry.CreatedAt}</Table.Cell>
<Table.Cell>{entry.UpdatedAt}</Table.Cell>
<Table.Cell>{entry.domains}</Table.Cell>
<Table.Cell>{entry.matcher}</Table.Cell>
<Table.Cell>{entry.Upstreams[0].backend}</Table.Cell>
<Table.Cell>
<Button.Group>
<Button size="xs" color="gray">
<HiAdjustments className="mr-3 h-4 w-4" /> Edit
</Button>
<Button
size="xs"
color="gray"
onClick={() => deleteHost(entry.ID)}
>
<HiTrash className="mr-3 h-4 w-4" /> Delete
</Button>
</Button.Group>
</Table.Cell>
</Table.Row>
);
})}
</Table.Body>
</Table>
<Modal show={modal} size="xl" onClose={() => setModal(false)}>
<Modal.Header>Add a new Host</Modal.Header>
<Modal.Body>
<form
className="flex flex-col gap-4"
onSubmit={handleSubmit(createHost)}
>
<div className="space-y-2">
<div className="mb-2 block">
<Label htmlFor="domain" value="Domain" />
</div>
<ul className="space-y-2">
{domainFields.map((field, index) => {
return (
<li key={field.id}>
<TextInput
type="text"
placeholder="example.com"
id={`domains.${index}.fqdn`}
key={field.id}
{...register(`domains.${index}.fqdn` as const)}
addon={
index > 0 && (
<Button size="xs" color="gray" onClick={() => domainRemove(index)}>
Delete
</Button>
)
}
helperText={
errors.domains?.[index] && (
<span>Please enter a valid FQDN</span>
)
}
/>
</li>
);
})}
</ul>
<div className="mb-2 block">
<Label value="Matcher" />
</div>
<TextInput
type="text"
id="matcher"
placeholder="/api/*"
{...register("matcher", { required: false })}
/>
<div className="mb-2 block">
<Label value="Upstreams" />
</div>
<ul className="space-y-2">
{upstreamFields.map((field, index) => {
return (
<li key={field.id}>
<TextInput
type="text"
placeholder="127.0.0.1:8080"
id={`upstreams.${index}.backend`}
key={field.id}
{...register(`upstreams.${index}.backend` as const)}
addon={
index > 0 && (
<Button size="xs" color="gray" onClick={() => upstreamRemove(index)}>
Delete
</Button>
)
}
helperText={
errors.upstreams?.[index] && (
<span>Please enter a valid IP Address</span>
)
}
/>
</li>
);
})}
</ul>
</div>
<Button.Group>
{loading ? (
<Button disabled={true}>
<div className="mr-3">
<Spinner size="sm" light={true} />
</div>
Loading ...
</Button>
) : (
<Button type="submit">Save</Button>
)}
<Button
disabled={loading}
type="button"
onClick={() => domainAppend({ fqdn: "" })}
>
Add Domain
</Button>
<Button
disabled={loading}
type="button"
onClick={() => upstreamAppend({ backend: "" })}
>
Add Upstream
</Button>
</Button.Group>
</form>
</Modal.Body>
</Modal>
</>
);
}
export default HostsPage;

1
frontend/src/react-app-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="react-scripts" />

View File

@@ -0,0 +1,15 @@
import { ReportHandler } from 'web-vitals';
const reportWebVitals = (onPerfEntry?: ReportHandler) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

View File

@@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@@ -0,0 +1,15 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
'node_modules/flowbite-react/**/*.{js,jsx,ts,tsx}',
"./components/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
darkMode: 'media',
plugins: [
require('flowbite/plugin')
]
}

26
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}

9712
frontend/yarn.lock Normal file

File diff suppressed because it is too large Load Diff