Certificates Renewal + SSE

- Certificate renewal is just a re-request as it's forced already
- Rejig the routes for readability
- Added Server Side Events so that the UI would invalidate the
cache when changes happen on the backend, such as certs being
provided or failing
- Added a SSE Token, which has the same shelf life as normal token
but can't be used interchangeably. The reason for this is, the
SSE endpoint needs a token for auth as a Query param, so it would
be stored in log files. If someone where to get a hold of that,
it's pretty useless as it can't be used to change anything, only
to listen for events until it expires
- Added test endpoint for SSE testing only availabe in debug mode
This commit is contained in:
Jamie Curnow 2023-03-07 16:42:26 +10:00
parent 35550082bf
commit 215083f6cf
No known key found for this signature in database
GPG Key ID: FFBB624C43388E9E
29 changed files with 665 additions and 197 deletions

View File

@ -163,6 +163,11 @@
"$ref": "file://./paths/tokens/post.json" "$ref": "file://./paths/tokens/post.json"
} }
}, },
"/tokens/sse": {
"post": {
"$ref": "file://./paths/tokens/sse/post.json"
}
},
"/upstreams": { "/upstreams": {
"get": { "get": {
"$ref": "file://./paths/upstreams/get.json" "$ref": "file://./paths/upstreams/get.json"

View File

@ -1,21 +1,17 @@
{ {
"operationId": "refreshToken", "operationId": "refreshToken",
"summary": "Refresh your access token", "summary": "Refresh your access token",
"tags": [ "tags": ["Tokens"],
"Tokens"
],
"responses": { "responses": {
"200": { "200": {
"description": "200 response", "description": "200 response",
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"required": [ "required": ["result"],
"result"
],
"properties": { "properties": {
"result": { "result": {
"$ref": "#/components/schemas/StreamObject" "$ref": "#/components/schemas/TokenObject"
} }
} }
}, },
@ -34,4 +30,4 @@
} }
} }
} }
} }

View File

@ -1,9 +1,7 @@
{ {
"operationId": "requestToken", "operationId": "requestToken",
"summary": "Request a new access token from credentials", "summary": "Request a new access token from credentials",
"tags": [ "tags": ["Tokens"],
"Tokens"
],
"requestBody": { "requestBody": {
"description": "Credentials Payload", "description": "Credentials Payload",
"required": true, "required": true,
@ -19,12 +17,10 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"required": [ "required": ["result"],
"result"
],
"properties": { "properties": {
"result": { "result": {
"$ref": "#/components/schemas/StreamObject" "$ref": "#/components/schemas/TokenObject"
} }
} }
}, },
@ -49,9 +45,7 @@
"schema": { "schema": {
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": ["error"],
"error"
],
"properties": { "properties": {
"result": { "result": {
"nullable": true "nullable": true
@ -76,4 +70,4 @@
} }
} }
} }
} }

View File

@ -0,0 +1,33 @@
{
"operationId": "requestSSEToken",
"summary": "Request a new SSE token",
"tags": ["Tokens"],
"responses": {
"200": {
"description": "200 response",
"content": {
"application/json": {
"schema": {
"required": ["result"],
"properties": {
"result": {
"$ref": "#/components/schemas/TokenObject"
}
}
},
"examples": {
"default": {
"value": {
"result": {
"expires": 1566540510,
"token": "eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.ey...xaHKYr3Kk6MvkUjcC4",
"scope": "user"
}
}
}
}
}
}
}
}
}

View File

