From 339ee13346fffc9b40052caa6c7c358ae409a782 Mon Sep 17 00:00:00 2001 From: Jamie Curnow Date: Thu, 29 Jul 2021 17:45:14 +1000 Subject: [PATCH] Certificate Authority work --- .../CertificateAuthorityObject.json | 25 +++- .../components/CertificateObject.json | 3 + .../certificates-authorities/caID/get.json | 12 +- .../certificates-authorities/caID/put.json | 12 +- .../paths/certificates-authorities/get.json | 24 ++-- .../paths/certificates-authorities/post.json | 14 ++- .../20201013035318_initial_schema.sql | 6 +- .../20201013035839_initial_data.sql | 47 +++++-- backend/internal/acme/acmesh.go | 36 +++++- .../schema/create_certificate_authority.go | 8 +- .../schema/update_certificate_authority.go | 5 +- .../internal/entity/certificate/methods.go | 2 - backend/internal/entity/certificate/model.go | 53 +++++++- .../entity/certificateauthority/methods.go | 18 ++- .../entity/certificateauthority/model.go | 16 ++- backend/internal/types/jsonb.go | 13 ++ backend/internal/worker/certificate.go | 2 + docker/dev/Dockerfile | 3 + docker/docker-compose.dev.yml | 9 +- frontend/src/api/npm/createUser.ts | 4 +- frontend/src/api/npm/getUser.ts | 6 +- frontend/src/api/npm/index.ts | 3 + frontend/src/api/npm/models.ts | 45 +++++++ .../api/npm/requestCertificateAuthorities.ts | 16 +++ frontend/src/api/npm/requestCertificates.ts | 16 +++ frontend/src/api/npm/requestUsers.ts | 16 +++ frontend/src/api/npm/responseTypes.ts | 53 ++++---- frontend/src/components/NavMenu.tsx | 47 ++++--- frontend/src/components/Router.tsx | 8 +- frontend/src/components/Table/Table.tsx | 28 ++++- frontend/src/context/UserContext.tsx | 6 +- frontend/src/locale/src/en.json | 18 +++ .../pages/CertificateAuthorities/index.tsx | 115 ++++++++++++++++++ frontend/src/pages/Certificates/index.tsx | 92 +++++++++++--- frontend/src/pages/Users/index.tsx | 92 +++++++++++--- 35 files changed, 737 insertions(+), 136 deletions(-) create mode 100644 frontend/src/api/npm/requestCertificateAuthorities.ts create mode 100644 frontend/src/api/npm/requestCertificates.ts create mode 100644 frontend/src/api/npm/requestUsers.ts create mode 100644 frontend/src/pages/CertificateAuthorities/index.tsx diff --git a/backend/embed/api_docs/components/CertificateAuthorityObject.json b/backend/embed/api_docs/components/CertificateAuthorityObject.json index 62289cce..e6c9a089 100644 --- a/backend/embed/api_docs/components/CertificateAuthorityObject.json +++ b/backend/embed/api_docs/components/CertificateAuthorityObject.json @@ -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" } } } \ No newline at end of file diff --git a/backend/embed/api_docs/components/CertificateObject.json b/backend/embed/api_docs/components/CertificateObject.json index abf36513..24b914f1 100644 --- a/backend/embed/api_docs/components/CertificateObject.json +++ b/backend/embed/api_docs/components/CertificateObject.json @@ -49,6 +49,9 @@ "type": "integer", "minimum": 0 }, + "certificate_authority": { + "$ref": "#/components/schemas/CertificateAuthorityObject" + }, "dns_provider_id": { "type": "integer", "minimum": 0 diff --git a/backend/embed/api_docs/paths/certificates-authorities/caID/get.json b/backend/embed/api_docs/paths/certificates-authorities/caID/get.json index 33f35416..40a18ee3 100644 --- a/backend/embed/api_docs/paths/certificates-authorities/caID/get.json +++ b/backend/embed/api_docs/paths/certificates-authorities/caID/get.json @@ -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 } } } diff --git a/backend/embed/api_docs/paths/certificates-authorities/caID/put.json b/backend/embed/api_docs/paths/certificates-authorities/caID/put.json index b1e99602..6fd0b18d 100644 --- a/backend/embed/api_docs/paths/certificates-authorities/caID/put.json +++ b/backend/embed/api_docs/paths/certificates-authorities/caID/put.json @@ -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 } } } diff --git a/backend/embed/api_docs/paths/certificates-authorities/get.json b/backend/embed/api_docs/paths/certificates-authorities/get.json index 14e54070..b4fc8f80 100644 --- a/backend/embed/api_docs/paths/certificates-authorities/get.json +++ b/backend/embed/api_docs/paths/certificates-authorities/get.json @@ -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 } ] } diff --git a/backend/embed/api_docs/paths/certificates-authorities/post.json b/backend/embed/api_docs/paths/certificates-authorities/post.json index 8cd359db..c980ac0c 100644 --- a/backend/embed/api_docs/paths/certificates-authorities/post.json +++ b/backend/embed/api_docs/paths/certificates-authorities/post.json @@ -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 } } } diff --git a/backend/embed/migrations/20201013035318_initial_schema.sql b/backend/embed/migrations/20201013035318_initial_schema.sql index 672dea23..73535cfd 100644 --- a/backend/embed/migrations/20201013035318_initial_schema.sql +++ b/backend/embed/migrations/20201013035318_initial_schema.sql @@ -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 ); diff --git a/backend/embed/migrations/20201013035839_initial_data.sql b/backend/embed/migrations/20201013035839_initial_data.sql index 11fd6ae1..a6e5cc1b 100644 --- a/backend/embed/migrations/20201013035839_initial_data.sql +++ b/backend/embed/migrations/20201013035839_initial_data.sql @@ -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 diff --git a/backend/internal/acme/acmesh.go b/backend/internal/acme/acmesh.go index 1c830363..ddf3b7e8 100644 --- a/backend/internal/acme/acmesh.go +++ b/backend/internal/acme/acmesh.go @@ -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 +} diff --git a/backend/internal/api/schema/create_certificate_authority.go b/backend/internal/api/schema/create_certificate_authority.go index a4b69bb3..a4269c0b 100644 --- a/backend/internal/api/schema/create_certificate_authority.go +++ b/backend/internal/api/schema/create_certificate_authority.go @@ -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) } diff --git a/backend/internal/api/schema/update_certificate_authority.go b/backend/internal/api/schema/update_certificate_authority.go index 9bdeadc5..15ebe5a6 100644 --- a/backend/internal/api/schema/update_certificate_authority.go +++ b/backend/internal/api/schema/update_certificate_authority.go @@ -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) } diff --git a/backend/internal/entity/certificate/methods.go b/backend/internal/entity/certificate/methods.go index 27ae7d78..ba0445f2 100644 --- a/backend/internal/entity/certificate/methods.go +++ b/backend/internal/entity/certificate/methods.go @@ -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) diff --git a/backend/internal/entity/certificate/model.go b/backend/internal/entity/certificate/model.go index c504664b..0f582cd9 100644 --- a/backend/internal/entity/certificate/model.go +++ b/backend/internal/entity/certificate/model.go @@ -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 - m.Status = StatusFailed - m.ErrorMessage = "something" - if err := m.Save(); err != nil { + // 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 = 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 } diff --git a/backend/internal/entity/certificateauthority/methods.go b/backend/internal/entity/certificateauthority/methods.go index 6a22b02f..e70e63f1 100644 --- a/backend/internal/entity/certificateauthority/methods.go +++ b/backend/internal/entity/certificateauthority/methods.go @@ -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) diff --git a/backend/internal/entity/certificateauthority/model.go b/backend/internal/entity/certificateauthority/model.go index 8a3ce47b..3f6a7c81 100644 --- a/backend/internal/entity/certificateauthority/model.go +++ b/backend/internal/entity/certificateauthority/model.go @@ -14,12 +14,16 @@ const ( // Model is the user model type Model struct { - ID int `json:"id" db:"id" filter:"id,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"` - Name string `json:"name" db:"name" filter:"name,string"` - Acme2URL string `json:"acme2_url" db:"acme2_url" filter:"acme2_url,string"` - IsDeleted bool `json:"is_deleted,omitempty" db:"is_deleted"` + ID int `json:"id" db:"id" filter:"id,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"` + Name string `json:"name" db:"name" filter:"name,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"` } func (m *Model) getByQuery(query string, params []interface{}) error { diff --git a/backend/internal/types/jsonb.go b/backend/internal/types/jsonb.go index d0fa2ae5..ff3f7930 100644 --- a/backend/internal/types/jsonb.go +++ b/backend/internal/types/jsonb.go @@ -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 +} diff --git a/backend/internal/worker/certificate.go b/backend/internal/worker/certificate.go index 157bdb67..a108010f 100644 --- a/backend/internal/worker/certificate.go +++ b/backend/internal/worker/certificate.go @@ -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) diff --git a/docker/dev/Dockerfile b/docker/dev/Dockerfile index 2f0f0cfe..4ec61383 100644 --- a/docker/dev/Dockerfile +++ b/docker/dev/Dockerfile @@ -1,3 +1,4 @@ +FROM nginxproxymanager/testca as testca FROM jc21/nginx-full:github-no-acme-golang LABEL maintainer="Jamie Curnow " @@ -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 diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 25c60dd7..d22ea115 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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: diff --git a/frontend/src/api/npm/createUser.ts b/frontend/src/api/npm/createUser.ts index 08c7f100..aa48c298 100644 --- a/frontend/src/api/npm/createUser.ts +++ b/frontend/src/api/npm/createUser.ts @@ -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 { +): Promise { const { result } = await api.post( { url: "/users", diff --git a/frontend/src/api/npm/getUser.ts b/frontend/src/api/npm/getUser.ts index 70230836..f4253f3d 100644 --- a/frontend/src/api/npm/getUser.ts +++ b/frontend/src/api/npm/getUser.ts @@ -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 { +export async function getUser(id: number | string = "me"): Promise { const userId = id ? id : "me"; const { result } = await api.get({ url: `/users/${userId}`, diff --git a/frontend/src/api/npm/index.ts b/frontend/src/api/npm/index.ts index 19da117f..118fe34b 100644 --- a/frontend/src/api/npm/index.ts +++ b/frontend/src/api/npm/index.ts @@ -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"; diff --git a/frontend/src/api/npm/models.ts b/frontend/src/api/npm/models.ts index 59a3d5a1..4607033e 100644 --- a/frontend/src/api/npm/models.ts +++ b/frontend/src/api/npm/models.ts @@ -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; +} diff --git a/frontend/src/api/npm/requestCertificateAuthorities.ts b/frontend/src/api/npm/requestCertificateAuthorities.ts new file mode 100644 index 00000000..5094c3f2 --- /dev/null +++ b/frontend/src/api/npm/requestCertificateAuthorities.ts @@ -0,0 +1,16 @@ +import * as api from "./base"; +import { CertificateAuthoritiesResponse } from "./responseTypes"; + +export async function requestCertificateAuthorities( + offset?: number, + abortController?: AbortController, +): Promise { + const { result } = await api.get( + { + url: "certificate-authorities", + params: { limit: 20, offset: offset || 0 }, + }, + abortController, + ); + return result; +} diff --git a/frontend/src/api/npm/requestCertificates.ts b/frontend/src/api/npm/requestCertificates.ts new file mode 100644 index 00000000..fd6a8323 --- /dev/null +++ b/frontend/src/api/npm/requestCertificates.ts @@ -0,0 +1,16 @@ +import * as api from "./base"; +import { CertificatesResponse } from "./responseTypes"; + +export async function requestCertificates( + offset?: number, + abortController?: AbortController, +): Promise { + const { result } = await api.get( + { + url: "certificates", + params: { limit: 20, offset: offset || 0 }, + }, + abortController, + ); + return result; +} diff --git a/frontend/src/api/npm/requestUsers.ts b/frontend/src/api/npm/requestUsers.ts new file mode 100644 index 00000000..870a333d --- /dev/null +++ b/frontend/src/api/npm/requestUsers.ts @@ -0,0 +1,16 @@ +import * as api from "./base"; +import { UsersResponse } from "./responseTypes"; + +export async function requestUsers( + offset?: number, + abortController?: AbortController, +): Promise { + const { result } = await api.get( + { + url: "users", + params: { limit: 20, offset: offset || 0 }, + }, + abortController, + ); + return result; +} diff --git a/frontend/src/api/npm/responseTypes.ts b/frontend/src/api/npm/responseTypes.ts index cf0c19f1..c9cb5a09 100644 --- a/frontend/src/api/npm/responseTypes.ts +++ b/frontend/src/api/npm/responseTypes.ts @@ -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[]; +} diff --git a/frontend/src/components/NavMenu.tsx b/frontend/src/components/NavMenu.tsx index cce93fbb..7a7f758b 100644 --- a/frontend/src/components/NavMenu.tsx +++ b/frontend/src/components/NavMenu.tsx @@ -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({ - id: "certificates.title", - defaultMessage: "Certificates", - }), + title: "SSL", icon: , - to: "/certificates", - }, - { - title: intl.formatMessage({ - id: "users.title", - defaultMessage: "Users", - }), - icon: , - to: "/users", + dropdownItems: [ + + + + {intl.formatMessage({ + id: "certificates.title", + defaultMessage: "Certificates", + })} + + + , + + + + {intl.formatMessage({ + id: "cert_authorities.title", + defaultMessage: "Certificate Authorities", + })} + + + , + ], }, { title: intl.formatMessage({ @@ -66,6 +77,14 @@ function NavMenu() { icon: , to: "/audit-log", }, + { + title: intl.formatMessage({ + id: "users.title", + defaultMessage: "Users", + }), + icon: , + to: "/users", + }, { title: intl.formatMessage({ id: "settings.title", diff --git a/frontend/src/components/Router.tsx b/frontend/src/components/Router.tsx index 5301eec0..fdcba6c5 100644 --- a/frontend/src/components/Router.tsx +++ b/frontend/src/components/Router.tsx @@ -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() { - + + + + diff --git a/frontend/src/components/Table/Table.tsx b/frontend/src/components/Table/Table.tsx index 88c5e2f9..419a5160 100644 --- a/frontend/src/components/Table/Table.tsx +++ b/frontend/src/components/Table/Table.tsx @@ -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 {val}; }; + case "setup": + return (val: boolean) => { + return ( + + {val + ? intl.formatMessage({ + id: "ready", + defaultMessage: "Ready", + }) + : intl.formatMessage({ + id: "required", + defaultMessage: "Required", + })} + + ); + }; + case "bool": + return (val: boolean) => { + return ( + + {val ? "true" : "false"} + + ); + }; } } diff --git a/frontend/src/context/UserContext.tsx b/frontend/src/context/UserContext.tsx index f0edc040..26f018f3 100644 --- a/frontend/src/context/UserContext.tsx +++ b/frontend/src/context/UserContext.tsx @@ -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(initalValue); +const UserContext = React.createContext(initalValue); // Provider interface Props { children?: JSX.Element; } function UserProvider({ children }: Props) { - const [userData, setUserData] = useState({ + const [userData, setUserData] = useState({ id: 0, name: "", nickname: "", diff --git a/frontend/src/locale/src/en.json b/frontend/src/locale/src/en.json index d6dd62f8..6cca765a 100644 --- a/frontend/src/locale/src/en.json +++ b/frontend/src/locale/src/en.json @@ -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" }, diff --git a/frontend/src/pages/CertificateAuthorities/index.tsx b/frontend/src/pages/CertificateAuthorities/index.tsx new file mode 100644 index 00000000..4000568e --- /dev/null +++ b/frontend/src/pages/CertificateAuthorities/index.tsx @@ -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 ( + +
+
+
+

