Certificate Authority work

This commit is contained in:
Jamie Curnow
2021-07-29 17:45:14 +10:00
parent ae00ab09e4
commit 339ee13346
35 changed files with 737 additions and 136 deletions

View File

@@ -7,7 +7,11 @@
"created_on", "created_on",
"modified_on", "modified_on",
"name", "name",
"acme2_url" "acmesh_server",
"ca_bundle",
"max_domains",
"is_wildcard_supported",
"is_setup"
], ],
"properties": { "properties": {
"id": { "id": {
@@ -27,10 +31,25 @@
"minLength": 1, "minLength": 1,
"maxLength": 100 "maxLength": 100
}, },
"acme2_url": { "acmesh_server": {
"type": "string", "type": "string",
"minLength": 8, "minLength": 2,
"maxLength": 255 "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"
} }
} }
} }

View File

@@ -49,6 +49,9 @@
"type": "integer", "type": "integer",
"minimum": 0 "minimum": 0
}, },
"certificate_authority": {
"$ref": "#/components/schemas/CertificateAuthorityObject"
},
"dns_provider_id": { "dns_provider_id": {
"type": "integer", "type": "integer",
"minimum": 0 "minimum": 0

View File

@@ -37,10 +37,14 @@
"value": { "value": {
"result": { "result": {
"id": 1, "id": 1,
"created_on": 1602588511, "created_on": 1627531400,
"modified_on": 1602588511, "modified_on": 1627531400,
"name": "Let's Encrypt", "name": "ZeroSSL",
"acme2_url": "https://acme-v02.api.letsencrypt.org/directory" "acmesh_server": "zerossl",
"ca_bundle": "",
"max_domains": 10,
"is_wildcard_supported": true,
"is_setup": false
} }
} }
} }

View File

@@ -46,10 +46,14 @@
"value": { "value": {
"result": { "result": {
"id": 1, "id": 1,
"created_on": 1602588511, "created_on": 1627531400,
"modified_on": 1602588511, "modified_on": 1627531400,
"name": "Let's Encrypt", "name": "ZeroSSL",
"acme2_url": "https://acme-v02.api.letsencrypt.org/directory" "acmesh_server": "zerossl",
"ca_bundle": "",
"max_domains": 10,
"is_wildcard_supported": true,
"is_setup": false
} }
} }
} }

View File

@@ -64,17 +64,25 @@
"items": [ "items": [
{ {
"id": 1, "id": 1,
"created_on": 1602588511, "created_on": 1627531400,
"modified_on": 1602588511, "modified_on": 1627531400,
"name": "Let's Encrypt", "name": "ZeroSSL",
"acme2_url": "https://acme-v02.api.letsencrypt.org/directory" "acmesh_server": "zerossl",
"ca_bundle": "",
"max_domains": 10,
"is_wildcard_supported": true,
"is_setup": false
}, },
{ {
"id": 2, "id": 2,
"created_on": 1602588511, "created_on": 1627531400,
"modified_on": 1602588511, "modified_on": 1627531400,
"name": "Let's Encrypt (Staging)", "name": "Let's Encrypt",
"acme2_url": "https://acme-staging-v02.api.letsencrypt.org/directory" "acmesh_server": "https://acme-v02.api.letsencrypt.org/directory",
"ca_bundle": "",
"max_domains": 10,
"is_wildcard_supported": true,
"is_setup": false
} }
] ]
} }

View File

@@ -32,11 +32,15 @@
"default": { "default": {
"value": { "value": {
"result": { "result": {
"id": 3, "id": 1,
"created_on": 1602588900, "created_on": 1627531400,
"modified_on": 1602588900, "modified_on": 1627531400,
"name": "Boulder", "name": "ZeroSSL",
"acme2_url": "https://boulder.local/directory" "acmesh_server": "zerossl",
"ca_bundle": "",
"max_domains": 10,
"is_wildcard_supported": true,
"is_setup": false
} }
} }
} }