@ -12,6 +12,7 @@ require (
github.com/go-chi/chi v4.1.2+incompatible github.com/go-chi/chi v4.1.2+incompatible
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-chi/jwtauth v4.0.4+incompatible github.com/go-chi/jwtauth v4.0.4+incompatible
github.com/jc21/go-sse v0.0.0-20230307041911-8ea9bdc44a58
github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760 github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760
github.com/jmoiron/sqlx v1.3.5 github.com/jmoiron/sqlx v1.3.5
github.com/mattn/go-sqlite3 v1.14.16 github.com/mattn/go-sqlite3 v1.14.16

View File

@ -27,6 +27,12 @@ github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxI
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/jc21/go-sse v0.0.0-20230307004720-e0a2266806a8 h1:3zrxixsRpzrMXd/c6vUHaoIi9OqirEPxPe4Ydxn3jNU=
github.com/jc21/go-sse v0.0.0-20230307004720-e0a2266806a8/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s=
github.com/jc21/go-sse v0.0.0-20230307015818-b2783ddda573 h1:aaRu9mFSjxNfbXWVe7MlarmuB0vcdTShXFbxjzHAseA=
github.com/jc21/go-sse v0.0.0-20230307015818-b2783ddda573/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s=
github.com/jc21/go-sse v0.0.0-20230307041911-8ea9bdc44a58 h1:WSD0YdEuFPZHIe8hkAjxoAEWZnzieAiLg3zw28EVf80=
github.com/jc21/go-sse v0.0.0-20230307041911-8ea9bdc44a58/go.mod h1:4v5Xmm0eYuaWqKJ63XUV5YfQPoxtId3DgDytbnWhi+s=
github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760 h1:7wxq2DIgtO36KLrFz1RldysO0WVvcYsD49G9tyAs01k= github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760 h1:7wxq2DIgtO36KLrFz1RldysO0WVvcYsD49G9tyAs01k=
github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760/go.mod h1:yIq2t51OJgVsdRlPY68NAnyVdBH0kYXxDTFtUxOap80= github.com/jc21/jsref v0.0.0-20210608024405-a97debfc4760/go.mod h1:yIq2t51OJgVsdRlPY68NAnyVdBH0kYXxDTFtUxOap80=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=

View File

@ -39,23 +39,11 @@ func GetCertificates() func(http.ResponseWriter, *http.Request) {
// Route: GET /certificates/{certificateID} // Route: GET /certificates/{certificateID}
func GetCertificate() func(http.ResponseWriter, *http.Request) { func GetCertificate() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var err error logger.Debug("here")
var certificateID int if item := getCertificateFromRequest(w, r); item != nil {
if certificateID, err = getURLParamInt(r, "certificateID"); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
item, err := certificate.GetByID(certificateID)
switch err {
case sql.ErrNoRows:
h.NotFound(w, r)
case nil:
// nolint: errcheck,gosec // nolint: errcheck,gosec
item.Expand(getExpandFromContext(r)) item.Expand(getExpandFromContext(r))
h.ResultResponseJSON(w, r, http.StatusOK, item) h.ResultResponseJSON(w, r, http.StatusOK, item)
default:
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
} }
} }
} }
@ -64,27 +52,20 @@ func GetCertificate() func(http.ResponseWriter, *http.Request) {
// Route: POST /certificates // Route: POST /certificates
func CreateCertificate() func(http.ResponseWriter, *http.Request) { func CreateCertificate() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) var item certificate.Model
if fillObjectFromBody(w, r, "", &item) {
// Get userID from token
userID, _ := r.Context().Value(c.UserIDCtxKey).(int)
item.UserID = userID
var newCertificate certificate.Model if err := item.Save(); err != nil {
err := json.Unmarshal(bodyBytes, &newCertificate) h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Certificate: %s", err.Error()), nil)
if err != nil { return
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil) }
return
configureCertificate(item)
h.ResultResponseJSON(w, r, http.StatusOK, item)
} }
// Get userID from token
userID, _ := r.Context().Value(c.UserIDCtxKey).(int)
newCertificate.UserID = userID
if err = newCertificate.Save(); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, fmt.Sprintf("Unable to save Certificate: %s", err.Error()), nil)
return
}
configureCertificate(newCertificate)
h.ResultResponseJSON(w, r, http.StatusOK, newCertificate)
} }
} }
@ -92,49 +73,19 @@ func CreateCertificate() func(http.ResponseWriter, *http.Request) {
// Route: PUT /certificates/{certificateID} // Route: PUT /certificates/{certificateID}
func UpdateCertificate() func(http.ResponseWriter, *http.Request) { func UpdateCertificate() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var err error if item := getCertificateFromRequest(w, r); item != nil {
var certificateID int
if certificateID, err = getURLParamInt(r, "certificateID"); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
certificateObject, err := certificate.GetByID(certificateID)
switch err {
case sql.ErrNoRows:
h.NotFound(w, r)
case nil:
// This is a special endpoint, as it needs to verify the schema payload // This is a special endpoint, as it needs to verify the schema payload
// based on the certificate type, without being given a type in the payload. // based on the certificate type, without being given a type in the payload.
// The middleware would normally handle this. // The middleware would normally handle this.
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte) if fillObjectFromBody(w, r, schema.UpdateCertificate(item.Type), item) {
schemaErrors, jsonErr := middleware.CheckRequestSchema(r.Context(), schema.UpdateCertificate(certificateObject.Type), bodyBytes) if err := item.Save(); err != nil {
if jsonErr != nil { h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
h.ResultErrorJSON(w, r, http.StatusInternalServerError, fmt.Sprintf("Schema Fatal: %v", jsonErr), nil) return
return }
// configureCertificate(*item, item.Request)
h.ResultResponseJSON(w, r, http.StatusOK, item)
} }
if len(schemaErrors) > 0 {
h.ResultSchemaErrorJSON(w, r, schemaErrors)
return
}
err := json.Unmarshal(bodyBytes, &certificateObject)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return
}
if err = certificateObject.Save(); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
configureCertificate(certificateObject)
h.ResultResponseJSON(w, r, http.StatusOK, certificateObject)
default:
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
} }
} }
} }
@ -143,31 +94,87 @@ func UpdateCertificate() func(http.ResponseWriter, *http.Request) {
// Route: DELETE /certificates/{certificateID} // Route: DELETE /certificates/{certificateID}
func DeleteCertificate() func(http.ResponseWriter, *http.Request) { func DeleteCertificate() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var err error if item := getCertificateFromRequest(w, r); item != nil {
var certificateID int cnt := host.GetCertificateUseCount(item.ID)
if certificateID, err = getURLParamInt(r, "certificateID"); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return
}
item, err := certificate.GetByID(certificateID)
switch err {
case sql.ErrNoRows:
h.NotFound(w, r)
case nil:
// Ensure that this upstream isn't in use by a host
cnt := host.GetCertificateUseCount(certificateID)
if cnt > 0 { if cnt > 0 {
h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot delete certificate that is in use by at least 1 host", nil) h.ResultErrorJSON(w, r, http.StatusBadRequest, "Cannot delete certificate that is in use by at least 1 host", nil)
return return
} }
h.ResultResponseJSON(w, r, http.StatusOK, item.Delete()) h.ResultResponseJSON(w, r, http.StatusOK, item.Delete())
default:
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
} }
} }
} }
// RenewCertificate is self explanatory
// Route: PUT /certificates/{certificateID}/renew
func RenewCertificate() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if item := getCertificateFromRequest(w, r); item != nil {
configureCertificate(*item)
h.ResultResponseJSON(w, r, http.StatusOK, true)
}
}
}
// DownloadCertificate is self explanatory
// Route: PUT /certificates/{certificateID}/download
func DownloadCertificate() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
if item := getCertificateFromRequest(w, r); item != nil {
// todo
h.ResultResponseJSON(w, r, http.StatusOK, "ok")
}
}
}
// getCertificateFromRequest has some reusable code for all endpoints that
// have a certificate id in the url. it will write errors to the output.
func getCertificateFromRequest(w http.ResponseWriter, r *http.Request) *certificate.Model {
var err error
var certificateID int
if certificateID, err = getURLParamInt(r, "certificateID"); err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
return nil
}
certificateObject, err := certificate.GetByID(certificateID)
switch err {
case sql.ErrNoRows:
h.NotFound(w, r)
case nil:
return &certificateObject
default:
h.ResultErrorJSON(w, r, http.StatusBadRequest, err.Error(), nil)
}
return nil
}
// getCertificateFromRequest has some reusable code for all endpoints that
// have a certificate id in the url. it will write errors to the output.
func fillObjectFromBody(w http.ResponseWriter, r *http.Request, validationSchema string, o interface{}) bool {
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
if validationSchema != "" {
schemaErrors, jsonErr := middleware.CheckRequestSchema(r.Context(), validationSchema, bodyBytes)
if jsonErr != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, fmt.Sprintf("Schema Fatal: %v", jsonErr), nil)
return false
}
if len(schemaErrors) > 0 {
h.ResultSchemaErrorJSON(w, r, schemaErrors)
return false
}
}
err := json.Unmarshal(bodyBytes, o)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return false
}
return true
}
func configureCertificate(c certificate.Model) { func configureCertificate(c certificate.Model) {
err := jobqueue.AddJob(jobqueue.Job{ err := jobqueue.AddJob(jobqueue.Job{
Name: "RequestCertificate", Name: "RequestCertificate",

View File

@ -0,0 +1,28 @@
package handler
import (
"encoding/json"
"net/http"
c "npm/internal/api/context"
h "npm/internal/api/http"
"npm/internal/serverevents"
)
// TestSSENotification specifically fires of a SSE message for testing purposes
// Route: POST /sse-notification
func TestSSENotification() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
bodyBytes, _ := r.Context().Value(c.BodyCtxKey).([]byte)
var msg serverevents.Message
err := json.Unmarshal(bodyBytes, &msg)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, h.ErrInvalidPayload.Error(), nil)
return
}
serverevents.Send(msg, "")
h.ResultResponseJSON(w, r, http.StatusOK, true)
}
}

View File

@ -65,7 +65,7 @@ func NewToken() func(http.ResponseWriter, *http.Request) {
return return
} }
if response, err := njwt.Generate(&userObj); err != nil { if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else { } else {
h.ResultResponseJSON(w, r, http.StatusOK, response) h.ResultResponseJSON(w, r, http.StatusOK, response)
@ -80,7 +80,34 @@ func RefreshToken() func(http.ResponseWriter, *http.Request) {
// TODO: Use your own methods to verify an existing user is // TODO: Use your own methods to verify an existing user is
// able to refresh their token and then give them a new one // able to refresh their token and then give them a new one
userObj, _ := user.GetByEmail("jc@jc21.com") userObj, _ := user.GetByEmail("jc@jc21.com")
if response, err := njwt.Generate(&userObj); err != nil { if response, err := njwt.Generate(&userObj, false); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else {
h.ResultResponseJSON(w, r, http.StatusOK, response)
}
}
}
// NewSSEToken will generate and return a very short lived token for
// use by the /sse/* endpoint. It requires an app token to generate this
// Route: POST /tokens/sse
func NewSSEToken() func(http.ResponseWriter, *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value(c.UserIDCtxKey).(int)
// Find user
userObj, userErr := user.GetByID(userID)
if userErr != nil {
h.ResultErrorJSON(w, r, http.StatusBadRequest, errors.ErrInvalidLogin.Error(), nil)
return
}
if userObj.IsDisabled {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, errors.ErrUserDisabled.Error(), nil)
return
}
if response, err := njwt.Generate(&userObj, true); err != nil {
h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil) h.ResultErrorJSON(w, r, http.StatusInternalServerError, err.Error(), nil)
} else { } else {
h.ResultResponseJSON(w, r, http.StatusOK, response) h.ResultResponseJSON(w, r, http.StatusOK, response)

View File

@ -0,0 +1,34 @@
package middleware
import (
"net/http"
h "npm/internal/api/http"
"npm/internal/entity/user"
"github.com/go-chi/jwtauth"
)
// SSEAuth will validate that the jwt token provided to get this far is a SSE token
// and that the user is enabled
func SSEAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
token, claims, err := jwtauth.FromContext(ctx)
if err != nil {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, err.Error(), nil)
return
}
userID := int(claims["uid"].(float64))
_, enabled := user.IsEnabled(userID)
if token == nil || !token.Valid || !enabled || !claims.VerifyIssuer("sse", true) {
h.ResultErrorJSON(w, r, http.StatusUnauthorized, "Unauthorised", nil)
return
}
// Should be all good now
next.ServeHTTP(w, r)
})
}

