mirror of
https://github.com/NginxProxyManager/nginx-proxy-manager.git
synced 2025-08-27 19:20:04 +00:00
Certificate Authority work
This commit is contained in:
@@ -7,7 +7,11 @@
|
||||
"created_on",
|
||||
"modified_on",
|
||||
"name",
|
||||
"acme2_url"
|
||||
"acmesh_server",
|
||||
"ca_bundle",
|
||||
"max_domains",
|
||||
"is_wildcard_supported",
|
||||
"is_setup"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -27,10 +31,25 @@
|
||||
"minLength": 1,
|
||||
"maxLength": 100
|
||||
},
|
||||
"acme2_url": {
|
||||
"acmesh_server": {
|
||||
"type": "string",
|
||||
"minLength": 8,
|
||||
"minLength": 2,
|
||||
"maxLength": 255
|
||||
},
|
||||
"ca_bundle": {
|
||||
"type": "string",
|
||||
"minLength": 0,
|
||||
"maxLength": 255
|
||||
},
|
||||
"max_domains": {
|
||||
"type": "integer",
|
||||
"minimum": 1
|
||||
},
|
||||
"is_wildcard_supported": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_setup": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
@@ -49,6 +49,9 @@
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"certificate_authority": {
|
||||
"$ref": "#/components/schemas/CertificateAuthorityObject"
|
||||
},
|
||||
"dns_provider_id": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
|
@@ -37,10 +37,14 @@
|
||||
"value": {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"created_on": 1602588511,
|
||||
"modified_on": 1602588511,
|
||||
"name": "Let's Encrypt",
|
||||
"acme2_url": "https://acme-v02.api.letsencrypt.org/directory"
|
||||
"created_on": 1627531400,
|
||||
"modified_on": 1627531400,
|
||||
"name": "ZeroSSL",
|
||||
"acmesh_server": "zerossl",
|
||||
"ca_bundle": "",
|
||||
"max_domains": 10,
|
||||
"is_wildcard_supported": true,
|
||||
"is_setup": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -46,10 +46,14 @@
|
||||
"value": {
|
||||
"result": {
|
||||
"id": 1,
|
||||
"created_on": 1602588511,
|
||||
"modified_on": 1602588511,
|
||||
"name": "Let's Encrypt",
|
||||
"acme2_url": "https://acme-v02.api.letsencrypt.org/directory"
|
||||
"created_on": 1627531400,
|
||||
"modified_on": 1627531400,
|
||||
"name": "ZeroSSL",
|
||||
"acmesh_server": "zerossl",
|
||||
"ca_bundle": "",
|
||||
"max_domains": 10,
|
||||
"is_wildcard_supported": true,
|
||||
"is_setup": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -64,17 +64,25 @@
|
||||
"items": [
|
||||
{
|
||||
"id": 1,
|
||||
"created_on": 1602588511,
|
||||
"modified_on": 1602588511,
|
||||
"name": "Let's Encrypt",
|
||||
"acme2_url": "https://acme-v02.api.letsencrypt.org/directory"
|
||||
"created_on": 1627531400,
|
||||
"modified_on": 1627531400,
|
||||
"name": "ZeroSSL",
|
||||
"acmesh_server": "zerossl",
|
||||
"ca_bundle": "",
|
||||
"max_domains": 10,
|
||||
"is_wildcard_supported": true,
|
||||
"is_setup": false
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"created_on": 1602588511,
|
||||
"modified_on": 1602588511,
|
||||
"name": "Let's Encrypt (Staging)",
|
||||
"acme2_url": "https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
"created_on": 1627531400,
|
||||
"modified_on": 1627531400,
|
||||
"name": "Let's Encrypt",
|
||||
"acmesh_server": "https://acme-v02.api.letsencrypt.org/directory",
|
||||
"ca_bundle": "",
|
||||
"max_domains": 10,
|
||||
"is_wildcard_supported": true,
|
||||
"is_setup": false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@@ -32,11 +32,15 @@
|
||||
"default": {
|
||||
"value": {
|
||||
"result": {
|
||||
"id": 3,
|
||||
"created_on": 1602588900,
|
||||
"modified_on": 1602588900,
|
||||
"name": "Boulder",
|
||||
"acme2_url": "https://boulder.local/directory"
|
||||
"id": 1,
|
||||
"created_on": 1627531400,
|
||||
"modified_on": 1627531400,
|
||||
"name": "ZeroSSL",
|
||||
"acmesh_server": "zerossl",
|
||||
"ca_bundle": "",
|
||||
"max_domains": 10,
|
||||
"is_wildcard_supported": true,
|
||||
"is_setup": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -56,7 +56,11 @@ CREATE TABLE IF NOT EXISTS `certificate_authority`
|
||||
created_on INTEGER NOT NULL DEFAULT 0,
|
||||
modified_on INTEGER NOT NULL DEFAULT 0,
|
||||
name TEXT NOT NULL,
|
||||
acme2_url TEXT NOT NULL,
|
||||
acmesh_server TEXT NOT NULL DEFAULT "",
|
||||
is_setup INTEGER NOT NULL DEFAULT 0,
|
||||
ca_bundle TEXT NOT NULL DEFAULT "",
|
||||
is_wildcard_supported INTEGER NOT NULL DEFAULT 0, -- specific to each CA, acme v1 doesn't usually have wildcards
|
||||
max_domains INTEGER NOT NULL DEFAULT 5, -- per request
|
||||
is_deleted INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
|
@@ -36,20 +36,51 @@ INSERT INTO `certificate_authority` (
|
||||
created_on,
|
||||
modified_on,
|
||||
name,
|
||||
acme2_url
|
||||
acmesh_server,
|
||||
is_wildcard_supported,
|
||||
max_domains
|
||||
) VALUES (
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now'),
|
||||
"Let's Encrypt",
|
||||
"https://acme-v02.api.letsencrypt.org/directory"
|
||||
"ZeroSSL",
|
||||
"zerossl",
|
||||
1,
|
||||
10
|
||||
), (
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now'),
|
||||
"Let's Encrypt (Staging)",
|
||||
"https://acme-staging-v02.api.letsencrypt.org/directory"
|
||||
"Let's Encrypt",
|
||||
"https://acme-v02.api.letsencrypt.org/directory",
|
||||
1,
|
||||
10
|
||||
), (
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now'),
|
||||
"Buypass Go SSL",
|
||||
"https://api.buypass.com/acme/directory",
|
||||
0,
|
||||
5
|
||||
), (
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now'),
|
||||
"Let's Encrypt (Testing)",
|
||||
"https://acme-staging-v02.api.letsencrypt.org/directory",
|
||||
1,
|
||||
10
|
||||
), (
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now'),
|
||||
"Buypass Go SSL (Testing)",
|
||||
"https://api.test4.buypass.no/acme/directory",
|
||||
0,
|
||||
5
|
||||
), (
|
||||
strftime('%s', 'now'),
|
||||
strftime('%s', 'now'),
|
||||
"SSL.com",
|
||||
"ssl.com",
|
||||
0,
|
||||
10
|
||||
);
|
||||
|
||||
|
||||
-- migrate:down
|
||||
|
||||
-- Not allowed to go down from initial
|
||||
|
@@ -17,7 +17,7 @@ var acmeShFile string
|
||||
|
||||
// GetAcmeShVersion will return the acme.sh script version
|
||||
func GetAcmeShVersion() string {
|
||||
if r, err := acmeShExec("--version"); err == nil {
|
||||
if r, err := shExec("--version"); err == nil {
|
||||
// modify the output
|
||||
r = strings.Trim(r, "\n")
|
||||
v := strings.Split(r, "\n")
|
||||
@@ -26,13 +26,15 @@ func GetAcmeShVersion() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func acmeShExec(args ...string) (string, error) {
|
||||
// shExec executes the acme.sh with arguments
|
||||
func shExec(args ...string) (string, error) {
|
||||
if _, err := os.Stat(acmeShFile); os.IsNotExist(err) {
|
||||
e := fmt.Errorf("%s does not exist", acmeShFile)
|
||||
logger.Error("AcmeShError", e)
|
||||
return "", e
|
||||
}
|
||||
|
||||
logger.Debug("CMD: %s %v", acmeShFile, args)
|
||||
// nolint: gosec
|
||||
c := exec.Command(acmeShFile, args...)
|
||||
b, e := c.Output()
|
||||
@@ -61,3 +63,33 @@ func WriteAcmeSh() {
|
||||
logger.Info("Wrote %s", acmeShFile)
|
||||
}
|
||||
}
|
||||
|
||||
// RequestCert does all the heavy lifting
|
||||
func RequestCert(domains []string, method string) error {
|
||||
args := []string{"--issue"}
|
||||
|
||||
webroot := "/home/wwwroot/example.com"
|
||||
|
||||
// Add domains to args
|
||||
for _, domain := range domains {
|
||||
args = append(args, "-d", domain)
|
||||
}
|
||||
|
||||
switch method {
|
||||
// case "dns":
|
||||
case "http":
|
||||
args = append(args, "-w", webroot)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("RequestCert method not supported: %s", method)
|
||||
}
|
||||
|
||||
ret, err := shExec(args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debug("ret: %+v", ret)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@@ -10,12 +10,14 @@ func CreateCertificateAuthority() string {
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"name",
|
||||
"acme2_url"
|
||||
"acmesh_server",
|
||||
"max_domains"
|
||||
],
|
||||
"properties": {
|
||||
"name": %s,
|
||||
"acme2_url": %s
|
||||
"acmesh_server": %s,
|
||||
"max_domains": %s
|
||||
}
|
||||
}
|
||||
`, stringMinMax(1, 100), stringMinMax(8, 255))
|
||||
`, stringMinMax(1, 100), stringMinMax(2, 255), intMinOne)
|
||||
}
|
||||
|
@@ -10,8 +10,9 @@ func UpdateCertificateAuthority() string {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": %s,
|
||||
"acme2_url": %s
|
||||
"acmesh_server": %s,
|
||||
"max_domains": %s
|
||||
}
|
||||
}
|
||||
`, stringMinMax(1, 100), stringMinMax(8, 255))
|
||||
`, stringMinMax(1, 100), stringMinMax(2, 255), intMinOne)
|
||||
}
|
||||
|
@@ -152,13 +152,11 @@ func GetByStatus(status string) ([]Model, error) {
|
||||
SELECT
|
||||
t.*
|
||||
FROM "%s" t
|
||||
INNER JOIN "dns_provider" d ON d."id" = t."dns_provider_id"
|
||||
INNER JOIN "certificate_authority" c ON c."id" = t."certificate_authority_id"
|
||||
WHERE
|
||||
t."type" IN ("http", "dns") AND
|
||||
t."status" = ? AND
|
||||
t."certificate_authority_id" > 0 AND
|
||||
t."dns_provider_id" > 0 AND
|
||||
t."is_deleted" = 0
|
||||
`, tableName)
|
||||
|
||||
|
@@ -2,11 +2,14 @@ package certificate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"npm/internal/acme"
|
||||
"npm/internal/database"
|
||||
"npm/internal/entity/certificateauthority"
|
||||
"npm/internal/entity/dnsprovider"
|
||||
"npm/internal/logger"
|
||||
"npm/internal/types"
|
||||
)
|
||||
|
||||
@@ -86,6 +89,10 @@ func (m *Model) Save() error {
|
||||
return fmt.Errorf("Certificate data is incorrect or incomplete for this type")
|
||||
}
|
||||
|
||||
if !m.ValidateWildcardSupport() {
|
||||
return fmt.Errorf("Cannot use Wildcard domains with this CA")
|
||||
}
|
||||
|
||||
m.setDefaultStatus()
|
||||
|
||||
if m.ID == 0 {
|
||||
@@ -129,6 +136,32 @@ func (m *Model) Validate() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateWildcardSupport will ensure that the CA given supports wildcards,
|
||||
// only if the domains on this object have at least 1 wildcard
|
||||
func (m *Model) ValidateWildcardSupport() bool {
|
||||
domains, err := m.DomainNames.AsStringArray()
|
||||
if err != nil {
|
||||
logger.Error("ValidateWildcardSupportError", err)
|
||||
return false
|
||||
}
|
||||
|
||||
hasWildcard := false
|
||||
for _, domain := range domains {
|
||||
if strings.Contains(domain, "*") {
|
||||
hasWildcard = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasWildcard {
|
||||
m.Expand()
|
||||
if !m.CertificateAuthority.IsWildcardSupported {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *Model) setDefaultStatus() {
|
||||
if m.ID == 0 {
|
||||
// It's a new certificate
|
||||
@@ -154,23 +187,33 @@ func (m *Model) Expand() {
|
||||
|
||||
// Request makes a certificate request
|
||||
func (m *Model) Request() error {
|
||||
logger.Info("Requesting certificate for: #%d %v", m.ID, m.Name)
|
||||
|
||||
m.Expand()
|
||||
m.Status = StatusRequesting
|
||||
if err := m.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If error
|
||||
// do request
|
||||
domains, err := m.DomainNames.AsStringArray()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = acme.RequestCert(domains, m.Type)
|
||||
if err != nil {
|
||||
m.Status = StatusFailed
|
||||
m.ErrorMessage = "something"
|
||||
m.ErrorMessage = err.Error()
|
||||
if err := m.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If done
|
||||
m.Status = StatusProvided
|
||||
t := time.Now()
|
||||
m.ExpiresOn.Time = &t
|
||||
m.ExpiresOn.Time = &t // todo
|
||||
if err := m.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@@ -33,13 +33,21 @@ func Create(ca *Model) (int, error) {
|
||||
created_on,
|
||||
modified_on,
|
||||
name,
|
||||
acme2_url,
|
||||
acmesh_server,
|
||||
ca_bundle,
|
||||
max_domains,
|
||||
is_wildcard_supported,
|
||||
is_setup,
|
||||
is_deleted
|
||||
) VALUES (
|
||||
:created_on,
|
||||
:modified_on,
|
||||
:name,
|
||||
:acme2_url,
|
||||
:acmesh_server,
|
||||
:ca_bundle,
|
||||
:max_domains,
|
||||
:is_wildcard_supported,
|
||||
:is_setup,
|
||||
:is_deleted
|
||||
)`, ca)
|
||||
|
||||
@@ -69,7 +77,11 @@ func Update(ca *Model) error {
|
||||
created_on = :created_on,
|
||||
modified_on = :modified_on,
|
||||
name = :name,
|
||||
acme2_url = :acme2_url,
|
||||
acmesh_server = :acmesh_server,
|
||||
ca_bundle = :ca_bundle,
|
||||
max_domains = :max_domains,
|
||||
is_wildcard_supported = :is_wildcard_supported,
|
||||
is_setup = :is_setup,
|
||||
is_deleted = :is_deleted
|
||||
WHERE id = :id`, ca)
|
||||
|
||||
|
@@ -18,7 +18,11 @@ type Model struct {
|
||||
CreatedOn types.DBDate `json:"created_on" db:"created_on" filter:"created_on,integer"`
|
||||
ModifiedOn types.DBDate `json:"modified_on" db:"modified_on" filter:"modified_on,integer"`
|
||||
Name string `json:"name" db:"name" filter:"name,string"`
|
||||
Acme2URL string `json:"acme2_url" db:"acme2_url" filter:"acme2_url,string"`
|
||||
AcmeshServer string `json:"acmesh_server" db:"acmesh_server" filter:"acmesh_server,string"`
|
||||
CABundle string `json:"ca_bundle" db:"ca_bundle" filter:"ca_bundle,string"`
|
||||
MaxDomains int `json:"max_domains" db:"max_domains" filter:"max_domains,integer"`
|
||||
IsWildcardSupported bool `json:"is_wildcard_supported" db:"is_wildcard_supported" filter:"is_wildcard_supported,boolean"`
|
||||
IsSetup bool `json:"is_setup" db:"is_setup" filter:"is_setup,boolean"`
|
||||
IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"`
|
||||
}
|
||||
|
||||
|
@@ -56,3 +56,16 @@ func (j *JSONB) UnmarshalJSON(data []byte) error {
|
||||
func (j JSONB) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(j.Decoded)
|
||||
}
|
||||
|
||||
// AsStringArray will attempt to return as []string
|
||||
func (j JSONB) AsStringArray() ([]string, error) {
|
||||
var strs []string
|
||||
|
||||
// Encode then Decode onto this type
|
||||
b, _ := j.MarshalJSON()
|
||||
if err := json.Unmarshal(b, &strs); err != nil {
|
||||
return strs, err
|
||||
}
|
||||
|
||||
return strs, nil
|
||||
}
|
||||
|
@@ -41,12 +41,14 @@ mainLoop:
|
||||
break mainLoop
|
||||
}
|
||||
case <-ticker.C:
|
||||
// Can confirm that this will wait for completion before the next loop
|
||||
requestCertificates()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requestCertificates() {
|
||||
// logger.Debug("requestCertificates fired")
|
||||
rows, err := certificate.GetByStatus(certificate.StatusReady)
|
||||
if err != nil {
|
||||
logger.Error("requestCertificatesError", err)
|
||||
|
@@ -1,3 +1,4 @@
|
||||
FROM nginxproxymanager/testca as testca
|
||||
FROM jc21/nginx-full:github-no-acme-golang
|
||||
LABEL maintainer="Jamie Curnow <jc@jc21.com>"
|
||||
|
||||
@@ -38,6 +39,8 @@ RUN curl -L -o /tmp/s6-overlay-amd64.tar.gz "https://github.com/just-containers/
|
||||
# Fix for golang dev:
|
||||
RUN chown -R 1000:1000 /opt/go
|
||||
|
||||
COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt
|
||||
|
||||
EXPOSE 80
|
||||
CMD [ "/init" ]
|
||||
HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:81/api || exit 1
|
||||
|
@@ -24,8 +24,15 @@ services:
|
||||
- ../data:/data
|
||||
working_dir: /app
|
||||
|
||||
stepca:
|
||||
image: nginxproxymanager/testca
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- ca.internal
|
||||
|
||||
swagger:
|
||||
image: 'swaggerapi/swagger-ui:latest'
|
||||
image: swaggerapi/swagger-ui:latest
|
||||
ports:
|
||||
- 3001:80
|
||||
environment:
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import * as api from "./base";
|
||||
import { UserResponse } from "./responseTypes";
|
||||
import { User } from "./models";
|
||||
|
||||
interface AuthOptions {
|
||||
type: string;
|
||||
@@ -20,7 +20,7 @@ interface Options {
|
||||
export async function createUser(
|
||||
{ payload }: Options,
|
||||
abortController?: AbortController,
|
||||
): Promise<UserResponse> {
|
||||
): Promise<User> {
|
||||
const { result } = await api.post(
|
||||
{
|
||||
url: "/users",
|
||||
|
@@ -1,9 +1,7 @@
|
||||
import * as api from "./base";
|
||||
import { UserResponse } from "./responseTypes";
|
||||
import { User } from "./models";
|
||||
|
||||
export async function getUser(
|
||||
id: number | string = "me",
|
||||
): Promise<UserResponse> {
|
||||
export async function getUser(id: number | string = "me"): Promise<User> {
|
||||
const userId = id ? id : "me";
|
||||
const { result } = await api.get({
|
||||
url: `/users/${userId}`,
|
||||
|
@@ -3,6 +3,9 @@ export * from "./getToken";
|
||||
export * from "./getUser";
|
||||
export * from "./models";
|
||||
export * from "./refreshToken";
|
||||
export * from "./requestCertificateAuthorities";
|
||||
export * from "./requestCertificates";
|
||||
export * from "./requestHealth";
|
||||
export * from "./requestSettings";
|
||||
export * from "./requestUsers";
|
||||
export * from "./responseTypes";
|
||||
|
@@ -3,6 +3,27 @@ export interface Sort {
|
||||
direction: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
export interface UserAuth {
|
||||
id: number;
|
||||
userId: number;
|
||||
type: string;
|
||||
createdOn: number;
|
||||
updatedOn: number;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
createdOn: number;
|
||||
updatedOn: number;
|
||||
roles: string[];
|
||||
gravatarUrl: string;
|
||||
isDisabled: boolean;
|
||||
auth?: UserAuth;
|
||||
}
|
||||
|
||||
export interface Setting {
|
||||
id: number;
|
||||
createdOn: number;
|
||||
@@ -10,3 +31,27 @@ export interface Setting {
|
||||
name: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface Certificate {
|
||||
id: number;
|
||||
createdOn: number;
|
||||
modifiedOn: number;
|
||||
name: string;
|
||||
acmeshServer: string;
|
||||
caBundle: string;
|
||||
maxDomains: number;
|
||||
isWildcardSupported: boolean;
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
||||
export interface CertificateAuthority {
|
||||
id: number;
|
||||
createdOn: number;
|
||||
modifiedOn: number;
|
||||
name: string;
|
||||
acmeshServer: string;
|
||||
caBundle: string;
|
||||
maxDomains: number;
|
||||
isWildcardSupported: boolean;
|
||||
isSetup: boolean;
|
||||
}
|
||||
|
16
frontend/src/api/npm/requestCertificateAuthorities.ts
Normal file
16
frontend/src/api/npm/requestCertificateAuthorities.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as api from "./base";
|
||||
import { CertificateAuthoritiesResponse } from "./responseTypes";
|
||||
|
||||
export async function requestCertificateAuthorities(
|
||||
offset?: number,
|
||||
abortController?: AbortController,
|
||||
): Promise<CertificateAuthoritiesResponse> {
|
||||
const { result } = await api.get(
|
||||
{
|
||||
url: "certificate-authorities",
|
||||
params: { limit: 20, offset: offset || 0 },
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
return result;
|
||||
}
|
16
frontend/src/api/npm/requestCertificates.ts
Normal file
16
frontend/src/api/npm/requestCertificates.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as api from "./base";
|
||||
import { CertificatesResponse } from "./responseTypes";
|
||||
|
||||
export async function requestCertificates(
|
||||
offset?: number,
|
||||
abortController?: AbortController,
|
||||
): Promise<CertificatesResponse> {
|
||||
const { result } = await api.get(
|
||||
{
|
||||
url: "certificates",
|
||||
params: { limit: 20, offset: offset || 0 },
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
return result;
|
||||
}
|
16
frontend/src/api/npm/requestUsers.ts
Normal file
16
frontend/src/api/npm/requestUsers.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as api from "./base";
|
||||
import { UsersResponse } from "./responseTypes";
|
||||
|
||||
export async function requestUsers(
|
||||
offset?: number,
|
||||
abortController?: AbortController,
|
||||
): Promise<UsersResponse> {
|
||||
const { result } = await api.get(
|
||||
{
|
||||
url: "users",
|
||||
params: { limit: 20, offset: offset || 0 },
|
||||
},
|
||||
abortController,
|
||||
);
|
||||
return result;
|
||||
}
|
@@ -1,4 +1,10 @@
|
||||
import { Sort, Setting } from "./models";
|
||||
import {
|
||||
Certificate,
|
||||
CertificateAuthority,
|
||||
Setting,
|
||||
Sort,
|
||||
User,
|
||||
} from "./models";
|
||||
|
||||
export interface HealthResponse {
|
||||
commit: string;
|
||||
@@ -8,32 +14,11 @@ export interface HealthResponse {
|
||||
version: string;
|
||||
}
|
||||
|
||||
export interface UserAuthResponse {
|
||||
id: number;
|
||||
userId: number;
|
||||
type: string;
|
||||
createdOn: number;
|
||||
updatedOn: number;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
expires: number;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: number;
|
||||
name: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
createdOn: number;
|
||||
updatedOn: number;
|
||||
roles: string[];
|
||||
gravatarUrl: string;
|
||||
isDisabled: boolean;
|
||||
auth?: UserAuthResponse;
|
||||
}
|
||||
|
||||
export interface SettingsResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
@@ -41,3 +26,27 @@ export interface SettingsResponse {
|
||||
sort: Sort[];
|
||||
items: Setting[];
|
||||
}
|
||||
|
||||
export interface CertificatesResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
sort: Sort[];
|
||||
items: Certificate[];
|
||||
}
|
||||
|
||||
export interface CertificateAuthoritiesResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
sort: Sort[];
|
||||
items: CertificateAuthority[];
|
||||
}
|
||||
|
||||
export interface UsersResponse {
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
sort: Sort[];
|
||||
items: User[];
|
||||
}
|
||||
|
@@ -1,7 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import { Navigation } from "components";
|
||||
import { Dropdown, Navigation } from "components";
|
||||
import { intl } from "locale";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Book,
|
||||
DeviceDesktop,
|
||||
@@ -43,20 +44,30 @@ function NavMenu() {
|
||||
to: "/access-lists",
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
title: "SSL",
|
||||
icon: <Shield />,
|
||||
dropdownItems: [
|
||||
<Dropdown.Item key="ssl-certificates">
|
||||
<Link to="/ssl/certificates" role="button" aria-expanded="false">
|
||||
<span className="nav-link-title">
|
||||
{intl.formatMessage({
|
||||
id: "certificates.title",
|
||||
defaultMessage: "Certificates",
|
||||
}),
|
||||
icon: <Shield />,
|
||||
to: "/certificates",
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: "users.title",
|
||||
defaultMessage: "Users",
|
||||
}),
|
||||
icon: <Users />,
|
||||
to: "/users",
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
</Dropdown.Item>,
|
||||
<Dropdown.Item key="ssl-authorities">
|
||||
<Link to="/ssl/authorities" role="button" aria-expanded="false">
|
||||
<span className="nav-link-title">
|
||||
{intl.formatMessage({
|
||||
id: "cert_authorities.title",
|
||||
defaultMessage: "Certificate Authorities",
|
||||
})}
|
||||
</span>
|
||||
</Link>
|
||||
</Dropdown.Item>,
|
||||
],
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
@@ -66,6 +77,14 @@ function NavMenu() {
|
||||
icon: <Book />,
|
||||
to: "/audit-log",
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: "users.title",
|
||||
defaultMessage: "Users",
|
||||
}),
|
||||
icon: <Users />,
|
||||
to: "/users",
|
||||
},
|
||||
{
|
||||
title: intl.formatMessage({
|
||||
id: "settings.title",
|
||||
|
@@ -12,6 +12,9 @@ import { BrowserRouter, Switch, Route } from "react-router-dom";
|
||||
const AccessLists = lazy(() => import("pages/AccessLists"));
|
||||
const AuditLog = lazy(() => import("pages/AuditLog"));
|
||||
const Certificates = lazy(() => import("pages/Certificates"));
|
||||
const CertificateAuthorities = lazy(
|
||||
() => import("pages/CertificateAuthorities"),
|
||||
);
|
||||
const Dashboard = lazy(() => import("pages/Dashboard"));
|
||||
const Hosts = lazy(() => import("pages/Hosts"));
|
||||
const Login = lazy(() => import("pages/Login"));
|
||||
@@ -54,9 +57,12 @@ function Router() {
|
||||
<Route path="/hosts">
|
||||
<Hosts />
|
||||
</Route>
|
||||
<Route path="/certificates">
|
||||
<Route path="/ssl/certificates">
|
||||
<Certificates />
|
||||
</Route>
|
||||
<Route path="/ssl/authorities">
|
||||
<CertificateAuthorities />
|
||||
</Route>
|
||||
<Route path="/audit-log">
|
||||
<AuditLog />
|
||||
</Route>
|
||||
|
@@ -1,6 +1,8 @@
|
||||
import React from "react";
|
||||
|
||||
import cn from "classnames";
|
||||
import { Badge } from "components";
|
||||
import { intl } from "locale";
|
||||
|
||||
export interface TableColumn {
|
||||
/**
|
||||
@@ -52,9 +54,33 @@ export const Table = ({ columns, data, pagination, sortBy }: TableProps) => {
|
||||
switch (given) {
|
||||
// Simple ID column has text-muted
|
||||
case "id":
|
||||
return (val: any) => {
|
||||
return (val: number) => {
|
||||
return <span className="text-muted">{val}</span>;
|
||||
};
|
||||
case "setup":
|
||||
return (val: boolean) => {
|
||||
return (
|
||||
<Badge color={val ? "lime" : "red"}>
|
||||
{val
|
||||
? intl.formatMessage({
|
||||
id: "ready",
|
||||
defaultMessage: "Ready",
|
||||
})
|
||||
: intl.formatMessage({
|
||||
id: "required",
|
||||
defaultMessage: "Required",
|
||||
})}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
case "bool":
|
||||
return (val: boolean) => {
|
||||
return (
|
||||
<Badge color={val ? "lime" : "red"}>
|
||||
{val ? "true" : "false"}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -1,18 +1,18 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { getUser, UserResponse } from "api/npm";
|
||||
import { getUser, User } from "api/npm";
|
||||
import { useAuthState } from "context";
|
||||
|
||||
// Context
|
||||
const initalValue = null;
|
||||
const UserContext = React.createContext<UserResponse | null>(initalValue);
|
||||
const UserContext = React.createContext<User | null>(initalValue);
|
||||
|
||||
// Provider
|
||||
interface Props {
|
||||
children?: JSX.Element;
|
||||
}
|
||||
function UserProvider({ children }: Props) {
|
||||
const [userData, setUserData] = useState<UserResponse>({
|
||||
const [userData, setUserData] = useState<User>({
|
||||
id: 0,
|
||||
name: "",
|
||||
nickname: "",
|
||||
|
@@ -5,6 +5,9 @@
|
||||
"auditlog.title": {
|
||||
"defaultMessage": "Audit Log"
|
||||
},
|
||||
"cert_authorities.title": {
|
||||
"defaultMessage": "Certificate Authorities"
|
||||
},
|
||||
"certificates.title": {
|
||||
"defaultMessage": "Certificates"
|
||||
},
|
||||
@@ -14,12 +17,21 @@
|
||||
"column.id": {
|
||||
"defaultMessage": "ID"
|
||||
},
|
||||
"column.max_domains": {
|
||||
"defaultMessage": "Domains per Cert"
|
||||
},
|
||||
"column.name": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
"column.status": {
|
||||
"defaultMessage": "Status"
|
||||
},
|
||||
"dashboard.title": {
|
||||
"defaultMessage": "Dashboard"
|
||||
},
|
||||
"column.wildcard_support": {
|
||||
"defaultMessage": "Wildcard Support"
|
||||
},
|
||||
"footer.changelog": {
|
||||
"defaultMessage": "Change Log"
|
||||
},
|
||||
@@ -47,6 +59,12 @@
|
||||
"profile.title": {
|
||||
"defaultMessage": "Profile settings"
|
||||
},
|
||||
"ready": {
|
||||
"defaultMessage": "Ready"
|
||||
},
|
||||
"required": {
|
||||
"defaultMessage": "Required"
|
||||
},
|
||||
"settings.title": {
|
||||
"defaultMessage": "Settings"
|
||||
},
|
||||
|
115
frontend/src/pages/CertificateAuthorities/index.tsx
Normal file
115
frontend/src/pages/CertificateAuthorities/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
|
||||
import {
|
||||
CertificateAuthoritiesResponse,
|
||||
requestCertificateAuthorities,
|
||||
} from "api/npm";
|
||||
import { Table } from "components";
|
||||
import { SuspenseLoader } from "components";
|
||||
import { intl } from "locale";
|
||||
import { useInterval } from "rooks";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Root = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
function CertificateAuthorities() {
|
||||
const [data, setData] = useState({} as CertificateAuthoritiesResponse);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const asyncFetch = useCallback(() => {
|
||||
requestCertificateAuthorities(offset)
|
||||
.then(setData)
|
||||
.catch((error: any) => {
|
||||
console.error("fetch data failed", error);
|
||||
});
|
||||
}, [offset]);
|
||||
|
||||
useEffect(() => {
|
||||
asyncFetch();
|
||||
}, [asyncFetch]);
|
||||
|
||||
// 1 Minute
|
||||
useInterval(asyncFetch, 1 * 60 * 1000, true);
|
||||
|
||||
const cols = [
|
||||
/*
|
||||
{
|
||||
name: "id",
|
||||
title: intl.formatMessage({ id: "column.id", defaultMessage: "ID" }),
|
||||
formatter: "id",
|
||||
className: "w-1",
|
||||
},
|
||||
*/
|
||||
{
|
||||
name: "name",
|
||||
title: intl.formatMessage({ id: "column.name", defaultMessage: "Name" }),
|
||||
},
|
||||
{
|
||||
name: "maxDomains",
|
||||
title: intl.formatMessage({
|
||||
id: "column.max_domains",
|
||||
defaultMessage: "Max Domains",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "isWildcardSupported",
|
||||
title: intl.formatMessage({
|
||||
id: "column.wildcard_support",
|
||||
defaultMessage: "Wildcard Support",
|
||||
}),
|
||||
formatter: "bool",
|
||||
},
|
||||
{
|
||||
name: "isSetup",
|
||||
title: intl.formatMessage({
|
||||
id: "column.status",
|
||||
defaultMessage: "Status",
|
||||
}),
|
||||
formatter: "setup",
|
||||
},
|
||||
];
|
||||
|
||||
if (typeof data.total !== "undefined" && data.total) {
|
||||
return (
|
||||
<Root>
|
||||
<div className="card">
|
||||
<div className="card-status-top bg-orange" />
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
{intl.formatMessage({
|
||||
id: "cert_authorities.title",
|
||||
defaultMessage: "Certificate Authorities",
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<Table
|
||||
columns={cols}
|
||||
data={data.items}
|
||||
sortBy={data.sort[0].field}
|
||||
pagination={{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
total: data.total,
|
||||
onSetOffset: (num: number) => {
|
||||
if (offset !== num) {
|
||||
setOffset(num);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.total !== "undefined") {
|
||||
return <p>No items!</p>;
|
||||
}
|
||||
|
||||
return <SuspenseLoader />;
|
||||
}
|
||||
|
||||
export default CertificateAuthorities;
|
@@ -1,6 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
|
||||
import { CertificatesResponse, requestCertificates } from "api/npm";
|
||||
import { Table } from "components";
|
||||
import { SuspenseLoader } from "components";
|
||||
import { intl } from "locale";
|
||||
import { useInterval } from "rooks";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Root = styled.div`
|
||||
@@ -8,11 +12,43 @@ const Root = styled.div`
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
function Certificates() {
|
||||
function CertificateAuthorities() {
|
||||
const [data, setData] = useState({} as CertificatesResponse);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const asyncFetch = useCallback(() => {
|
||||
requestCertificates(offset)
|
||||
.then(setData)
|
||||
.catch((error: any) => {
|
||||
console.error("fetch data failed", error);
|
||||
});
|
||||
}, [offset]);
|
||||
|
||||
useEffect(() => {
|
||||
asyncFetch();
|
||||
}, [asyncFetch]);
|
||||
|
||||
// 1 Minute
|
||||
useInterval(asyncFetch, 1 * 60 * 1000, true);
|
||||
|
||||
const cols = [
|
||||
{
|
||||
name: "id",
|
||||
title: intl.formatMessage({ id: "column.id", defaultMessage: "ID" }),
|
||||
formatter: "id",
|
||||
className: "w-1",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
title: intl.formatMessage({ id: "column.name", defaultMessage: "Name" }),
|
||||
},
|
||||
];
|
||||
|
||||
if (typeof data.total !== "undefined" && data.total) {
|
||||
return (
|
||||
<Root>
|
||||
<div className="card">
|
||||
<div className="card-status-top bg-cyan" />
|
||||
<div className="card-status-top bg-yellow" />
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
{intl.formatMessage({
|
||||
@@ -21,9 +57,31 @@ function Certificates() {
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<Table
|
||||
columns={cols}
|
||||
data={data.items}
|
||||
sortBy={data.sort[0].field}
|
||||
pagination={{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
total: data.total,
|
||||
onSetOffset: (num: number) => {
|
||||
if (offset !== num) {
|
||||
setOffset(num);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.total !== "undefined") {
|
||||
return <p>No items!</p>;
|
||||
}
|
||||
|
||||
return <SuspenseLoader />;
|
||||
}
|
||||
|
||||
export default Certificates;
|
||||
export default CertificateAuthorities;
|
||||
|
@@ -1,6 +1,10 @@
|
||||
import React from "react";
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
|
||||
import { UsersResponse, requestUsers } from "api/npm";
|
||||
import { Table } from "components";
|
||||
import { SuspenseLoader } from "components";
|
||||
import { intl } from "locale";
|
||||
import { useInterval } from "rooks";
|
||||
import styled from "styled-components";
|
||||
|
||||
const Root = styled.div`
|
||||
@@ -9,10 +13,46 @@ const Root = styled.div`
|
||||
`;
|
||||
|
||||
function Users() {
|
||||
const [data, setData] = useState({} as UsersResponse);
|
||||
const [offset, setOffset] = useState(0);
|
||||
|
||||
const asyncFetch = useCallback(() => {
|
||||
requestUsers(offset)
|
||||
.then(setData)
|
||||
.catch((error: any) => {
|
||||
console.error("fetch data failed", error);
|
||||
});
|
||||
}, [offset]);
|
||||
|
||||
useEffect(() => {
|
||||
asyncFetch();
|
||||
}, [asyncFetch]);
|
||||
|
||||
// 1 Minute
|
||||
useInterval(asyncFetch, 1 * 60 * 1000, true);
|
||||
|
||||
const cols = [
|
||||
{
|
||||
name: "id",
|
||||
title: intl.formatMessage({ id: "column.id", defaultMessage: "ID" }),
|
||||
formatter: "id",
|
||||
className: "w-1",
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
title: intl.formatMessage({ id: "users.name", defaultMessage: "Name" }),
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
title: intl.formatMessage({ id: "users.email", defaultMessage: "Email" }),
|
||||
},
|
||||
];
|
||||
|
||||
if (typeof data.total !== "undefined" && data.total) {
|
||||
return (
|
||||
<Root>
|
||||
<div className="card">
|
||||
<div className="card-status-top bg-cyan" />
|
||||
<div className="card-status-top bg-indigo" />
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
{intl.formatMessage({
|
||||
@@ -21,9 +61,31 @@ function Users() {
|
||||
})}
|
||||
</h3>
|
||||
</div>
|
||||
<Table
|
||||
columns={cols}
|
||||
data={data.items}
|
||||
sortBy={data.sort[0].field}
|
||||
pagination={{
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
total: data.total,
|
||||
onSetOffset: (num: number) => {
|
||||
if (offset !== num) {
|
||||
setOffset(num);
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Root>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof data.total !== "undefined") {
|
||||
return <p>No items!</p>;
|
||||
}
|
||||
|
||||
return <SuspenseLoader />;
|
||||
}
|
||||
|
||||
export default Users;
|
||||
|
Reference in New Issue
Block a user