View File

@@ -56,7 +56,11 @@ CREATE TABLE IF NOT EXISTS `certificate_authority`
created_on INTEGER NOT NULL DEFAULT 0, created_on INTEGER NOT NULL DEFAULT 0,
modified_on INTEGER NOT NULL DEFAULT 0, modified_on INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL, 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 is_deleted INTEGER NOT NULL DEFAULT 0
); );

View File

@@ -36,20 +36,51 @@ INSERT INTO `certificate_authority` (
created_on, created_on,
modified_on, modified_on,
name, name,
acme2_url acmesh_server,
is_wildcard_supported,
max_domains
) VALUES ( ) VALUES (
strftime('%s', 'now'), strftime('%s', 'now'),
strftime('%s', 'now'), strftime('%s', 'now'),
"Let's Encrypt", "ZeroSSL",
"https://acme-v02.api.letsencrypt.org/directory" "zerossl",
1,
10
), ( ), (
strftime('%s', 'now'), strftime('%s', 'now'),
strftime('%s', 'now'), strftime('%s', 'now'),
"Let's Encrypt (Staging)", "Let's Encrypt",
"https://acme-staging-v02.api.letsencrypt.org/directory" "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 -- migrate:down
-- Not allowed to go down from initial

View File

@@ -17,7 +17,7 @@ var acmeShFile string
// GetAcmeShVersion will return the acme.sh script version // GetAcmeShVersion will return the acme.sh script version
func GetAcmeShVersion() string { func GetAcmeShVersion() string {
if r, err := acmeShExec("--version"); err == nil { if r, err := shExec("--version"); err == nil {
// modify the output // modify the output
r = strings.Trim(r, "\n") r = strings.Trim(r, "\n")
v := strings.Split(r, "\n") v := strings.Split(r, "\n")
@@ -26,13 +26,15 @@ func GetAcmeShVersion() string {
return "" 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) { if _, err := os.Stat(acmeShFile); os.IsNotExist(err) {
e := fmt.Errorf("%s does not exist", acmeShFile) e := fmt.Errorf("%s does not exist", acmeShFile)
logger.Error("AcmeShError", e) logger.Error("AcmeShError", e)
return "", e return "", e
} }
logger.Debug("CMD: %s %v", acmeShFile, args)
// nolint: gosec // nolint: gosec
c := exec.Command(acmeShFile, args...) c := exec.Command(acmeShFile, args...)
b, e := c.Output() b, e := c.Output()
@@ -61,3 +63,33 @@ func WriteAcmeSh() {
logger.Info("Wrote %s", acmeShFile) 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
}

View File

@@ -10,12 +10,14 @@ func CreateCertificateAuthority() string {
"additionalProperties": false, "additionalProperties": false,
"required": [ "required": [
"name", "name",
"acme2_url" "acmesh_server",
"max_domains"
], ],
"properties": { "properties": {
"name": %s, "name": %s,
"acme2_url": %s "acmesh_server": %s,
"max_domains": %s
} }
} }
`, stringMinMax(1, 100), stringMinMax(8, 255)) `, stringMinMax(1, 100), stringMinMax(2, 255), intMinOne)
} }

View File

@@ -10,8 +10,9 @@ func UpdateCertificateAuthority() string {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"name": %s, "name": %s,
"acme2_url": %s "acmesh_server": %s,
"max_domains": %s
} }
} }
`, stringMinMax(1, 100), stringMinMax(8, 255)) `, stringMinMax(1, 100), stringMinMax(2, 255), intMinOne)
} }

View File