+ {intl.formatMessage({ + id: "cert_authorities.title", + defaultMessage: "Certificate Authorities", + })} +

+
+ { + if (offset !== num) { + setOffset(num); + } + }, + }} + /> + + + ); + } + + if (typeof data.total !== "undefined") { + return

No items!

; + } + + return ; +} + +export default CertificateAuthorities; diff --git a/frontend/src/pages/Certificates/index.tsx b/frontend/src/pages/Certificates/index.tsx index 57a87d90..f96c9250 100644 --- a/frontend/src/pages/Certificates/index.tsx +++ b/frontend/src/pages/Certificates/index.tsx @@ -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,22 +12,76 @@ const Root = styled.div` flex-direction: column; `; -function Certificates() { - return ( - -
-
-
-

- {intl.formatMessage({ - id: "certificates.title", - defaultMessage: "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 ( + +
+
+
+

+ {intl.formatMessage({ + id: "certificates.title", + defaultMessage: "Certificates", + })} +

+
+
{ + if (offset !== num) { + setOffset(num); + } + }, + }} + /> - - - ); + + ); + } + + if (typeof data.total !== "undefined") { + return

No items!

; + } + + return ; } -export default Certificates; +export default CertificateAuthorities; diff --git a/frontend/src/pages/Users/index.tsx b/frontend/src/pages/Users/index.tsx index 3abf5f7e..c14a1fc4 100644 --- a/frontend/src/pages/Users/index.tsx +++ b/frontend/src/pages/Users/index.tsx @@ -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,21 +13,79 @@ const Root = styled.div` `; function Users() { - return ( - -
-
-
-

- {intl.formatMessage({ - id: "users.title", - defaultMessage: "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 ( + +
+
+
+

+ {intl.formatMessage({ + id: "users.title", + defaultMessage: "Users", + })} +

+
+
{ + if (offset !== num) { + setOffset(num); + } + }, + }} + /> - - - ); + + ); + } + + if (typeof data.total !== "undefined") { + return

No items!

; + } + + return ; } export default Users;