View File

@ -19,6 +19,7 @@ import (
"npm/internal/entity/upstream" "npm/internal/entity/upstream"
"npm/internal/entity/user" "npm/internal/entity/user"
"npm/internal/logger" "npm/internal/logger"
"npm/internal/serverevents"
"github.com/go-chi/chi" "github.com/go-chi/chi"
chiMiddleware "github.com/go-chi/chi/middleware" chiMiddleware "github.com/go-chi/chi/middleware"
@ -45,7 +46,6 @@ func NewRouter() http.Handler {
chiMiddleware.RealIP, chiMiddleware.RealIP,
chiMiddleware.Recoverer, chiMiddleware.Recoverer,
chiMiddleware.Throttle(5), chiMiddleware.Throttle(5),
chiMiddleware.Timeout(30*time.Second),
middleware.PrettyPrint, middleware.PrettyPrint,
middleware.Expansion, middleware.Expansion,
middleware.DecodeAuth(), middleware.DecodeAuth(),
@ -61,8 +61,14 @@ func applyRoutes(r chi.Router) chi.Router {
r.NotFound(handler.NotFound()) r.NotFound(handler.NotFound())
r.MethodNotAllowed(handler.NotAllowed()) r.MethodNotAllowed(handler.NotAllowed())
// SSE - requires a sse token as the `jwt` get parameter
// Exists inside /api but it's here so that we can skip the Timeout middleware
// that applies to other endpoints.
r.With(middleware.EnforceSetup(true), middleware.SSEAuth).
Mount("/api/sse", serverevents.Get())
// API // API
r.Route("/api", func(r chi.Router) { r.With(chiMiddleware.Timeout(30*time.Second)).Route("/api", func(r chi.Router) {
r.Get("/", handler.Health()) r.Get("/", handler.Health())
r.Get("/schema", handler.Schema()) r.Get("/schema", handler.Schema())
r.With(middleware.EnforceSetup(true), middleware.Enforce("")). r.With(middleware.EnforceSetup(true), middleware.Enforce("")).
@ -74,34 +80,54 @@ func applyRoutes(r chi.Router) chi.Router {
Post("/", handler.NewToken()) Post("/", handler.NewToken())
r.With(middleware.Enforce("")). r.With(middleware.Enforce("")).
Get("/", handler.RefreshToken()) Get("/", handler.RefreshToken())
r.With(middleware.Enforce("")).
Post("/sse", handler.NewSSEToken())
}) })
// Users // Users
r.Route("/users", func(r chi.Router) { r.Route("/users", func(r chi.Router) {
r.With(middleware.EnforceSetup(true), middleware.Enforce("")).Get("/{userID:(?:me)}", handler.GetUser()) // Create - can be done in Setup stage as well
r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityUsersManage)).Get("/{userID:(?:[0-9]+)}", handler.GetUser()) r.With(middleware.Enforce(user.CapabilityUsersManage), middleware.EnforceRequestSchema(schema.CreateUser())).
r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityUsersManage)).Delete("/{userID:(?:[0-9]+|me)}", handler.DeleteUser())
r.With(middleware.EnforceSetup(true), middleware.Enforce(user.CapabilityUsersManage)).With(middleware.Filters(user.GetFilterSchema())).
Get("/", handler.GetUsers())
r.With(middleware.EnforceRequestSchema(schema.CreateUser()), middleware.Enforce(user.CapabilityUsersManage)).
Post("/", handler.CreateUser()) Post("/", handler.CreateUser())
r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.UpdateUser()), middleware.Enforce("")). // Requires Setup stage to be completed
Put("/{userID:(?:me)}", handler.UpdateUser()) r.With(middleware.EnforceSetup(true)).Route("/", func(r chi.Router) {
r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.UpdateUser()), middleware.Enforce(user.CapabilityUsersManage)). // Get yourself, requires a login but no other permissions
Put("/{userID:(?:[0-9]+)}", handler.UpdateUser()) r.With(middleware.Enforce("")).
Get("/{userID:(?:me)}", handler.GetUser())
// Auth // Update yourself, requires a login but no other permissions
r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.SetAuth()), middleware.Enforce("")). r.With(middleware.Enforce(""), middleware.EnforceRequestSchema(schema.UpdateUser())).
Post("/{userID:(?:me)}/auth", handler.SetAuth()) Put("/{userID:(?:me)}", handler.UpdateUser())
r.With(middleware.EnforceSetup(true)).With(middleware.EnforceRequestSchema(schema.SetAuth()), middleware.Enforce(user.CapabilityUsersManage)).
Post("/{userID:(?:[0-9]+)}/auth", handler.SetAuth()) r.With(middleware.Enforce(user.CapabilityUsersManage)).Route("/", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityUsersManage), middleware.Filters(user.GetFilterSchema())).
Get("/", handler.GetUsers())
// Specific Item
r.Get("/{userID:(?:[0-9]+)}", handler.GetUser())
r.Delete("/{userID:(?:[0-9]+|me)}", handler.DeleteUser())
// Update another user
r.With(middleware.EnforceRequestSchema(schema.UpdateUser())).
Put("/{userID:(?:[0-9]+)}", handler.UpdateUser())
})
// Auth - sets passwords
r.With(middleware.Enforce(""), middleware.EnforceRequestSchema(schema.SetAuth())).
Post("/{userID:(?:me)}/auth", handler.SetAuth())
r.With(middleware.Enforce(user.CapabilityUsersManage), middleware.EnforceRequestSchema(schema.SetAuth())).
Post("/{userID:(?:[0-9]+)}/auth", handler.SetAuth())
})
}) })
// Only available in debug mode: delete users without auth // Only available in debug mode
if config.GetLogLevel() == logger.DebugLevel { if config.GetLogLevel() == logger.DebugLevel {
// delete users without auth
r.Delete("/users", handler.DeleteUsers()) r.Delete("/users", handler.DeleteUsers())
// SSE test endpoints
r.Post("/sse-notification", handler.TestSSENotification())
} }
// Settings // Settings
@ -117,27 +143,48 @@ func applyRoutes(r chi.Router) chi.Router {
// Access Lists // Access Lists
r.With(middleware.EnforceSetup(true)).Route("/access-lists", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/access-lists", func(r chi.Router) {
// List
r.With(middleware.Filters(accesslist.GetFilterSchema()), middleware.Enforce(user.CapabilityAccessListsView)). r.With(middleware.Filters(accesslist.GetFilterSchema()), middleware.Enforce(user.CapabilityAccessListsView)).
Get("/", handler.GetAccessLists()) Get("/", handler.GetAccessLists())
r.With(middleware.Enforce(user.CapabilityAccessListsView)).Get("/{accessListID:[0-9]+}", handler.GetAccessList())
r.With(middleware.Enforce(user.CapabilityAccessListsManage)).Delete("/{accessListID:[0-9]+}", handler.DeleteAccessList()) // Create
r.With(middleware.Enforce(user.CapabilityAccessListsManage)).With(middleware.EnforceRequestSchema(schema.CreateAccessList())). r.With(middleware.Enforce(user.CapabilityAccessListsManage), middleware.EnforceRequestSchema(schema.CreateAccessList())).
Post("/", handler.CreateAccessList()) Post("/", handler.CreateAccessList())
r.With(middleware.Enforce(user.CapabilityAccessListsManage)).With(middleware.EnforceRequestSchema(schema.UpdateAccessList())).
Put("/{accessListID:[0-9]+}", handler.UpdateAccessList()) // Specific Item
r.Route("/{accessListID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityAccessListsView)).
Get("/", handler.GetAccessList())
r.With(middleware.Enforce(user.CapabilityAccessListsManage)).Route("/", func(r chi.Router) {
r.Delete("/{accessListID:[0-9]+}", handler.DeleteAccessList())
r.With(middleware.EnforceRequestSchema(schema.UpdateAccessList())).
Put("/{accessListID:[0-9]+}", handler.UpdateAccessList())
})
})
}) })
// DNS Providers // DNS Providers
r.With(middleware.EnforceSetup(true)).Route("/dns-providers", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/dns-providers", func(r chi.Router) {
r.With(middleware.Filters(dnsprovider.GetFilterSchema()), middleware.Enforce(user.CapabilityDNSProvidersView)). // List
r.With(middleware.Enforce(user.CapabilityDNSProvidersView), middleware.Filters(dnsprovider.GetFilterSchema())).
Get("/", handler.GetDNSProviders()) Get("/", handler.GetDNSProviders())
r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).Get("/{providerID:[0-9]+}", handler.GetDNSProvider())
r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).Delete("/{providerID:[0-9]+}", handler.DeleteDNSProvider())
r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).With(middleware.EnforceRequestSchema(schema.CreateDNSProvider())).
Post("/", handler.CreateDNSProvider())
r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).With(middleware.EnforceRequestSchema(schema.UpdateDNSProvider())).
Put("/{providerID:[0-9]+}", handler.UpdateDNSProvider())
// Create
r.With(middleware.Enforce(user.CapabilityDNSProvidersManage), middleware.EnforceRequestSchema(schema.CreateDNSProvider())).
Post("/", handler.CreateDNSProvider())
// Specific Item
r.Route("/{providerID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).
Get("/{providerID:[0-9]+}", handler.GetDNSProvider())
r.With(middleware.Enforce(user.CapabilityDNSProvidersManage)).Route("/", func(r chi.Router) {
r.Delete("/", handler.DeleteDNSProvider())
r.With(middleware.EnforceRequestSchema(schema.UpdateDNSProvider())).
Put("/{providerID:[0-9]+}", handler.UpdateDNSProvider())
})
})
// List Acme DNS Providers
r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).Route("/acmesh", func(r chi.Router) { r.With(middleware.Enforce(user.CapabilityDNSProvidersView)).Route("/acmesh", func(r chi.Router) {
r.Get("/{acmeshID:[a-z0-9_]+}", handler.GetAcmeshProvider()) r.Get("/{acmeshID:[a-z0-9_]+}", handler.GetAcmeshProvider())
r.Get("/", handler.GetAcmeshProviders()) r.Get("/", handler.GetAcmeshProviders())
@ -146,81 +193,141 @@ func applyRoutes(r chi.Router) chi.Router {
// Certificate Authorities // Certificate Authorities
r.With(middleware.EnforceSetup(true)).Route("/certificate-authorities", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/certificate-authorities", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView), middleware.Filters(certificateauthority.GetFilterSchema())). r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView), middleware.Filters(certificateauthority.GetFilterSchema())).
Get("/", handler.GetCertificateAuthorities()) Get("/", handler.GetCertificateAuthorities())
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView)).Get("/{caID:[0-9]+}", handler.GetCertificateAuthority())
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).Delete("/{caID:[0-9]+}", handler.DeleteCertificateAuthority()) // Create
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).With(middleware.EnforceRequestSchema(schema.CreateCertificateAuthority())). r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage), middleware.EnforceRequestSchema(schema.CreateCertificateAuthority())).
Post("/", handler.CreateCertificateAuthority()) Post("/", handler.CreateCertificateAuthority())
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).With(middleware.EnforceRequestSchema(schema.UpdateCertificateAuthority())).
Put("/{caID:[0-9]+}", handler.UpdateCertificateAuthority()) // Specific Item
r.Route("/{caID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesView)).
Get("/", handler.GetCertificateAuthority())
r.With(middleware.Enforce(user.CapabilityCertificateAuthoritiesManage)).Route("/", func(r chi.Router) {
r.Delete("/{caID:[0-9]+}", handler.DeleteCertificateAuthority())
r.With(middleware.EnforceRequestSchema(schema.UpdateCertificateAuthority())).
Put("/{caID:[0-9]+}", handler.UpdateCertificateAuthority())
})
})
}) })
// Certificates // Certificates
r.With(middleware.EnforceSetup(true)).Route("/certificates", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/certificates", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityCertificatesView), middleware.Filters(certificate.GetFilterSchema())). r.With(middleware.Enforce(user.CapabilityCertificatesView), middleware.Filters(certificate.GetFilterSchema())).
Get("/", handler.GetCertificates()) Get("/", handler.GetCertificates())
r.With(middleware.Enforce(user.CapabilityCertificatesView)).Get("/{certificateID:[0-9]+}", handler.GetCertificate())
r.With(middleware.Enforce(user.CapabilityCertificatesManage)).Delete("/{certificateID:[0-9]+}", handler.DeleteCertificate()) // Create
r.With(middleware.Enforce(user.CapabilityCertificatesManage)).With(middleware.EnforceRequestSchema(schema.CreateCertificate())). r.With(middleware.Enforce(user.CapabilityCertificatesManage), middleware.EnforceRequestSchema(schema.CreateCertificate())).
Post("/", handler.CreateCertificate()) Post("/", handler.CreateCertificate())
/*
r.With(middleware.EnforceRequestSchema(schema.UpdateCertificate())). // Specific Item
Put("/{certificateID:[0-9]+}", handler.UpdateCertificate()) r.Route("/{certificateID:[0-9]+}", func(r chi.Router) {
*/ r.With(middleware.Enforce(user.CapabilityCertificatesView)).
r.With(middleware.Enforce(user.CapabilityCertificatesManage)).Put("/{certificateID:[0-9]+}", handler.UpdateCertificate()) Get("/", handler.GetCertificate())
r.With(middleware.Enforce(user.CapabilityCertificatesManage)).Route("/", func(r chi.Router) {
r.Delete("/", handler.DeleteCertificate())
r.Put("/", handler.UpdateCertificate())
// r.With(middleware.EnforceRequestSchema(schema.UpdateCertificate())).
// Put("/", handler.UpdateCertificate())
r.Post("/renew", handler.RenewCertificate())
r.Get("/download", handler.DownloadCertificate())
})
})
}) })
// Hosts // Hosts
r.With(middleware.EnforceSetup(true)).Route("/hosts", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/hosts", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityHostsView), middleware.Filters(host.GetFilterSchema())). r.With(middleware.Enforce(user.CapabilityHostsView), middleware.Filters(host.GetFilterSchema())).
Get("/", handler.GetHosts()) Get("/", handler.GetHosts())
r.With(middleware.Enforce(user.CapabilityHostsView)).Get("/{hostID:[0-9]+}", handler.GetHost())
r.With(middleware.Enforce(user.CapabilityHostsManage)).Delete("/{hostID:[0-9]+}", handler.DeleteHost()) // Create
r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.CreateHost())). r.With(middleware.Enforce(user.CapabilityHostsManage), middleware.EnforceRequestSchema(schema.CreateHost())).
Post("/", handler.CreateHost()) Post("/", handler.CreateHost())
r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.UpdateHost())).
Put("/{hostID:[0-9]+}", handler.UpdateHost()) // Specific Item
r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{hostID:[0-9]+}/nginx-config", handler.GetHostNginxConfig("json")) r.Route("/{hostID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{hostID:[0-9]+}/nginx-config.txt", handler.GetHostNginxConfig("text")) r.With(middleware.Enforce(user.CapabilityHostsView)).
Get("/", handler.GetHost())
r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) {
r.Delete("/", handler.DeleteHost())
r.With(middleware.EnforceRequestSchema(schema.UpdateHost())).
Put("/", handler.UpdateHost())
r.Get("/nginx-config", handler.GetHostNginxConfig("json"))
r.Get("/nginx-config.txt", handler.GetHostNginxConfig("text"))
})
})
}) })
// Nginx Templates // Nginx Templates
r.With(middleware.EnforceSetup(true)).Route("/nginx-templates", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/nginx-templates", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityNginxTemplatesView), middleware.Filters(nginxtemplate.GetFilterSchema())). r.With(middleware.Enforce(user.CapabilityNginxTemplatesView), middleware.Filters(nginxtemplate.GetFilterSchema())).
Get("/", handler.GetNginxTemplates()) Get("/", handler.GetNginxTemplates())
r.With(middleware.Enforce(user.CapabilityNginxTemplatesView)).Get("/{templateID:[0-9]+}", handler.GetNginxTemplates())
r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage)).Delete("/{templateID:[0-9]+}", handler.DeleteNginxTemplate()) // Create
r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage)).With(middleware.EnforceRequestSchema(schema.CreateNginxTemplate())). r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage), middleware.EnforceRequestSchema(schema.CreateNginxTemplate())).
Post("/", handler.CreateNginxTemplate()) Post("/", handler.CreateNginxTemplate())
r.With(middleware.Enforce(user.CapabilityNginxTemplatesManage)).With(middleware.EnforceRequestSchema(schema.UpdateNginxTemplate())).
Put("/{templateID:[0-9]+}", handler.UpdateNginxTemplate()) // Specific Item
r.Route("/{templateID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityNginxTemplatesView)).
Get("/", handler.GetNginxTemplates())
r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) {
r.Delete("/", handler.DeleteNginxTemplate())
r.With(middleware.EnforceRequestSchema(schema.UpdateNginxTemplate())).
Put("/", handler.UpdateNginxTemplate())
})
})
}) })
// Streams // Streams
r.With(middleware.EnforceSetup(true)).Route("/streams", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/streams", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityStreamsView), middleware.Filters(stream.GetFilterSchema())). r.With(middleware.Enforce(user.CapabilityStreamsView), middleware.Filters(stream.GetFilterSchema())).
Get("/", handler.GetStreams()) Get("/", handler.GetStreams())
r.With(middleware.Enforce(user.CapabilityStreamsView)).Get("/{hostID:[0-9]+}", handler.GetStream())
r.With(middleware.Enforce(user.CapabilityStreamsManage)).Delete("/{hostID:[0-9]+}", handler.DeleteStream()) // Create
r.With(middleware.Enforce(user.CapabilityStreamsManage)).With(middleware.EnforceRequestSchema(schema.CreateStream())). r.With(middleware.Enforce(user.CapabilityStreamsManage), middleware.EnforceRequestSchema(schema.CreateStream())).
Post("/", handler.CreateStream()) Post("/", handler.CreateStream())
r.With(middleware.Enforce(user.CapabilityStreamsManage)).With(middleware.EnforceRequestSchema(schema.UpdateStream())).
Put("/{hostID:[0-9]+}", handler.UpdateStream()) // Specific Item
r.Route("/{hostID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityStreamsView)).
Get("/", handler.GetStream())
r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) {
r.Delete("/", handler.DeleteStream())
r.With(middleware.EnforceRequestSchema(schema.UpdateStream())).
Put("/", handler.UpdateStream())
})
})
}) })
// Upstreams // Upstreams
r.With(middleware.EnforceSetup(true)).Route("/upstreams", func(r chi.Router) { r.With(middleware.EnforceSetup(true)).Route("/upstreams", func(r chi.Router) {
// List
r.With(middleware.Enforce(user.CapabilityHostsView), middleware.Filters(upstream.GetFilterSchema())). r.With(middleware.Enforce(user.CapabilityHostsView), middleware.Filters(upstream.GetFilterSchema())).
Get("/", handler.GetUpstreams()) Get("/", handler.GetUpstreams())
r.With(middleware.Enforce(user.CapabilityHostsView)).Get("/{upstreamID:[0-9]+}", handler.GetUpstream())
r.With(middleware.Enforce(user.CapabilityHostsManage)).Delete("/{upstreamID:[0-9]+}", handler.DeleteUpstream()) // Create
r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.CreateUpstream())). r.With(middleware.Enforce(user.CapabilityHostsManage), middleware.EnforceRequestSchema(schema.CreateUpstream())).
Post("/", handler.CreateUpstream()) Post("/", handler.CreateUpstream())
r.With(middleware.Enforce(user.CapabilityHostsManage)).With(middleware.EnforceRequestSchema(schema.UpdateUpstream())).
Put("/{upstreamID:[0-9]+}", handler.UpdateUpstream()) // Specific Item
r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{upstreamID:[0-9]+}/nginx-config", handler.GetUpstreamNginxConfig("json")) r.Route("/{upstreamID:[0-9]+}", func(r chi.Router) {
r.With(middleware.Enforce(user.CapabilityHostsManage)).Get("/{upstreamID:[0-9]+}/nginx-config.txt", handler.GetUpstreamNginxConfig("text")) r.With(middleware.Enforce(user.CapabilityHostsView)).
Get("/", handler.GetUpstream())
r.With(middleware.Enforce(user.CapabilityHostsManage)).Route("/", func(r chi.Router) {
r.Delete("/", handler.DeleteUpstream())
r.With(middleware.EnforceRequestSchema(schema.UpdateUpstream())).
Put("/", handler.UpdateUpstream())
r.Get("/nginx-config", handler.GetUpstreamNginxConfig("json"))
r.Get("/nginx-config.txt", handler.GetUpstreamNginxConfig("text"))
})
})
}) })
}) })