@@ -152,13 +152,11 @@ func GetByStatus(status string) ([]Model, error) {
SELECT SELECT
t.* t.*
FROM "%s" 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" INNER JOIN "certificate_authority" c ON c."id" = t."certificate_authority_id"
WHERE WHERE
t."type" IN ("http", "dns") AND t."type" IN ("http", "dns") AND
t."status" = ? AND t."status" = ? AND
t."certificate_authority_id" > 0 AND t."certificate_authority_id" > 0 AND
t."dns_provider_id" > 0 AND
t."is_deleted" = 0 t."is_deleted" = 0
`, tableName) `, tableName)

View File

@@ -2,11 +2,14 @@ package certificate
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
"npm/internal/acme"
"npm/internal/database" "npm/internal/database"
"npm/internal/entity/certificateauthority" "npm/internal/entity/certificateauthority"
"npm/internal/entity/dnsprovider" "npm/internal/entity/dnsprovider"
"npm/internal/logger"
"npm/internal/types" "npm/internal/types"
) )
@@ -86,6 +89,10 @@ func (m *Model) Save() error {
return fmt.Errorf("Certificate data is incorrect or incomplete for this type") 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() m.setDefaultStatus()
if m.ID == 0 { 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() { func (m *Model) setDefaultStatus() {
if m.ID == 0 { if m.ID == 0 {
// It's a new certificate // It's a new certificate
@@ -154,23 +187,33 @@ func (m *Model) Expand() {
// 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)
m.Expand() m.Expand()
m.Status = StatusRequesting m.Status = StatusRequesting
if err := m.Save(); err != nil { if err := m.Save(); err != nil {
return err 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.Status = StatusFailed
m.ErrorMessage = "something" m.ErrorMessage = err.Error()
if err := m.Save(); err != nil { if err := m.Save(); err != nil {
return err return err
} }
}
// If done // If done
m.Status = StatusProvided m.Status = StatusProvided
t := time.Now() t := time.Now()
m.ExpiresOn.Time = &t m.ExpiresOn.Time = &t // todo
if err := m.Save(); err != nil { if err := m.Save(); err != nil {
return err return err
} }

View File

@@ -33,13 +33,21 @@ func Create(ca *Model) (int, error) {
created_on, created_on,
modified_on, modified_on,
name, name,
acme2_url, acmesh_server,
ca_bundle,
max_domains,
is_wildcard_supported,
is_setup,
is_deleted is_deleted
) VALUES ( ) VALUES (
:created_on, :created_on,
:modified_on, :modified_on,
:name, :name,
:acme2_url, :acmesh_server,
:ca_bundle,
:max_domains,
:is_wildcard_supported,
:is_setup,
:is_deleted :is_deleted
)`, ca) )`, ca)
@@ -69,7 +77,11 @@ func Update(ca *Model) error {
created_on = :created_on, created_on = :created_on,
modified_on = :modified_on, modified_on = :modified_on,
name = :name, 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 is_deleted = :is_deleted
WHERE id = :id`, ca) WHERE id = :id`, ca)

View File

@@ -18,7 +18,11 @@ type Model struct {
CreatedOn types.DBDate `json:"created_on" db:"created_on" filter:"created_on,integer"` 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"` ModifiedOn types.DBDate `json:"modified_on" db:"modified_on" filter:"modified_on,integer"`
Name string `json:"name" db:"name" filter:"name,string"` 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"` IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"`
} }

View File

@@ -56,3 +56,16 @@ func (j *JSONB) UnmarshalJSON(data []byte) error {
func (j JSONB) MarshalJSON() ([]byte, error) { func (j JSONB) MarshalJSON() ([]byte, error) {
return json.Marshal(j.Decoded) 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
}

View File

@@ -41,12 +41,14 @@ mainLoop:
break mainLoop break mainLoop
} }
case <-ticker.C: case <-ticker.C:
// Can confirm that this will wait for completion before the next loop
requestCertificates() requestCertificates()
} }
} }
} }
func requestCertificates() { func requestCertificates() {
// logger.Debug("requestCertificates fired")
rows, err := certificate.GetByStatus(certificate.StatusReady) rows, err := certificate.GetByStatus(certificate.StatusReady)
if err != nil { if err != nil {
logger.Error("requestCertificatesError", err) logger.Error("requestCertificatesError", err)

View File

@@ -1,3 +1,4 @@
FROM nginxproxymanager/testca as testca
FROM jc21/nginx-full:github-no-acme-golang FROM jc21/nginx-full:github-no-acme-golang
LABEL maintainer="Jamie Curnow <jc@jc21.com>" 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: # Fix for golang dev:
RUN chown -R 1000:1000 /opt/go RUN chown -R 1000:1000 /opt/go
COPY --from=testca /home/step/certs/root_ca.crt /etc/ssl/certs/NginxProxyManager.crt
EXPOSE 80 EXPOSE 80
CMD [ "/init" ] CMD [ "/init" ]
HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:81/api || exit 1 HEALTHCHECK --interval=15s --timeout=3s CMD curl -f http://127.0.0.1:81/api || exit 1

View File

@@ -24,8 +24,15 @@ services:
- ../data:/data - ../data:/data
working_dir: /app working_dir: /app
stepca:
image: nginxproxymanager/testca
networks:
default:
aliases:
- ca.internal
swagger: swagger:
image: 'swaggerapi/swagger-ui:latest' image: swaggerapi/swagger-ui:latest
ports: ports:
- 3001:80 - 3001:80
environment: environment:

View File

@@ -1,5 +1,5 @@
import * as api from "./base"; import * as api from "./base";
import { UserResponse } from "./responseTypes"; import { User } from "./models";
interface AuthOptions { interface AuthOptions {
type: string; type: string;
@@ -20,7 +20,7 @@ interface Options {
export async function createUser( export async function createUser(
{ payload }: Options, { payload }: Options,
abortController?: AbortController, abortController?: AbortController,
): Promise<UserResponse> { ): Promise<User> {
const { result } = await api.post( const { result } = await api.post(
{ {
url: "/users", url: "/users",

View File

@@ -1,9 +1,7 @@
import * as api from "./base"; import * as api from "./base";
import { UserResponse } from "./responseTypes"; import { User } from "./models";
export async function getUser( export async function getUser(id: number | string = "me"): Promise<User> {
id: number | string = "me",
): Promise<UserResponse> {
const userId = id ? id : "me"; const userId = id ? id : "me";
const { result } = await api.get({ const { result } = await api.get({
url: `/users/${userId}`, url: `/users/${userId}`,

View File

@@ -3,6 +3,9 @@ export * from "./getToken";
export * from "./getUser"; export * from "./getUser";
export * from "./models"; export * from "./models";
export * from "./refreshToken"; export * from "./refreshToken";
export * from "./requestCertificateAuthorities";
export * from "./requestCertificates";
export * from "./requestHealth"; export * from "./requestHealth";
export * from "./requestSettings"; export * from "./requestSettings";
export * from "./requestUsers";
export * from "./responseTypes"; export * from "./responseTypes";

View File

@@ -3,6 +3,27 @@ export interface Sort {
direction: "ASC" | "DESC"; 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 { export interface Setting {
id: number; id: number;
createdOn: number; createdOn: number;
@@ -10,3 +31,27 @@ export interface Setting {
name: string; name: string;
value: any; 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -1,4 +1,10 @@
import { Sort, Setting } from "./models"; import {
Certificate,
CertificateAuthority,
Setting,
Sort,
User,
} from "./models";
export interface HealthResponse { export interface HealthResponse {
commit: string; commit: string;
@@ -8,32 +14,11 @@ export interface HealthResponse {
version: string; version: string;
} }
export interface UserAuthResponse {
id: number;
userId: number;
type: string;
createdOn: number;
updatedOn: number;
}
export interface TokenResponse { export interface TokenResponse {
expires: number; expires: number;
token: string; 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 { export interface SettingsResponse {
total: number; total: number;
offset: number; offset: number;
@@ -41,3 +26,27 @@ export interface SettingsResponse {
sort: Sort[]; sort: Sort[];
items: Setting[]; 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[];
}

View File

@@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { Navigation } from "components"; import { Dropdown, Navigation } from "components";
import { intl } from "locale"; import { intl } from "locale";
import { Link } from "react-router-dom";
import { import {
Book, Book,
DeviceDesktop, DeviceDesktop,
@@ -43,20 +44,30 @@ function NavMenu() {
to: "/access-lists", 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", id: "certificates.title",
defaultMessage: "Certificates", defaultMessage: "Certificates",
}), })}
icon: <Shield />, </span>
to: "/certificates", </Link>
}, </Dropdown.Item>,
{ <Dropdown.Item key="ssl-authorities">
title: intl.formatMessage({ <Link to="/ssl/authorities" role="button" aria-expanded="false">
id: "users.title", <span className="nav-link-title">
defaultMessage: "Users", {intl.formatMessage({
}), id: "cert_authorities.title",
icon: <Users />, defaultMessage: "Certificate Authorities",
to: "/users", })}
</span>
</Link>
</Dropdown.Item>,
],
}, },
{ {
title: intl.formatMessage({ title: intl.formatMessage({
@@ -66,6 +77,14 @@ function NavMenu() {
icon: <Book />, icon: <Book />,
to: "/audit-log", to: "/audit-log",
}, },
{
title: intl.formatMessage({
id: "users.title",
defaultMessage: "Users",
}),
icon: <Users />,
to: "/users",
},
{ {
title: intl.formatMessage({ title: intl.formatMessage({
id: "settings.title", id: "settings.title",

View File

@@ -12,6 +12,9 @@ import { BrowserRouter, Switch, Route } from "react-router-dom";
const AccessLists = lazy(() => import("pages/AccessLists")); const AccessLists = lazy(() => import("pages/AccessLists"));
const AuditLog = lazy(() => import("pages/AuditLog")); const AuditLog = lazy(() => import("pages/AuditLog"));
const Certificates = lazy(() => import("pages/Certificates")); const Certificates = lazy(() => import("pages/Certificates"));
const CertificateAuthorities = lazy(
() => import("pages/CertificateAuthorities"),
);
const Dashboard = lazy(() => import("pages/Dashboard")); const Dashboard = lazy(() => import("pages/Dashboard"));
const Hosts = lazy(() => import("pages/Hosts")); const Hosts = lazy(() => import("pages/Hosts"));
const Login = lazy(() => import("pages/Login")); const Login = lazy(() => import("pages/Login"));
@@ -54,9 +57,12 @@ function Router() {
<Route path="/hosts"> <Route path="/hosts">
<Hosts /> <Hosts />
</Route> </Route>
<Route path="/certificates"> <Route path="/ssl/certificates">
<Certificates /> <Certificates />
</Route> </Route>
<Route path="/ssl/authorities">
<CertificateAuthorities />
</Route>
<Route path="/audit-log"> <Route path="/audit-log">
<AuditLog /> <AuditLog />
</Route> </Route>

View File

@@ -1,6 +1,8 @@
import React from "react"; import React from "react";
import cn from "classnames"; import cn from "classnames";
import { Badge } from "components";
import { intl } from "locale";
export interface TableColumn { export interface TableColumn {
/** /**
@@ -52,9 +54,33 @@ export const Table = ({ columns, data, pagination, sortBy }: TableProps) => {
switch (given) { switch (given) {
// Simple ID column has text-muted // Simple ID column has text-muted
case "id": case "id":
return (val: any) => { return (val: number) => {
return <span className="text-muted">{val}</span>; 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>
);
};
} }
} }

View File

@@ -1,18 +1,18 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { getUser, UserResponse } from "api/npm"; import { getUser, User } from "api/npm";
import { useAuthState } from "context"; import { useAuthState } from "context";
// Context // Context
const initalValue = null; const initalValue = null;
const UserContext = React.createContext<UserResponse | null>(initalValue); const UserContext = React.createContext<User | null>(initalValue);
// Provider // Provider
interface Props { interface Props {
children?: JSX.Element; children?: JSX.Element;
} }
function UserProvider({ children }: Props) { function UserProvider({ children }: Props) {
const [userData, setUserData] = useState<UserResponse>({ const [userData, setUserData] = useState<User>({
id: 0, id: 0,
name: "", name: "",
nickname: "", nickname: "",

View File

@@ -5,6 +5,9 @@
"auditlog.title": { "auditlog.title": {
"defaultMessage": "Audit Log" "defaultMessage": "Audit Log"
}, },
"cert_authorities.title": {
"defaultMessage": "Certificate Authorities"
},
"certificates.title": { "certificates.title": {
"defaultMessage": "Certificates" "defaultMessage": "Certificates"
}, },
@@ -14,12 +17,21 @@
"column.id": { "column.id": {
"defaultMessage": "ID" "defaultMessage": "ID"
}, },
"column.max_domains": {
"defaultMessage": "Domains per Cert"
},
"column.name": { "column.name": {
"defaultMessage": "Name" "defaultMessage": "Name"
}, },
"column.status": {
"defaultMessage": "Status"
},
"dashboard.title": { "dashboard.title": {
"defaultMessage": "Dashboard" "defaultMessage": "Dashboard"
}, },
"column.wildcard_support": {
"defaultMessage": "Wildcard Support"
},
"footer.changelog": { "footer.changelog": {
"defaultMessage": "Change Log" "defaultMessage": "Change Log"
}, },
@@ -47,6 +59,12 @@
"profile.title": { "profile.title": {
"defaultMessage": "Profile settings" "defaultMessage": "Profile settings"
}, },
"ready": {
"defaultMessage": "Ready"
},
"required": {
"defaultMessage": "Required"
},
"settings.title": { "settings.title": {
"defaultMessage": "Settings" "defaultMessage": "Settings"
}, },

View 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;

View File

@@ -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 { intl } from "locale";
import { useInterval } from "rooks";
import styled from "styled-components"; import styled from "styled-components";
const Root = styled.div` const Root = styled.div`
@@ -8,11 +12,43 @@ const Root = styled.div`
flex-direction: column; 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 ( return (
<Root> <Root>
<div className="card"> <div className="card">
<div className="card-status-top bg-cyan" /> <div className="card-status-top bg-yellow" />
<div className="card-header"> <div className="card-header">
<h3 className="card-title"> <h3 className="card-title">
{intl.formatMessage({ {intl.formatMessage({
@@ -21,9 +57,31 @@ function Certificates() {
})} })}
</h3> </h3>
</div> </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> </div>
</Root> </Root>
); );
} }
export default Certificates; if (typeof data.total !== "undefined") {
return <p>No items!</p>;
}
return <SuspenseLoader />;
}
export default CertificateAuthorities;

View File

@@ -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 { intl } from "locale";
import { useInterval } from "rooks";
import styled from "styled-components"; import styled from "styled-components";
const Root = styled.div` const Root = styled.div`
@@ -9,10 +13,46 @@ const Root = styled.div`
`; `;
function Users() { 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 ( return (
<Root> <Root>
<div className="card"> <div className="card">
<div className="card-status-top bg-cyan" /> <div className="card-status-top bg-indigo" />
<div className="card-header"> <div className="card-header">
<h3 className="card-title"> <h3 className="card-title">
{intl.formatMessage({ {intl.formatMessage({
@@ -21,9 +61,31 @@ function Users() {
})} })}
</h3> </h3>
</div> </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> </div>
</Root> </Root>
); );
} }
if (typeof data.total !== "undefined") {
return <p>No items!</p>;
}
return <SuspenseLoader />;
}
export default Users; export default Users;