View File

@ -6,6 +6,7 @@ import (
"time" "time"
"npm/internal/logger" "npm/internal/logger"
"npm/internal/serverevents"
) )
const httpPort = 3000 const httpPort = 3000
@ -20,6 +21,8 @@ func StartServer() {
ReadHeaderTimeout: 3 * time.Second, ReadHeaderTimeout: 3 * time.Second,
} }
defer serverevents.Shutdown()
err := server.ListenAndServe() err := server.ListenAndServe()
if err != nil { if err != nil {
logger.Error("HttpListenError", err) logger.Error("HttpListenError", err)

View File

@ -14,6 +14,7 @@ import (
"npm/internal/entity/dnsprovider" "npm/internal/entity/dnsprovider"
"npm/internal/entity/user" "npm/internal/entity/user"
"npm/internal/logger" "npm/internal/logger"
"npm/internal/serverevents"
"npm/internal/types" "npm/internal/types"
"npm/internal/util" "npm/internal/util"
@ -123,6 +124,9 @@ func (m *Model) Delete() bool {
if err := m.Save(); err != nil { if err := m.Save(); err != nil {
return false return false
} }
// todo: delete from acme.sh as well
return true return true
} }
@ -239,6 +243,7 @@ func (m *Model) GetCertificateLocations() (string, string, string) {
// Request makes a certificate request // Request makes a certificate request
func (m *Model) Request() error { func (m *Model) Request() error {
logger.Info("Requesting certificate for: #%d %v", m.ID, m.Name) logger.Info("Requesting certificate for: #%d %v", m.ID, m.Name)
serverevents.SendChange("certificates")
// nolint: errcheck, gosec // nolint: errcheck, gosec
m.Expand([]string{"certificate-authority", "dns-provider"}) m.Expand([]string{"certificate-authority", "dns-provider"})
@ -283,6 +288,7 @@ func (m *Model) Request() error {
return err return err
} }
serverevents.SendChange("certificates")
logger.Info("Request for certificate for: #%d %v was completed", m.ID, m.Name) logger.Info("Request for certificate for: #%d %v was completed", m.ID, m.Name)
return nil return nil
} }

View File

@ -24,11 +24,16 @@ type GeneratedResponse struct {
} }
// Generate will create a JWT // Generate will create a JWT
func Generate(userObj *user.Model) (GeneratedResponse, error) { func Generate(userObj *user.Model, forSSE bool) (GeneratedResponse, error) {
var response GeneratedResponse var response GeneratedResponse
key, _ := GetPrivateKey() key, _ := GetPrivateKey()
expires := time.Now().AddDate(0, 0, 1) // 1 day expires := time.Now().AddDate(0, 0, 1) // 1 day
issuer := "api"
if forSSE {
issuer = "sse"
}
// Create the Claims // Create the Claims
claims := UserJWTClaims{ claims := UserJWTClaims{
@ -37,7 +42,7 @@ func Generate(userObj *user.Model) (GeneratedResponse, error) {
jwt.StandardClaims{ jwt.StandardClaims{
IssuedAt: time.Now().Unix(), IssuedAt: time.Now().Unix(),
ExpiresAt: expires.Unix(), ExpiresAt: expires.Unix(),
Issuer: "api", Issuer: issuer,
}, },
} }

View File

@ -92,6 +92,11 @@ func Error(errorClass string, err error) {
logger.Error(errorClass, err) logger.Error(errorClass, err)
} }
// Get returns the logger
func Get() *Logger {
return logger
}
// Configure logger and will return error if missing required fields. // Configure logger and will return error if missing required fields.
func (l *Logger) Configure(c *Config) error { func (l *Logger) Configure(c *Config) error {
// ensure updates to the config are atomic // ensure updates to the config are atomic

View File

@ -0,0 +1,74 @@
package serverevents
import (
"encoding/json"
"net/http"
"npm/internal/logger"
"github.com/jc21/go-sse"
)
var instance *sse.Server
const defaultChannel = "changes"
// Message is how we're going to send the data
type Message struct {
Lang string `json:"lang,omitempty"`
LangParams map[string]string `json:"lang_params,omitempty"`
Type string `json:"type,omitempty"`
Affects string `json:"affects,omitempty"`
}
// Get will return a sse server
func Get() *sse.Server {
if instance == nil {
instance = sse.NewServer(&sse.Options{
Logger: logger.Get(),
ChannelNameFunc: func(request *http.Request) string {
return defaultChannel // This is the channel for all updates regardless of visibility
},
})
}
return instance
}
// Shutdown will shutdown the server
func Shutdown() {
if instance != nil {
instance.Shutdown()
}
}
// SendChange will send a specific change
func SendChange(affects string) {
Send(Message{Affects: affects}, "")
}
// SendMessage will construct a message for the UI
func SendMessage(typ, lang string, langParams map[string]string) {
Send(Message{
Type: typ,
Lang: lang,
LangParams: langParams,
}, "")
}
// Send will send a message
func Send(msg Message, channel string) {
if channel == "" {
channel = defaultChannel
}
logger.Debug("SSE Sending: %+v", msg)
if data, err := json.Marshal(msg); err != nil {
logger.Error("SSEError", err)
} else {
Get().SendMessage(channel, sse.SimpleMessage(string(data)))
}
}
// TODO: if we end up implementing user visibility,
// then we'll have to subscribe people to their own
// channels and publish to all or some depending on visibility.
// This means using a specific ChannelNameFunc that revolves
// around the user and their visibility.

View File

@ -7,12 +7,18 @@ server {
} }
location /api/ { location /api/ {
add_header X-Served-By $host; add_header X-Served-By $host;
proxy_set_header Host $host; chunked_transfer_encoding off;
proxy_set_header X-Forwarded-Scheme $scheme; proxy_buffering off;
proxy_set_header X-Forwarded-Proto $scheme; proxy_cache off;
proxy_set_header X-Forwarded-For $remote_addr; proxy_http_version 1.1;
proxy_pass http://127.0.0.1:3000/api/; proxy_set_header Host $host;
proxy_set_header Connection '';
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Accel-Buffering no;
proxy_pass http://127.0.0.1:3000/api/;
} }
location ~ .html { location ~ .html {

View File

@ -4,11 +4,17 @@ server {
server_name nginxproxymanager; server_name nginxproxymanager;
location / { location / {
add_header X-Served-By $host; add_header X-Served-By $host;
proxy_set_header Host $host; chunked_transfer_encoding off;
proxy_set_header X-Forwarded-Scheme $scheme; proxy_buffering off;
proxy_set_header X-Forwarded-Proto $scheme; proxy_cache off;
proxy_set_header X-Forwarded-For $remote_addr; proxy_http_version 1.1;
proxy_pass http://localhost:3000/; proxy_set_header Host $host;
proxy_set_header Connection '';
proxy_set_header X-Forwarded-Scheme $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Accel-Buffering no;
proxy_pass http://localhost:3000/;
} }
} }

View File

@ -0,0 +1,14 @@
import * as api from "./base";
import { TokenResponse } from "./responseTypes";
export async function getSSEToken(
abortController?: AbortController,
): Promise<TokenResponse> {
const { result } = await api.post(
{
url: "/tokens/sse",
},
abortController,
);
return result;
}

View File

@ -14,6 +14,7 @@ export * from "./getHealth";
export * from "./getHosts"; export * from "./getHosts";
export * from "./getNginxTemplates"; export * from "./getNginxTemplates";
export * from "./getSettings"; export * from "./getSettings";
export * from "./getSSEToken";
export * from "./getToken"; export * from "./getToken";
export * from "./getUpstreamNginxConfig"; export * from "./getUpstreamNginxConfig";
export * from "./getUpstreams"; export * from "./getUpstreams";
@ -22,6 +23,7 @@ export * from "./getUsers";
export * from "./helpers"; export * from "./helpers";
export * from "./models"; export * from "./models";
export * from "./refreshToken"; export * from "./refreshToken";
export * from "./renewCertificate";
export * from "./responseTypes"; export * from "./responseTypes";
export * from "./setAuth"; export * from "./setAuth";
export * from "./setCertificate"; export * from "./setCertificate";

View File

@ -165,3 +165,10 @@ export interface Upstream {
advancedConfig: string; advancedConfig: string;
isDisabled: boolean; isDisabled: boolean;
} }
export interface SSEMessage {
lang?: string;
langParams?: string;
type?: "info" | "warning" | "success" | "error" | "loading";
affects?: string | string[];
}

View File

@ -0,0 +1,15 @@
import * as api from "./base";
import { Certificate } from "./models";
export async function renewCertificate(
id: number,
abortController?: AbortController,
): Promise<Certificate> {
const { result } = await api.post(
{
url: `/certificates/${id}/renew`,
},
abortController,
);
return result;
}

View File

@ -1,12 +1,56 @@
import { ReactNode } from "react"; import { useEffect, ReactNode } from "react";
import { Box, Container } from "@chakra-ui/react"; import { Box, Container, useToast } from "@chakra-ui/react";
import { getSSEToken, SSEMessage } from "api/npm";
import { Footer, Navigation } from "components"; import { Footer, Navigation } from "components";
import { intl } from "locale";
import AuthStore from "modules/AuthStore";
import { useQueryClient } from "react-query";
interface Props { interface Props {
children?: ReactNode; children?: ReactNode;
} }
function SiteWrapper({ children }: Props) { function SiteWrapper({ children }: Props) {
const queryClient = useQueryClient();
const toast = useToast();
// TODO: fix bug where this will fail if the browser is kept open longer
// than the expiry of the sse token
useEffect(() => {
async function fetchData() {
const response = await getSSEToken();
const eventSource = new EventSource(
`/api/sse/changes?jwt=${response.token}`,
);
eventSource.onmessage = (e: any) => {
const j: SSEMessage = JSON.parse(e.data);
if (j) {
if (j.affects) {
queryClient.invalidateQueries(j.affects);
}
if (j.type) {
toast({
description: intl.formatMessage({ id: j.lang }),
status: j.type || "info",
position: "top",
duration: 3000,
isClosable: true,
});
}
}
};
eventSource.onerror = (e) => {
console.error("SSE EventSource failed:", e);
};
return () => {
eventSource.close();
};
}
if (AuthStore.token) {
fetchData();
}
}, [queryClient, toast]);
return ( return (
<Box display="flex" flexDir="column" height="100vh"> <Box display="flex" flexDir="column" height="100vh">
<Box flexShrink={0}> <Box flexShrink={0}>

View File

@ -71,6 +71,9 @@
"certificate.create": { "certificate.create": {
"defaultMessage": "Zertifikat erstellen" "defaultMessage": "Zertifikat erstellen"
}, },
"certificate.renewal-requested": {
"defaultMessage": "Renewal has been queued"
},
"certificates.title": { "certificates.title": {
"defaultMessage": "Zertifikate" "defaultMessage": "Zertifikate"
}, },

View File

@ -347,6 +347,9 @@
"certificate.create": { "certificate.create": {
"defaultMessage": "Create Certificate" "defaultMessage": "Create Certificate"
}, },
"certificate.renewal-requested": {
"defaultMessage": "Renewal has been queued"
},
"certificates.title": { "certificates.title": {
"defaultMessage": "Certificates" "defaultMessage": "Certificates"
}, },

View File

@ -71,6 +71,9 @@
"certificate.create": { "certificate.create": {
"defaultMessage": "ایجاد گواهی" "defaultMessage": "ایجاد گواهی"
}, },
"certificate.renewal-requested": {
"defaultMessage": "Renewal has been queued"
},
"certificates.title": { "certificates.title": {
"defaultMessage": "گواهینامه ها" "defaultMessage": "گواهینامه ها"
}, },

View File

@ -25,6 +25,7 @@ export interface TableProps {
sortBy: TableSortBy[]; sortBy: TableSortBy[];
filters: TableFilter[]; filters: TableFilter[];
onTableEvent: any; onTableEvent: any;
onRenewal: (id: number) => void;
} }
function Table({ function Table({
data, data,
@ -32,6 +33,7 @@ function Table({
onTableEvent, onTableEvent,
sortBy, sortBy,
filters, filters,
onRenewal,
}: TableProps) { }: TableProps) {
const [editId, setEditId] = useState(0); const [editId, setEditId] = useState(0);
const [columns, tableData] = useMemo(() => { const [columns, tableData] = useMemo(() => {
@ -85,7 +87,7 @@ function Table({
title: intl.formatMessage({ title: intl.formatMessage({
id: "action.renew", id: "action.renew",
}), }),
onClick: (e: any, { id }: any) => alert(id), onClick: (e: any, { id }: any) => onRenewal(id),
icon: <FiRefreshCw />, icon: <FiRefreshCw />,
disabled: (data: any) => disabled: (data: any) =>
data.type !== "dns" && data.type !== "http", data.type !== "dns" && data.type !== "http",
@ -110,7 +112,7 @@ function Table({
}, },
]; ];
return [columns, data]; return [columns, data];
}, [data]); }, [data, onRenewal]);
const tableInstance = useTable( const tableInstance = useTable(
{ {

View File

@ -1,9 +1,11 @@
import { useEffect, useReducer, useState } from "react"; import { useEffect, useReducer, useState } from "react";
import { Alert, AlertIcon } from "@chakra-ui/react"; import { Alert, AlertIcon, useToast } from "@chakra-ui/react";
import { renewCertificate } from "api/npm";
import { EmptyList, SpinnerPage, tableEventReducer } from "components"; import { EmptyList, SpinnerPage, tableEventReducer } from "components";
import { useCertificates } from "hooks"; import { useCertificates } from "hooks";
import { intl } from "locale"; import { intl } from "locale";
import { useQueryClient } from "react-query";
import Table from "./Table"; import Table from "./Table";
@ -19,6 +21,9 @@ const initialState = {
filters: [], filters: [],
}; };
function TableWrapper() { function TableWrapper() {
const toast = useToast();
const queryClient = useQueryClient();
const [{ offset, limit, sortBy, filters }, dispatch] = useReducer( const [{ offset, limit, sortBy, filters }, dispatch] = useReducer(
tableEventReducer, tableEventReducer,
initialState, initialState,
@ -36,6 +41,32 @@ function TableWrapper() {
setTableData(data as any); setTableData(data as any);
}, [data]); }, [data]);
const renewCert = async (id: number) => {
try {
await renewCertificate(id);
toast({
description: intl.formatMessage({
id: `certificate.renewal-requested`,
}),
status: "info",
position: "top",
duration: 3000,
isClosable: true,
});
setTimeout(() => {
queryClient.invalidateQueries("certificates");
}, 500);
} catch (err: any) {
toast({
description: err.message,
status: "error",
position: "top",
duration: 3000,
isClosable: true,
});
}
};
if (isFetching || isLoading || !tableData) { if (isFetching || isLoading || !tableData) {
return <SpinnerPage />; return <SpinnerPage />;
} }
@ -76,6 +107,7 @@ function TableWrapper() {
sortBy={sortBy} sortBy={sortBy}
filters={filters} filters={filters}
onTableEvent={dispatch} onTableEvent={dispatch}
onRenewal={renewCert}
/> />
); );
} }

View File

@ -25,4 +25,4 @@
"include": [ "include": [
"src" "src"
] ]
} }