From 632bcd675440e8547abd8fe608a15811afc42b89 Mon Sep 17 00:00:00 2001 From: Hiram Chirino Date: Thu, 7 Dec 2023 11:00:14 -0500 Subject: [PATCH] feat: add a sites and ca api * add feature flags for site, ca and device apis * ca and site api documentation marks them experimental * use spiffe style URIs in the certs * add revision field to VPCs so they can be watched for changes. * events API can now watch for VPC changes Signed-off-by: Hiram Chirino --- Makefile | 1 + cmd/apiserver/main.go | 53 +- cmd/nexctl/main.go | 1 + cmd/nexctl/site.go | 128 +++ deploy/nexodus/base/apiserver/deployment.yaml | 22 + .../nexodus/base/apiserver/kustomization.yaml | 2 + deploy/nexodus/overlays/dev/issuer.yaml | 12 +- deploy/nexodus/overlays/openshift/issuer.yaml | 39 + deploy/nexodus/overlays/playground/.gitignore | 3 + .../overlays/playground/kustomization.yaml | 11 +- deploy/nexodus/overlays/prod/.gitignore | 3 + deploy/nexodus/overlays/qa/.gitignore | 3 + hack/gen-ca-secret.sh | 28 + hack/openapi-templates/model.mustache | 2 + integration-tests/features/ca-api.feature | 102 +++ .../features/feature-flags-api.feature | 5 +- integration-tests/features/site-api.feature | 341 ++++++++ integration-tests/features/vpc-api.feature | 3 + internal/api/public/.openapi-generator/FILES | 8 + internal/api/public/api_ca.go | 155 ++++ internal/api/public/api_sites.go | 785 ++++++++++++++++++ internal/api/public/api_vpc.go | 157 ++++ internal/api/public/api_vpc_sites_custom.go | 39 + internal/api/public/client.go | 6 + internal/api/public/model_models_add_site.go | 19 + ...odel_models_certificate_signing_request.go | 23 + ...del_models_certificate_signing_response.go | 19 + internal/api/public/model_models_key_usage.go | 41 + internal/api/public/model_models_site.go | 27 + .../api/public/model_models_update_site.go | 19 + internal/api/public/model_models_vpc.go | 14 +- internal/cucumber/certificates.go | 61 ++ internal/cucumber/cucumber.go | 239 +++++- internal/cucumber/http_response.go | 38 +- internal/database/database.go | 1 + .../migration_20231211_0000/migration.go | 75 ++ internal/docs-private/docs.go | 12 +- internal/docs-private/swagger.json | 12 +- internal/docs-private/swagger.yaml | 7 + internal/docs/docs.go | 595 ++++++++++++- internal/docs/swagger.json | 595 ++++++++++++- internal/docs/swagger.yaml | 456 ++++++++++ internal/fflags/fflags.go | 44 +- internal/handlers/api.go | 19 + internal/handlers/ca.go | 410 +++++++++ internal/handlers/envoy_authz.go | 62 ++ internal/handlers/events.go | 51 ++ internal/handlers/handlers_test.go | 2 +- internal/handlers/site.go | 535 ++++++++++++ internal/handlers/vpc.go | 22 +- internal/models/ca.go | 91 ++ internal/models/duration.go | 34 + internal/models/site.go | 40 + internal/models/vpc.go | 16 +- internal/routers/routers.go | 9 + internal/routers/token.rego | 24 +- ui/src/App.tsx | 55 +- ui/src/DataProvider.tsx | 40 + ui/src/layout/Menus.tsx | 50 +- ui/src/pages/Sites.tsx | 139 ++++ 60 files changed, 5656 insertions(+), 149 deletions(-) create mode 100644 cmd/nexctl/site.go create mode 100644 deploy/nexodus/overlays/playground/.gitignore create mode 100644 deploy/nexodus/overlays/prod/.gitignore create mode 100644 deploy/nexodus/overlays/qa/.gitignore create mode 100755 hack/gen-ca-secret.sh create mode 100644 integration-tests/features/ca-api.feature create mode 100644 integration-tests/features/site-api.feature create mode 100644 internal/api/public/api_ca.go create mode 100644 internal/api/public/api_sites.go create mode 100644 internal/api/public/api_vpc_sites_custom.go create mode 100644 internal/api/public/model_models_add_site.go create mode 100644 internal/api/public/model_models_certificate_signing_request.go create mode 100644 internal/api/public/model_models_certificate_signing_response.go create mode 100644 internal/api/public/model_models_key_usage.go create mode 100644 internal/api/public/model_models_site.go create mode 100644 internal/api/public/model_models_update_site.go create mode 100644 internal/cucumber/certificates.go create mode 100644 internal/database/migration_20231211_0000/migration.go create mode 100644 internal/handlers/ca.go create mode 100644 internal/handlers/site.go create mode 100644 internal/models/ca.go create mode 100644 internal/models/duration.go create mode 100644 internal/models/site.go create mode 100644 ui/src/DataProvider.tsx create mode 100644 ui/src/pages/Sites.tsx diff --git a/Makefile b/Makefile index 232be13e5..ace1aa9f6 100644 --- a/Makefile +++ b/Makefile @@ -549,6 +549,7 @@ clear-db: DROP TABLE IF EXISTS security_groups;\ DROP TABLE IF EXISTS device_metadata;\ DROP TABLE IF EXISTS devices;\ + DROP TABLE IF EXISTS sites;\ DROP TABLE IF EXISTS user_organizations;\ DROP TABLE IF EXISTS vpcs;\ DROP TABLE IF EXISTS organizations;\ diff --git a/cmd/apiserver/main.go b/cmd/apiserver/main.go index 6ea5405ec..efd2cd2eb 100644 --- a/cmd/apiserver/main.go +++ b/cmd/apiserver/main.go @@ -17,6 +17,7 @@ import ( "log" "net" "net/http" + "net/url" "os" "os/signal" "sync" @@ -54,23 +55,24 @@ func init() { tracer = otel.Tracer("apiserver") } -// @title Nexodus API -// @version 1.0 -// @description This is the Nexodus API Server. +// @title Nexodus API +// @description This is the Nexodus API Server. +// @version 1.0 +// @contact.name The Nexodus Authors +// @contact.url https://github.com/nexodus-io/nexodus/issues +// @license.name Apache 2.0 +// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @BasePath / -// @contact.name The Nexodus Authors -// @contact.url https://github.com/nexodus-io/nexodus/issues - -// @license.name Apache 2.0 -// @license.url http://www.apache.org/licenses/LICENSE-2.0.html +// @tag.name CA +// @tag.description X509 Certificate related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server. +// @tag.name Sites +// @tag.description Skupper Site related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server. // @securitydefinitions.oauth2.implicit OAuth2Implicit -// @authorizationurl https://auth.try.nexodus.127.0.0.1.nip.io/ -// +// @authorizationurl https://auth.try.nexodus.127.0.0.1.nip.io/ // @scope.admin Grants read and write access to administrative information // @scope.user Grants read and write access to resources owned by this user - -// @BasePath / func main() { // Override to capitalize "Show" cli.HelpFlag.(*cli.BoolFlag).Usage = "Show help" @@ -278,6 +280,18 @@ func main() { Required: false, Sources: cli.EnvVars("NEXAPI_SMTP_FROM"), }, + &cli.StringFlag{ + Name: "ca-cert", + Usage: "Certificate authority cert", + Required: false, + Sources: cli.EnvVars("NEXAPI_CA_CERT"), + }, + &cli.StringFlag{ + Name: "ca-key", + Usage: "Certificate authority key", + Required: false, + Sources: cli.EnvVars("NEXAPI_CA_KEY"), + }, }, Action: func(ctx context.Context, command *cli.Command) error { @@ -316,7 +330,16 @@ func main() { session.SetStore(sessionStore), ) - api, err := handlers.NewAPI(ctx, logger.Sugar(), db, ipam, fflags, store, signalBus, redisClient, sessionManager) + caKeyPair := handlers.CertificateKeyPair{} + if command.String("ca-cert") != "" && command.String("ca-key") != "" { + var err error + caKeyPair, err = handlers.ParseCertificateKeyPair([]byte(command.String("ca-cert")), []byte(command.String("ca-key"))) + if err != nil { + log.Fatal("invalid --ca-cert or --ca-key values:", err) + } + } + + api, err := handlers.NewAPI(ctx, logger.Sugar(), db, ipam, fflags, store, signalBus, redisClient, sessionManager, caKeyPair) if err != nil { log.Fatal(err) } @@ -381,6 +404,10 @@ func main() { log.Fatal(fmt.Errorf("invalid tls-key: %w", err)) } api.URL = command.String("url") + api.URLParsed, err = url.Parse(api.URL) + if err != nil { + log.Fatal(fmt.Errorf("invalid url: %w", err)) + } router, err := routers.NewAPIRouter(ctx, routers.APIRouterOptions{ Logger: logger.Sugar(), diff --git a/cmd/nexctl/main.go b/cmd/nexctl/main.go index 0e59aafbf..d8fa9e046 100644 --- a/cmd/nexctl/main.go +++ b/cmd/nexctl/main.go @@ -106,6 +106,7 @@ func main() { createDeviceCommand(), createUserSubCommand(), createSecurityGroupCommand(), + createSiteCommand(), createInvitationCommand(), }, } diff --git a/cmd/nexctl/site.go b/cmd/nexctl/site.go new file mode 100644 index 000000000..d6b28f6f7 --- /dev/null +++ b/cmd/nexctl/site.go @@ -0,0 +1,128 @@ +package main + +import ( + "context" + "github.com/google/uuid" + "github.com/nexodus-io/nexodus/internal/api/public" + "github.com/urfave/cli/v3" + "log" +) + +func createSiteCommand() *cli.Command { + return &cli.Command{ + Name: "site", + Hidden: true, + Usage: "Commands relating to sites", + Commands: []*cli.Command{ + { + Name: "list", + Usage: "List all sites", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "vpc-id", + Value: "", + Required: false, + }, + }, + Action: func(ctx context.Context, command *cli.Command) error { + orgID := command.String("vpc-id") + if orgID != "" { + id, err := uuid.Parse(orgID) + if err != nil { + log.Fatal(err) + } + return listVpcSites(ctx, command, id) + } + return listAllSites(ctx, command) + }, + }, + { + Name: "delete", + Usage: "Delete a site", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "site-id", + Required: true, + }, + }, + Action: func(ctx context.Context, command *cli.Command) error { + devID := command.String("site-id") + return deleteSite(ctx, command, devID) + }, + }, + { + Name: "update", + Usage: "Update a site", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "site-id", + Required: true, + }, + &cli.StringFlag{ + Name: "hostname", + Required: false, + }, + }, + Action: func(ctx context.Context, command *cli.Command) error { + devID := command.String("site-id") + update := public.ModelsUpdateSite{} + if command.IsSet("hostname") { + update.Hostname = command.String("hostname") + } + return updateSite(ctx, command, devID, update) + }, + }, + }, + } +} +func siteTableFields(command *cli.Command) []TableField { + var fields []TableField + fields = append(fields, TableField{Header: "SITE ID", Field: "Id"}) + fields = append(fields, TableField{Header: "HOSTNAME", Field: "Hostname"}) + fields = append(fields, TableField{Header: "VPC ID", Field: "VpcId"}) + fields = append(fields, TableField{Header: "PUBLIC KEY", Field: "PublicKey"}) + fields = append(fields, TableField{Header: "OS", Field: "Os"}) + return fields +} + +func listAllSites(ctx context.Context, command *cli.Command) error { + c := createClient(ctx, command) + sites := apiResponse(c.SitesApi.ListSites(context.Background()).Execute()) + show(command, siteTableFields(command), sites) + return nil +} + +func listVpcSites(ctx context.Context, command *cli.Command, vpcId uuid.UUID) error { + c := createClient(ctx, command) + sites := apiResponse(c.VPCApi.ListSitesInVPC(context.Background(), vpcId.String()).Execute()) + show(command, siteTableFields(command), sites) + return nil +} + +func deleteSite(ctx context.Context, command *cli.Command, devID string) error { + devUUID, err := uuid.Parse(devID) + if err != nil { + log.Fatalf("failed to parse a valid UUID from %s %v", devUUID, err) + } + c := createClient(ctx, command) + res := apiResponse(c.SitesApi.DeleteSite(context.Background(), devUUID.String()).Execute()) + show(command, orgTableFields(), res) + showSuccessfully(command, "deleted") + return nil +} + +func updateSite(ctx context.Context, command *cli.Command, devID string, update public.ModelsUpdateSite) error { + devUUID, err := uuid.Parse(devID) + if err != nil { + log.Fatalf("failed to parse a valid UUID from %s %v", devUUID, err) + } + + c := createClient(ctx, command) + res := apiResponse(c.SitesApi. + UpdateSite(context.Background(), devUUID.String()). + Update(update). + Execute()) + show(command, orgTableFields(), res) + showSuccessfully(command, "updated") + return nil +} diff --git a/deploy/nexodus/base/apiserver/deployment.yaml b/deploy/nexodus/base/apiserver/deployment.yaml index 43c487ff3..d0f5c6e65 100644 --- a/deploy/nexodus/base/apiserver/deployment.yaml +++ b/deploy/nexodus/base/apiserver/deployment.yaml @@ -193,6 +193,28 @@ spec: name: smtp-server key: NEXAPI_SMTP_USER optional: true + - name: NEXAPI_CA_CERT + valueFrom: + secretKeyRef: + name: nexodus-ca-key-pair + key: ca.crt + optional: true + - name: NEXAPI_CA_KEY + valueFrom: + secretKeyRef: + name: nexodus-ca-key-pair + key: tls.key + optional: true + - name: NEXAPI_FFLAG_DEVICES + valueFrom: + configMapKeyRef: + name: apiserver + key: NEXAPI_FFLAG_DEVICES + - name: NEXAPI_FFLAG_SITES + valueFrom: + configMapKeyRef: + name: apiserver + key: NEXAPI_FFLAG_SITES # CI deployment seems to fail when this is enabled # readinessProbe: diff --git a/deploy/nexodus/base/apiserver/kustomization.yaml b/deploy/nexodus/base/apiserver/kustomization.yaml index 6a05c4783..3bb5baef6 100644 --- a/deploy/nexodus/base/apiserver/kustomization.yaml +++ b/deploy/nexodus/base/apiserver/kustomization.yaml @@ -23,6 +23,8 @@ configMapGenerator: - NEXAPI_FETCH_MGR=redis - NEXAPI_FETCH_MGR_TIMEOUT=2s - NEXAPI_DEVICE_CACHE_SIZE=500 + - NEXAPI_FFLAG_DEVICES=true + - NEXAPI_FFLAG_SITES=true resources: - service.yaml - deployment.yaml diff --git a/deploy/nexodus/overlays/dev/issuer.yaml b/deploy/nexodus/overlays/dev/issuer.yaml index 871d39a2e..843e0bbf9 100644 --- a/deploy/nexodus/overlays/dev/issuer.yaml +++ b/deploy/nexodus/overlays/dev/issuer.yaml @@ -14,8 +14,16 @@ spec: commonName: nexodus-selfsigned-ca secretName: nexodus-ca-key-pair privateKey: - algorithm: ECDSA - size: 256 + algorithm: RSA + size: 2048 + encoding: PKCS1 + uris: + - https://try.nexodus.127.0.0.1.nip.io + usages: + - digital signature + - key encipherment + - crl sign + - cert sign issuerRef: name: selfsigned-issuer kind: Issuer diff --git a/deploy/nexodus/overlays/openshift/issuer.yaml b/deploy/nexodus/overlays/openshift/issuer.yaml index 87914c9ba..a435016de 100644 --- a/deploy/nexodus/overlays/openshift/issuer.yaml +++ b/deploy/nexodus/overlays/openshift/issuer.yaml @@ -12,3 +12,42 @@ spec: - http01: ingress: serviceType: ClusterIP +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: selfsigned-issuer +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: nexodus-selfsigned-ca +spec: + isCA: true + commonName: nexodus-selfsigned-ca + secretName: nexodus-ca-key-pair + privateKey: + algorithm: RSA + size: 2048 + encoding: PKCS1 + uris: + - https://try.nexodus.127.0.0.1.nip.io + usages: + - digital signature + - key encipherment + - crl sign + - cert sign + issuerRef: + name: selfsigned-issuer + kind: Issuer + group: cert-manager.io +--- +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + name: nexodus-issuer +spec: + ca: + secretName: nexodus-ca-key-pair diff --git a/deploy/nexodus/overlays/playground/.gitignore b/deploy/nexodus/overlays/playground/.gitignore new file mode 100644 index 000000000..bf87ec7c1 --- /dev/null +++ b/deploy/nexodus/overlays/playground/.gitignore @@ -0,0 +1,3 @@ +apiserver-ca.yaml +secret.yaml +secret-smtp.yaml \ No newline at end of file diff --git a/deploy/nexodus/overlays/playground/kustomization.yaml b/deploy/nexodus/overlays/playground/kustomization.yaml index 03eab6443..6704a3181 100644 --- a/deploy/nexodus/overlays/playground/kustomization.yaml +++ b/deploy/nexodus/overlays/playground/kustomization.yaml @@ -6,15 +6,16 @@ resources: namespace: nexodus-playground configMapGenerator: - behavior: replace + name: auth-config literals: - hostname=auth.playground.nexodus.io - frontend-url=https://playground.nexodus.io - name: auth-config - behavior: replace + name: realm files: - files/nexodus.json - name: realm - behavior: merge + name: apiproxy literals: - APIPROXY_API_URL=https://api.playground.nexodus.io - APIPROXY_OIDC_URL=https://auth.playground.nexodus.io/realms/nexodus @@ -22,8 +23,8 @@ configMapGenerator: - APIPROXY_WEB_DOMAIN=playground.nexodus.io - APIPROXY_WEB_ORIGINS=https://playground.nexodus.io - ENVOY_COMP_LOG_LEVEL=upstream:info,http:info,router:info,jwt:info - name: apiproxy - behavior: merge + name: apiserver literals: - NEXAPI_URL=https://api.playground.nexodus.io - NEXAPI_OIDC_URL=https://auth.playground.nexodus.io/realms/nexodus @@ -31,9 +32,9 @@ configMapGenerator: - NEXAPI_REDIRECT_URL=https://playground.nexodus.io/#/login - NEXAPI_ORIGINS=https://playground.nexodus.io - NEXAPI_ENVIRONMENT=qa - - NEXAPI_FFLAG_SECURITY_GROUPS=true + - NEXAPI_FFLAG_DEVICES=false + - NEXAPI_FFLAG_SITES=true - NEXAPI_DEBUG=0 - name: apiserver patches: # Update the dns names for the certificates diff --git a/deploy/nexodus/overlays/prod/.gitignore b/deploy/nexodus/overlays/prod/.gitignore new file mode 100644 index 000000000..bf87ec7c1 --- /dev/null +++ b/deploy/nexodus/overlays/prod/.gitignore @@ -0,0 +1,3 @@ +apiserver-ca.yaml +secret.yaml +secret-smtp.yaml \ No newline at end of file diff --git a/deploy/nexodus/overlays/qa/.gitignore b/deploy/nexodus/overlays/qa/.gitignore new file mode 100644 index 000000000..bf87ec7c1 --- /dev/null +++ b/deploy/nexodus/overlays/qa/.gitignore @@ -0,0 +1,3 @@ +apiserver-ca.yaml +secret.yaml +secret-smtp.yaml \ No newline at end of file diff --git a/hack/gen-ca-secret.sh b/hack/gen-ca-secret.sh new file mode 100755 index 000000000..7a0d1dc88 --- /dev/null +++ b/hack/gen-ca-secret.sh @@ -0,0 +1,28 @@ +#!/bin/bash +set -e + +if [ $# -eq 0 ]; then + echo "usage: $0 " + echo "example: $0 try.nexodus.io" + exit 1 +fi + +if [ -d ./.ca ]; then + echo "error: ./ca directory already exists" + exit 1 +fi + +echo "Generating new CA" +mkdir ./.ca || true +chmod 700 ./.ca +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ./.ca/tls.key +openssl req -new -x509 -days 3650 -key ./.ca/key.pem -out ./.ca/ca.crt -subj "/CN=$1" + +touch apiserver-ca.yaml +chmod 600 apiserver-ca.yaml +kubectl create secret generic apiserver-ca \ + --from-file=ca.crt=./.ca/ca.crt \ + --from-file=tls.key=./.ca/tls.key \ + --output yaml --dry-run=client > nexodus-ca-key-pair.yaml +echo "Created nexodus-ca-key-pair.yaml" +rm -rf ./.ca diff --git a/hack/openapi-templates/model.mustache b/hack/openapi-templates/model.mustache index 835cd9812..8226fa5c0 100644 --- a/hack/openapi-templates/model.mustache +++ b/hack/openapi-templates/model.mustache @@ -4,6 +4,7 @@ {{>partial_header}} package {{packageName}} {{#models}} +{{^model.isEnum}} {{#imports}} {{#-first}} import ( @@ -13,6 +14,7 @@ import ( ) {{/-last}} {{/imports}} +{{/model.isEnum}} {{#model}} {{#isEnum}} // {{{classname}}} {{#description}}{{{.}}}{{/description}}{{^description}}the model '{{{classname}}}'{{/description}} diff --git a/integration-tests/features/ca-api.feature b/integration-tests/features/ca-api.feature new file mode 100644 index 000000000..170efbc81 --- /dev/null +++ b/integration-tests/features/ca-api.feature @@ -0,0 +1,102 @@ +Feature: CA API + + Background: + Given a user named "Bob" with password "testpass" + + Scenario: Sign a CSR + + Given I am logged in as "Bob" + + When I GET path "/api/users/me" + Then the response code should be 200 + Given I store the ".id" selection from the response as ${user_id} + And the response should match json: + """ + { + "id": "${user_id}", + "full_name": "Test Bob", + "picture": "", + "username": "${response.username}" + } + """ + + # Bob asks for his cert to be signed. + Given I generate a new CSR and key as ${csr_pem}/${cert_key} using: + """ + Subject: + CommonName: "test" + Country: ["US"] + Organization: ["Example Inc."] + """ + + # He has to be logged in with a device token so that the cert can be associated with the device + When I POST path "/api/ca/sign" with json body: + """ + { + "request": "${csr_pem | json_escape}", + "usages": ["signing", "key encipherment", "server auth", "client auth"] + } + """ + Then the response code should be 403 + And the response should match json: + """ + {"error":"a device token is required"} + """ + + + # Create a site... + Given I generate a new key pair as ${private_key}/${public_key} + When I POST path "/api/sites" with json body: + """ + { + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "platform": "kubernetes" + } + """ + Then the response code should be 201 + Given I store the ".id" selection from the response as ${site_id} + And the response should match json: + """ + { + "id": "${site_id}", + "revision": ${response.revision}, + "hostname": "", + "os": "", + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "link_secret": "", + "bearer_token": "${response.bearer_token}", + "platform": "kubernetes" + } + """ + Given I store the ".bearer_token" selection from the response as ${site_bearer_token} + And I decrypt the sealed "${site_bearer_token}" with "${private_key}" and store the result as ${site_bearer_token} + + # Try to sign the CSR again with the site bearer token + When I set the "Authorization" header to "Bearer ${site_bearer_token}" + And I POST path "/api/ca/sign" with json body: + """ + { + "request": "${csr_pem | json_escape}", + "usages": ["signing", "key encipherment", "server auth", "client auth"] + } + """ + + Then the response code should be 200 + Given I store the ${response.certificate | parse_x509_cert} as ${cert} + Given I store the ${response.ca | parse_x509_cert} as ${ca} + + Then "${cert.Subject.Country.0}" should match "US" + Then "${cert.Subject.Organization.0}" should match "Example Inc." + Then "${cert.Subject.CommonName}" should match "test" + + # the CA will set the URIs of cert to identify which site created the cert + Then "${cert.URIs.0 | string}" should match "spiffe://api.try.nexodus.127.0.0.1.nip.io/o/${user_id}/v/${user_id}/s/${site_id}" + + # the CA cert is per VPC.. + Then "${ca.URIs.0 | string}" should match "spiffe://api.try.nexodus.127.0.0.1.nip.io/o/${user_id}/v/${user_id}" diff --git a/integration-tests/features/feature-flags-api.feature b/integration-tests/features/feature-flags-api.feature index beb3484ff..4a47f951a 100644 --- a/integration-tests/features/feature-flags-api.feature +++ b/integration-tests/features/feature-flags-api.feature @@ -10,8 +10,11 @@ Feature: Feature Flags API And the response should match json: """ { + "ca-api": true, "multi-organization": true, - "security-groups": true + "security-groups": true, + "devices-api": true, + "sites-api": true } """ diff --git a/integration-tests/features/site-api.feature b/integration-tests/features/site-api.feature new file mode 100644 index 000000000..b77d12cc8 --- /dev/null +++ b/integration-tests/features/site-api.feature @@ -0,0 +1,341 @@ +Feature: Site API + + Background: + Given a user named "Bob" with password "testpass" + Given a user named "EvilAlice" with password "testpass" + Given a user named "Oliver" with password "testpass" + Given a user named "Oscar" with password "testpass" + + Scenario: Basic site CRUD operations. + + Given I am logged in as "Bob" + + When I GET path "/api/users/me" + Then the response code should be 200 + Given I store the ".id" selection from the response as ${user_id} + + # Initial site listing should be empty. + When I GET path "/api/sites" + Then the response code should be 200 + And the response should match json: + """ + [] + """ + + # Bob creates a site + Given I generate a new key pair as ${private_key}/${public_key} + When I POST path "/api/sites" with json body: + """ + { + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "platform": "kubernetes" + } + """ + Then the response code should be 201 + Given I store the ".id" selection from the response as ${site_id} + And the response should match json: + """ + { + "id": "${site_id}", + "revision": ${response.revision}, + "hostname": "", + "os": "", + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "link_secret": "", + "bearer_token": "${response.bearer_token}", + "platform": "kubernetes" + } + """ + + # Bob can update his site. + When I PATCH path "/api/sites/${site_id}" with json body: + """ + { + "hostname": "kittenhome" + } + """ + Then the response code should be 200 + Given I store the ${response} as ${site1} + And the response should match json: + """ + { + "id": "${site_id}", + "revision": ${response.revision}, + "hostname": "kittenhome", + "os": "", + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "link_secret": "", + "bearer_token": "${response.bearer_token}", + "platform": "kubernetes" + } + """ + + # Bob gets an should see 1 site in the site listing.. + When I GET path "/api/sites" + Then the response code should be 200 + And the response should match json: + """ + [{ + "id": "${site_id}", + "revision": ${response[0].revision}, + "hostname": "kittenhome", + "os": "", + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "link_secret": "", + "bearer_token": "${response[0].bearer_token}", + "platform": "kubernetes" + }] + """ + + # + # Verify EvilAlice can't see Bob's stuff + # + Given I am logged in as "EvilAlice" + + # EvilAlice gets an empty list of sites.. + When I GET path "/api/sites" + Then the response code should be 200 + And the response should match json: + """ + [] + """ + + When I GET path "/api/sites/${site_id}" + Then the response code should be 404 + + When I PATCH path "/api/sites/${site_id}" with json body: + """ + { + "hostname": "evilkitten" + } + """ + Then the response code should be 404 + + When I DELETE path "/api/sites/${site_id}" + Then the response code should be 404 + And the response should match json: + """ + { + "error": "not found", + "resource": "site" + } + """ + + # + # Switch back to Bob, and make sure he can delete his site. + # + Given I am logged in as "Bob" + When I DELETE path "/api/sites/${site_id}" + Then the response code should be 200 + And the response should match json: + """ + { + "id": "${site_id}", + "revision": ${response.revision}, + "hostname": "kittenhome", + "os": "", + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "", + "name": "site-a", + "link_secret": "", + "platform": "kubernetes" + } + """ + + # We should be able to create a new site with the same public key again. + When I POST path "/api/sites" with json body: + """ + { + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "platform": "kubernetes" + } + """ + Then the response code should be 201 + Given I store the ".id" selection from the response as ${site_id} + And the response should match json: + """ + { + "id": "${site_id}", + "revision": ${response.revision}, + "hostname": "", + "os": "", + "owner_id": "${user_id}", + "vpc_id": "${user_id}", + "public_key": "${public_key}", + "name": "site-a", + "link_secret": "", + "bearer_token": "${response.bearer_token}", + "platform": "kubernetes" + } + """ + Given I store the ".bearer_token" selection from the response as ${site_bearer_token} + And I decrypt the sealed "${site_bearer_token}" with "${private_key}" and store the result as ${site_bearer_token} + And I set the "Authorization" header to "Bearer ${site_bearer_token}" + + + Scenario: Using the events endpoint to stream site change events + + Given I am logged in as "Oliver" + When I GET path "/api/users/me" + Then the response code should be 200 + Given I store the ".id" selection from the response as ${oliver_user_id} + + Given I am logged in as "Oscar" + When I GET path "/api/users/me" + Then the response code should be 200 + Given I store the ".id" selection from the response as ${oscar_user_id} + + When I POST path "/api/invitations" with json body: + """ + { + "user_id": "${oliver_user_id}", + "organization_id": "${oscar_user_id}" + } + """ + Then the response code should be 201 + Given I store the ".id" selection from the response as ${invitation_id} + + Given I am logged in as "Oliver" + When I POST path "/api/invitations/${invitation_id}/accept" + Then the response code should be 204 + And the response should match "" + + # Subscribe to the event stream + Given I am logged in as "Oliver" + When I POST path "/api/vpcs/${oscar_user_id}/events" with json body expecting a json event stream: + """ + [ + { + "kind": "site", + "gt_revision": 0 + } + ] + """ + Then the response code should be 200 + And the response header "Content-Type" should match "application/json;stream=watch" + + Given I wait up to "3" seconds for a response event + Then the response should match json: + """ + { + "kind": "site", + "type": "tail" + } + """ + + # Create a site... + Given I am logged in as "Oscar" + Given I generate a new public key as ${public_key} + When I POST path "/api/sites" with json body: + """ + { + "owner_id": "${oscar_user_id}", + "vpc_id": "${oscar_user_id}", + "public_key": "${public_key}", + "name": "site-a", + "platform": "kubernetes" + } + """ + Then the response code should be 201 + Given I store the ${response.id} as ${site_id} + + # We should get additional change events... + Given I am logged in as "Oliver" + Given I wait up to "3" seconds for a response event + Then the response should match json: + """ + { + "kind": "site", + "type": "change", + "value": { + "hostname": "", + "id": "${site_id}", + "link_secret": "", + "name": "site-a", + "os": "", + "owner_id": "${oscar_user_id}", + "platform": "kubernetes", + "public_key": "${public_key}", + "revision": ${response.value.revision}, + "vpc_id": "${oscar_user_id}" + } + } + """ + + # Update the security group + Given I am logged in as "Oscar" + When I PATCH path "/api/sites/${site_id}" with json body: + """ + { + "hostname": "test" + } + """ + Then the response code should be 200 + Given I store the ${response} as ${site} + + # We should get additional change events... + Given I am logged in as "Oliver" + Given I wait up to "3" seconds for a response event + Then the response should match json: + """ + { + "kind": "site", + "type": "change", + "value": { + "hostname": "test", + "id": "${site_id}", + "link_secret": "", + "name": "site-a", + "os": "", + "owner_id": "${oscar_user_id}", + "platform": "kubernetes", + "public_key": "${public_key}", + "revision": ${response.value.revision}, + "vpc_id": "${oscar_user_id}" + } + } + """ + + Given I am logged in as "Oscar" + When I DELETE path "/api/sites/${site_id}" + Then the response code should be 200 + Given I store the ${response} as ${site} + + Given I am logged in as "Oliver" + Given I wait up to "3" seconds for a response event + Then the response should match json: + """ + { + "kind": "site", + "type": "delete", + "value": { + "hostname": "test", + "id": "${site_id}", + "link_secret": "", + "name": "site-a", + "os": "", + "owner_id": "${oscar_user_id}", + "platform": "kubernetes", + "public_key": "", + "revision": ${response.value.revision}, + "vpc_id": "${oscar_user_id}" + } + } + """ + diff --git a/integration-tests/features/vpc-api.feature b/integration-tests/features/vpc-api.feature index 54cdf59da..44ff2497f 100644 --- a/integration-tests/features/vpc-api.feature +++ b/integration-tests/features/vpc-api.feature @@ -27,6 +27,7 @@ Feature: Organization API "description": "default vpc", "id": "${oscar_user_id}", "organization_id": "${oscar_user_id}", + "revision": ${response.revision}, "private_cidr": false } """ @@ -72,6 +73,7 @@ Feature: Organization API "description": "extra vpc", "id": "${extra_vpc_id}", "organization_id": "${oscar_user_id}", + "revision": ${response.revision}, "private_cidr": false } """ @@ -104,6 +106,7 @@ Feature: Organization API "description": "extra vpc modified", "id": "${extra_vpc_id}", "organization_id": "${oscar_user_id}", + "revision": ${response.revision}, "private_cidr": false } """ diff --git a/internal/api/public/.openapi-generator/FILES b/internal/api/public/.openapi-generator/FILES index ca1b7b8f4..50bc6900b 100644 --- a/internal/api/public/.openapi-generator/FILES +++ b/internal/api/public/.openapi-generator/FILES @@ -1,10 +1,12 @@ api_auth.go +api_ca.go api_devices.go api_f_flag.go api_invitation.go api_organizations.go api_reg_key.go api_security_group.go +api_sites.go api_users.go api_vpc.go client.go @@ -14,8 +16,11 @@ model_models_add_invitation.go model_models_add_organization.go model_models_add_reg_key.go model_models_add_security_group.go +model_models_add_site.go model_models_add_vpc.go model_models_base_error.go +model_models_certificate_signing_request.go +model_models_certificate_signing_response.go model_models_conflicts_error.go model_models_device.go model_models_device_metadata.go @@ -23,6 +28,7 @@ model_models_device_start_response.go model_models_endpoint.go model_models_internal_server_error.go model_models_invitation.go +model_models_key_usage.go model_models_login_end_request.go model_models_login_end_response.go model_models_login_start_response.go @@ -34,10 +40,12 @@ model_models_refresh_token_response.go model_models_reg_key.go model_models_security_group.go model_models_security_rule.go +model_models_site.go model_models_tunnel_ip.go model_models_update_device.go model_models_update_reg_key.go model_models_update_security_group.go +model_models_update_site.go model_models_update_vpc.go model_models_user.go model_models_user_info_response.go diff --git a/internal/api/public/api_ca.go b/internal/api/public/api_ca.go new file mode 100644 index 000000000..8d6423a7b --- /dev/null +++ b/internal/api/public/api_ca.go @@ -0,0 +1,155 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" +) + +// CAApiService CAApi service +type CAApiService service + +type ApiSignCSRRequest struct { + ctx context.Context + ApiService *CAApiService + certificateSigningRequest *ModelsCertificateSigningRequest +} + +// Certificate signing request +func (r ApiSignCSRRequest) CertificateSigningRequest(certificateSigningRequest ModelsCertificateSigningRequest) ApiSignCSRRequest { + r.certificateSigningRequest = &certificateSigningRequest + return r +} + +func (r ApiSignCSRRequest) Execute() (*ModelsCertificateSigningResponse, *http.Response, error) { + return r.ApiService.SignCSRExecute(r) +} + +/* +SignCSR Signs a certificate signing request + +Signs a certificate signing request + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiSignCSRRequest +*/ +func (a *CAApiService) SignCSR(ctx context.Context) ApiSignCSRRequest { + return ApiSignCSRRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return ModelsCertificateSigningResponse +func (a *CAApiService) SignCSRExecute(r ApiSignCSRRequest) (*ModelsCertificateSigningResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ModelsCertificateSigningResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "CAApiService.SignCSR") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/ca/sign" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.certificateSigningRequest == nil { + return localVarReturnValue, nil, reportError("certificateSigningRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.certificateSigningRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ModelsValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/internal/api/public/api_sites.go b/internal/api/public/api_sites.go new file mode 100644 index 000000000..b054fb4ad --- /dev/null +++ b/internal/api/public/api_sites.go @@ -0,0 +1,785 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +import ( + "bytes" + "context" + "io" + "net/http" + "net/url" + "strings" +) + +// SitesApiService SitesApi service +type SitesApiService service + +type ApiCreateSiteRequest struct { + ctx context.Context + ApiService *SitesApiService + site *ModelsAddSite +} + +// Add Site +func (r ApiCreateSiteRequest) Site(site ModelsAddSite) ApiCreateSiteRequest { + r.site = &site + return r +} + +func (r ApiCreateSiteRequest) Execute() (*ModelsSite, *http.Response, error) { + return r.ApiService.CreateSiteExecute(r) +} + +/* +CreateSite Add Sites + +Adds a new site + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiCreateSiteRequest +*/ +func (a *SitesApiService) CreateSite(ctx context.Context) ApiCreateSiteRequest { + return ApiCreateSiteRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return ModelsSite +func (a *SitesApiService) CreateSiteExecute(r ApiCreateSiteRequest) (*ModelsSite, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ModelsSite + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SitesApiService.CreateSite") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/sites" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.site == nil { + return localVarReturnValue, nil, reportError("site is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.site + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 401 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 409 { + var v ModelsConflictsError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiDeleteSiteRequest struct { + ctx context.Context + ApiService *SitesApiService + id string +} + +func (r ApiDeleteSiteRequest) Execute() (*ModelsSite, *http.Response, error) { + return r.ApiService.DeleteSiteExecute(r) +} + +/* +DeleteSite Delete Site + +Deletes an existing site and associated IPAM lease + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id Site ID + @return ApiDeleteSiteRequest +*/ +func (a *SitesApiService) DeleteSite(ctx context.Context, id string) ApiDeleteSiteRequest { + return ApiDeleteSiteRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return ModelsSite +func (a *SitesApiService) DeleteSiteExecute(r ApiDeleteSiteRequest) (*ModelsSite, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodDelete + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ModelsSite + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SitesApiService.DeleteSite") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/sites/{id}" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiGetSiteRequest struct { + ctx context.Context + ApiService *SitesApiService + id string +} + +func (r ApiGetSiteRequest) Execute() (*ModelsSite, *http.Response, error) { + return r.ApiService.GetSiteExecute(r) +} + +/* +GetSite Get Sites + +Gets a site by ID + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id Site ID + @return ApiGetSiteRequest +*/ +func (a *SitesApiService) GetSite(ctx context.Context, id string) ApiGetSiteRequest { + return ApiGetSiteRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return ModelsSite +func (a *SitesApiService) GetSiteExecute(r ApiGetSiteRequest) (*ModelsSite, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ModelsSite + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SitesApiService.GetSite") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/sites/{id}" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 401 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiListSitesRequest struct { + ctx context.Context + ApiService *SitesApiService +} + +func (r ApiListSitesRequest) Execute() ([]ModelsSite, *http.Response, error) { + return r.ApiService.ListSitesExecute(r) +} + +/* +ListSites List Sites + +Lists all sites + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @return ApiListSitesRequest +*/ +func (a *SitesApiService) ListSites(ctx context.Context) ApiListSitesRequest { + return ApiListSitesRequest{ + ApiService: a, + ctx: ctx, + } +} + +// Execute executes the request +// +// @return []ModelsSite +func (a *SitesApiService) ListSitesExecute(r ApiListSitesRequest) ([]ModelsSite, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue []ModelsSite + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SitesApiService.ListSites") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/sites" + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 401 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + +type ApiUpdateSiteRequest struct { + ctx context.Context + ApiService *SitesApiService + id string + update *ModelsUpdateSite +} + +// Site Update +func (r ApiUpdateSiteRequest) Update(update ModelsUpdateSite) ApiUpdateSiteRequest { + r.update = &update + return r +} + +func (r ApiUpdateSiteRequest) Execute() (*ModelsSite, *http.Response, error) { + return r.ApiService.UpdateSiteExecute(r) +} + +/* +UpdateSite Update Sites + +Updates a site by ID + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id Site ID + @return ApiUpdateSiteRequest +*/ +func (a *SitesApiService) UpdateSite(ctx context.Context, id string) ApiUpdateSiteRequest { + return ApiUpdateSiteRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return ModelsSite +func (a *SitesApiService) UpdateSiteExecute(r ApiUpdateSiteRequest) (*ModelsSite, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ModelsSite + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "SitesApiService.UpdateSite") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/sites/{id}" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.update == nil { + return localVarReturnValue, nil, reportError("update is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + // body params + localVarPostBody = r.update + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 401 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 404 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/internal/api/public/api_vpc.go b/internal/api/public/api_vpc.go index 9ad6137a1..7268d6b1c 100644 --- a/internal/api/public/api_vpc.go +++ b/internal/api/public/api_vpc.go @@ -935,6 +935,163 @@ func (a *VPCApiService) ListSecurityGroupsInVPCExecute(r ApiListSecurityGroupsIn return localVarReturnValue, localVarHTTPResponse, nil } +type ApiListSitesInVPCRequest struct { + ctx context.Context + ApiService *VPCApiService + id string + gtRevision *int32 +} + +// greater than revision +func (r ApiListSitesInVPCRequest) GtRevision(gtRevision int32) ApiListSitesInVPCRequest { + r.gtRevision = >Revision + return r +} + +func (r ApiListSitesInVPCRequest) Execute() ([]ModelsSite, *http.Response, error) { + return r.ApiService.ListSitesInVPCExecute(r) +} + +/* +ListSitesInVPC List Sites + +Lists all sites for this VPC + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param id VPC ID + @return ApiListSitesInVPCRequest +*/ +func (a *VPCApiService) ListSitesInVPC(ctx context.Context, id string) ApiListSitesInVPCRequest { + return ApiListSitesInVPCRequest{ + ApiService: a, + ctx: ctx, + id: id, + } +} + +// Execute executes the request +// +// @return []ModelsSite +func (a *VPCApiService) ListSitesInVPCExecute(r ApiListSitesInVPCRequest) ([]ModelsSite, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue []ModelsSite + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "VPCApiService.ListSitesInVPC") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/api/vpcs/{id}/sites" + localVarPath = strings.Replace(localVarPath, "{"+"id"+"}", url.PathEscape(parameterValueToString(r.id, "id")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + if r.gtRevision != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "gt_revision", r.gtRevision, "") + } + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 401 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 429 { + var v ModelsBaseError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + return localVarReturnValue, localVarHTTPResponse, newErr + } + if localVarHTTPResponse.StatusCode == 500 { + var v ModelsInternalServerError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiListVPCsRequest struct { ctx context.Context ApiService *VPCApiService diff --git a/internal/api/public/api_vpc_sites_custom.go b/internal/api/public/api_vpc_sites_custom.go new file mode 100644 index 000000000..d3098f48b --- /dev/null +++ b/internal/api/public/api_vpc_sites_custom.go @@ -0,0 +1,39 @@ +package public + +import ( + "github.com/nexodus-io/nexodus/internal/util" +) + +// Informer creates a *ApiListSitesInOrganizationInformer which provides a simpler +// API to list sites but which is implemented with the Watch api. The *ApiListSitesInOrganizationInformer +// maintains a local site cache which gets updated with the Watch events. +func (r ApiListSitesInVPCRequest) Informer() *Informer[ModelsSite] { + informer := NewInformer[ModelsSite](&SiteAdaptor{}, r.gtRevision, ApiWatchEventsRequest{ + ctx: r.ctx, + ApiService: r.ApiService.client.VPCApi, + id: r.id, + }) + return informer +} + +type SiteAdaptor struct{} + +func (d SiteAdaptor) Revision(item ModelsSite) int32 { + return item.Revision +} + +func (d SiteAdaptor) Key(item ModelsSite) string { + return item.Id +} + +func (d SiteAdaptor) Kind() string { + return "site" +} + +func (d SiteAdaptor) Item(value map[string]interface{}) (ModelsSite, error) { + item := ModelsSite{} + err := util.JsonUnmarshal(value, &item) + return item, err +} + +var _ InformerAdaptor[ModelsSite] = &SiteAdaptor{} diff --git a/internal/api/public/client.go b/internal/api/public/client.go index 5fb7c3394..69be8b415 100644 --- a/internal/api/public/client.go +++ b/internal/api/public/client.go @@ -52,6 +52,8 @@ type APIClient struct { AuthApi *AuthApiService + CAApi *CAApiService + DevicesApi *DevicesApiService FFlagApi *FFlagApiService @@ -64,6 +66,8 @@ type APIClient struct { SecurityGroupApi *SecurityGroupApiService + SitesApi *SitesApiService + UsersApi *UsersApiService VPCApi *VPCApiService @@ -86,12 +90,14 @@ func NewAPIClient(cfg *Configuration) *APIClient { // API Services c.AuthApi = (*AuthApiService)(&c.common) + c.CAApi = (*CAApiService)(&c.common) c.DevicesApi = (*DevicesApiService)(&c.common) c.FFlagApi = (*FFlagApiService)(&c.common) c.InvitationApi = (*InvitationApiService)(&c.common) c.OrganizationsApi = (*OrganizationsApiService)(&c.common) c.RegKeyApi = (*RegKeyApiService)(&c.common) c.SecurityGroupApi = (*SecurityGroupApiService)(&c.common) + c.SitesApi = (*SitesApiService)(&c.common) c.UsersApi = (*UsersApiService)(&c.common) c.VPCApi = (*VPCApiService)(&c.common) diff --git a/internal/api/public/model_models_add_site.go b/internal/api/public/model_models_add_site.go new file mode 100644 index 000000000..db7834c46 --- /dev/null +++ b/internal/api/public/model_models_add_site.go @@ -0,0 +1,19 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +// ModelsAddSite struct for ModelsAddSite +type ModelsAddSite struct { + Name string `json:"name,omitempty"` + Platform string `json:"platform,omitempty"` + PublicKey string `json:"public_key,omitempty"` + VpcId string `json:"vpc_id,omitempty"` +} diff --git a/internal/api/public/model_models_certificate_signing_request.go b/internal/api/public/model_models_certificate_signing_request.go new file mode 100644 index 000000000..384fb7bf1 --- /dev/null +++ b/internal/api/public/model_models_certificate_signing_request.go @@ -0,0 +1,23 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +// ModelsCertificateSigningRequest struct for ModelsCertificateSigningRequest +type ModelsCertificateSigningRequest struct { + // Requested 'duration' (i.e. lifetime) of the Certificate. Note that the issuer may choose to ignore the requested duration, just like any other requested attribute. +optional + Duration string `json:"duration,omitempty"` + // Requested basic constraints isCA value. Note that the issuer may choose to ignore the requested isCA value, just like any other requested attribute. NOTE: If the CSR in the `Request` field has a BasicConstraints extension, it must have the same isCA value as specified here. If true, this will automatically add the `cert sign` usage to the list of requested `usages`. +optional + IsCa bool `json:"is_ca,omitempty"` + // The PEM-encoded X.509 certificate signing request to be submitted to the issuer for signing. If the CSR has a BasicConstraints extension, its isCA attribute must match the `isCA` value of this CertificateRequest. If the CSR has a KeyUsage extension, its key usages must match the key usages in the `usages` field of this CertificateRequest. If the CSR has a ExtKeyUsage extension, its extended key usages must match the extended key usages in the `usages` field of this CertificateRequest. + Request string `json:"request,omitempty"` + // Requested key usages and extended key usages. NOTE: If the CSR in the `Request` field has uses the KeyUsage or ExtKeyUsage extension, these extensions must have the same values as specified here without any additional values. If unset, defaults to `digital signature` and `key encipherment`. +optional + Usages []ModelsKeyUsage `json:"usages,omitempty"` +} diff --git a/internal/api/public/model_models_certificate_signing_response.go b/internal/api/public/model_models_certificate_signing_response.go new file mode 100644 index 000000000..de0aa22be --- /dev/null +++ b/internal/api/public/model_models_certificate_signing_response.go @@ -0,0 +1,19 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +// ModelsCertificateSigningResponse struct for ModelsCertificateSigningResponse +type ModelsCertificateSigningResponse struct { + // The PEM encoded X.509 certificate of the signer, also known as the CA (Certificate Authority). This is set on a best-effort basis by different issuers. If not set, the CA is assumed to be unknown/not available. +optional + Ca string `json:"ca,omitempty"` + // The PEM encoded X.509 certificate resulting from the certificate signing request. If not set, the CertificateRequest has either not been completed or has failed. More information on failure can be found by checking the `conditions` field. +optional + Certificate string `json:"certificate,omitempty"` +} diff --git a/internal/api/public/model_models_key_usage.go b/internal/api/public/model_models_key_usage.go new file mode 100644 index 000000000..d5915e6f7 --- /dev/null +++ b/internal/api/public/model_models_key_usage.go @@ -0,0 +1,41 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +// ModelsKeyUsage the model 'ModelsKeyUsage' +type ModelsKeyUsage string + +// List of models.KeyUsage +const ( + UsageSigning ModelsKeyUsage = "signing" + UsageDigitalSignature ModelsKeyUsage = "digital signature" + UsageContentCommitment ModelsKeyUsage = "content commitment" + UsageKeyEncipherment ModelsKeyUsage = "key encipherment" + UsageKeyAgreement ModelsKeyUsage = "key agreement" + UsageDataEncipherment ModelsKeyUsage = "data encipherment" + UsageCertSign ModelsKeyUsage = "cert sign" + UsageCRLSign ModelsKeyUsage = "crl sign" + UsageEncipherOnly ModelsKeyUsage = "encipher only" + UsageDecipherOnly ModelsKeyUsage = "decipher only" + UsageAny ModelsKeyUsage = "any" + UsageServerAuth ModelsKeyUsage = "server auth" + UsageClientAuth ModelsKeyUsage = "client auth" + UsageCodeSigning ModelsKeyUsage = "code signing" + UsageEmailProtection ModelsKeyUsage = "email protection" + UsageSMIME ModelsKeyUsage = "s/mime" + UsageIPsecEndSystem ModelsKeyUsage = "ipsec end system" + UsageIPsecTunnel ModelsKeyUsage = "ipsec tunnel" + UsageIPsecUser ModelsKeyUsage = "ipsec user" + UsageTimestamping ModelsKeyUsage = "timestamping" + UsageOCSPSigning ModelsKeyUsage = "ocsp signing" + UsageMicrosoftSGC ModelsKeyUsage = "microsoft sgc" + UsageNetscapeSGC ModelsKeyUsage = "netscape sgc" +) diff --git a/internal/api/public/model_models_site.go b/internal/api/public/model_models_site.go new file mode 100644 index 000000000..2d704df99 --- /dev/null +++ b/internal/api/public/model_models_site.go @@ -0,0 +1,27 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +// ModelsSite struct for ModelsSite +type ModelsSite struct { + // the token nexd should use to reconcile Site state. + BearerToken string `json:"bearer_token,omitempty"` + Hostname string `json:"hostname,omitempty"` + Id string `json:"id,omitempty"` + LinkSecret string `json:"link_secret,omitempty"` + Name string `json:"name,omitempty"` + Os string `json:"os,omitempty"` + OwnerId string `json:"owner_id,omitempty"` + Platform string `json:"platform,omitempty"` + PublicKey string `json:"public_key,omitempty"` + Revision int32 `json:"revision,omitempty"` + VpcId string `json:"vpc_id,omitempty"` +} diff --git a/internal/api/public/model_models_update_site.go b/internal/api/public/model_models_update_site.go new file mode 100644 index 000000000..e6d691a3e --- /dev/null +++ b/internal/api/public/model_models_update_site.go @@ -0,0 +1,19 @@ +/* +Nexodus API + +This is the Nexodus API Server. + +API version: 1.0 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package public + +// ModelsUpdateSite struct for ModelsUpdateSite +type ModelsUpdateSite struct { + Hostname string `json:"hostname,omitempty"` + LinkSecret string `json:"link_secret,omitempty"` + Os string `json:"os,omitempty"` + Revision int32 `json:"revision,omitempty"` +} diff --git a/internal/api/public/model_models_vpc.go b/internal/api/public/model_models_vpc.go index 6f7a50c42..93b1e7d9a 100644 --- a/internal/api/public/model_models_vpc.go +++ b/internal/api/public/model_models_vpc.go @@ -12,10 +12,12 @@ package public // ModelsVPC struct for ModelsVPC type ModelsVPC struct { - Description string `json:"description,omitempty"` - Id string `json:"id,omitempty"` - Ipv4Cidr string `json:"ipv4_cidr,omitempty"` - Ipv6Cidr string `json:"ipv6_cidr,omitempty"` - OrganizationId string `json:"organization_id,omitempty"` - PrivateCidr bool `json:"private_cidr,omitempty"` + CaCertificates []string `json:"ca_certificates,omitempty"` + Description string `json:"description,omitempty"` + Id string `json:"id,omitempty"` + Ipv4Cidr string `json:"ipv4_cidr,omitempty"` + Ipv6Cidr string `json:"ipv6_cidr,omitempty"` + OrganizationId string `json:"organization_id,omitempty"` + PrivateCidr bool `json:"private_cidr,omitempty"` + Revision int32 `json:"revision,omitempty"` } diff --git a/internal/cucumber/certificates.go b/internal/cucumber/certificates.go new file mode 100644 index 000000000..3040422d8 --- /dev/null +++ b/internal/cucumber/certificates.go @@ -0,0 +1,61 @@ +package cucumber + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "github.com/cucumber/godog" + "github.com/ghodss/yaml" +) + +func init() { + StepModules = append(StepModules, func(ctx *godog.ScenarioContext, s *TestScenario) { + ctx.Step(`^I generate a new CSR and key as \${([^"]*)}\/\${([^"]*)} using:$`, s.iGenerateANewCSRAndKeyAsUsing) + }) + PipeFunctions["parse_x509_cert"] = parseX509Certificate +} +func (s *TestScenario) iGenerateANewCSRAndKeyAsUsing(csr, key, settings string) error { + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + + template := x509.CertificateRequest{} + err = yaml.Unmarshal([]byte(settings), &template) + if err != nil { + return fmt.Errorf("invalid x509.CertificateRequest json: %w", err) + } + + csrBytes, err := x509.CreateCertificateRequest(rand.Reader, &template, privateKey) + if err != nil { + return fmt.Errorf("CreateCertificateRequest failure: %w", err) + } + s.Variables[csr] = string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrBytes})) + s.Variables[key] = string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})) + + return nil +} + +func parseX509Certificate(value any, err error) (any, error) { + if err != nil { + return value, err + } + certData, err := ToString(value, "certificate", JsonEncoding) + if err != nil { + return value, err + } + + certDERBlock, _ := pem.Decode([]byte(certData)) + if certDERBlock == nil || certDERBlock.Type != "CERTIFICATE" { + return value, fmt.Errorf("pem block type is not CERTIFICATE") + } + certificate, err := x509.ParseCertificate(certDERBlock.Bytes) + if err != nil { + return value, fmt.Errorf("pem block type is not CERTIFICATE") + } + + return certificate, err +} diff --git a/internal/cucumber/cucumber.go b/internal/cucumber/cucumber.go index bec7d5fc6..386445c57 100644 --- a/internal/cucumber/cucumber.go +++ b/internal/cucumber/cucumber.go @@ -26,16 +26,19 @@ package cucumber import ( + "bytes" "context" "crypto/tls" "encoding/json" "fmt" jsonpatch "github.com/evanphx/json-patch" + "github.com/ghodss/yaml" "golang.org/x/oauth2" "gorm.io/gorm" "net/http" "os" "reflect" + "strconv" "strings" "sync" "testing" @@ -127,36 +130,55 @@ func (s *TestScenario) Session() *TestSession { return result } -func (s *TestScenario) JsonMustMatch(actual, expected string, expand bool) error { +type Encoding struct { + Name string + Marshal func(any) ([]byte, error) + Unmarshal func([]byte, any) error +} + +var JsonEncoding = Encoding{ + Name: "json", + Marshal: func(a any) ([]byte, error) { + return json.MarshalIndent(a, "", " ") + }, + Unmarshal: json.Unmarshal, +} +var YamlEncoding = Encoding{ + Name: "yaml", + Marshal: yaml.Marshal, + Unmarshal: yaml.Unmarshal, +} + +func (s *TestScenario) EncodingMustMatch(encoding Encoding, actual, expected string, expandExpected bool) error { var actualParsed interface{} - err := json.Unmarshal([]byte(actual), &actualParsed) + err := encoding.Unmarshal([]byte(actual), &actualParsed) if err != nil { - return fmt.Errorf("error parsing actual json: %w\njson was:\n%s", err, actual) + return fmt.Errorf("error parsing actual %s: %w\n%s was:\n%s", encoding.Name, err, encoding.Name, actual) } var expectedParsed interface{} expanded := expected - if expand { - expanded, err = s.Expand(expected, "defs", "ref") + if expandExpected { + expanded, err = s.Expand(expected) if err != nil { return err } } - // When you first set up a test step, you might not know what JSON you are expecting. + // When you first set up a test step, you might not know what data you are expecting. if strings.TrimSpace(expanded) == "" { - actual, _ := json.MarshalIndent(actualParsed, "", " ") - return fmt.Errorf("expected json not specified, actual json was:\n%s", actual) + actual, _ := encoding.Marshal(actualParsed) + return fmt.Errorf("expected %s not specified, actual %s was:\n%s", encoding.Name, encoding.Name, actual) } - if err := json.Unmarshal([]byte(expanded), &expectedParsed); err != nil { - return fmt.Errorf("error parsing expected json: %w\njson was:\n%s", err, expanded) + if err := encoding.Unmarshal([]byte(expanded), &expectedParsed); err != nil { + return fmt.Errorf("error parsing expected %s: %w\n%s was:\n%s", encoding.Name, err, encoding.Name, expanded) } if !reflect.DeepEqual(expectedParsed, actualParsed) { - expected, _ := json.MarshalIndent(expectedParsed, "", " ") - actual, _ := json.MarshalIndent(actualParsed, "", " ") + expected, _ := encoding.Marshal(expectedParsed) + actual, _ := encoding.Marshal(actualParsed) diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ A: difflib.SplitLines(string(expected)), @@ -173,6 +195,14 @@ func (s *TestScenario) JsonMustMatch(actual, expected string, expand bool) error return nil } +func (s *TestScenario) JsonMustMatch(actual, expected string, expandExpected bool) error { + return s.EncodingMustMatch(JsonEncoding, actual, expected, expandExpected) +} + +func (s *TestScenario) YamlMustMatch(actual, expected string, expandExpected bool) error { + return s.EncodingMustMatch(YamlEncoding, actual, expected, expandExpected) +} + func (s *TestScenario) JsonMustContain(actual, expected string, expand bool) error { var actualParsed interface{} @@ -190,11 +220,11 @@ func (s *TestScenario) JsonMustContain(actual, expected string, expand bool) err // When you first set up a test step, you might not know what JSON you are expecting. if strings.TrimSpace(expected) == "" { - actual, _ := json.MarshalIndent(actualParsed, "", " ") + actual, _ := JsonEncoding.Marshal(actualParsed) return fmt.Errorf("expected json not specified, actual json was:\n%s", actual) } - actualIndented, err := json.MarshalIndent(actualParsed, "", " ") + actualIndented, err := JsonEncoding.Marshal(actualParsed) if err != nil { return err } @@ -208,7 +238,7 @@ func (s *TestScenario) JsonMustContain(actual, expected string, expand bool) err if err != nil { return fmt.Errorf("error parsing merged json: %w\njson was:\n%s", err, actual) } - mergedIndented, err := json.MarshalIndent(actualParsed, "", " ") + mergedIndented, err := JsonEncoding.Marshal(actualParsed) if err != nil { return err } @@ -246,12 +276,14 @@ func (s *TestScenario) Expand(value string, skippedVars ...string) (result strin } func (s *TestScenario) ResolveString(name string) (string, error) { - value, err := s.Resolve(name) if err != nil { return "", err } + return ToString(value, name, JsonEncoding) +} +func ToString(value interface{}, name string, encoding Encoding) (string, error) { switch value := value.(type) { case string: return value, nil @@ -272,7 +304,7 @@ func (s *TestScenario) ResolveString(name string) (string, error) { return "", fmt.Errorf("failed to evaluate selection: %s: %w", name, value) } - bytes, err := json.Marshal(value) + bytes, err := encoding.Marshal(value) if err != nil { return "", err } @@ -280,19 +312,28 @@ func (s *TestScenario) ResolveString(name string) (string, error) { } func (s *TestScenario) Resolve(name string) (interface{}, error) { + + pipes := strings.Split(name, "|") + for i := range pipes { + pipes[i] = strings.TrimSpace(pipes[i]) + } + name = pipes[0] + pipes = pipes[1:] + session := s.Session() if name == "response" { - return session.RespJson() + value, err := session.RespJson() + return pipeline(pipes, value, err) } else if strings.HasPrefix(name, "response.") || strings.HasPrefix(name, "response[") { selector := "." + name query, err := gojq.Parse(selector) if err != nil { - return nil, err + return pipeline(pipes, nil, err) } j, err := session.RespJson() if err != nil { - return nil, err + return pipeline(pipes, nil, err) } j = map[string]interface{}{ @@ -301,16 +342,166 @@ func (s *TestScenario) Resolve(name string) (interface{}, error) { iter := query.Run(j) if next, found := iter.Next(); found { - return next, nil + return pipeline(pipes, next, nil) } else { - return nil, fmt.Errorf("field ${%s} not found in json response:\n%s", name, string(session.RespBytes)) + return pipeline(pipes, nil, fmt.Errorf("field ${%s} not found in json response:\n%s", name, string(session.RespBytes))) } } + + parts := strings.Split(name, ".") + name = parts[0] + value, found := s.Variables[name] if !found { - return nil, fmt.Errorf("variable ${%s} not defined yet", name) + return pipeline(pipes, nil, fmt.Errorf("variable ${%s} not defined yet", name)) } - return value, nil + + if len(parts) > 1 { + + var err error + for _, part := range parts[1:] { + value, err = s.SelectChild(value, part) + if err != nil { + return pipeline(pipes, nil, err) + } + } + + return pipeline(pipes, value, nil) + + } + + return pipeline(pipes, value, nil) +} + +func (s *TestScenario) SelectChild(value any, path string) (any, error) { + v := reflect.ValueOf(value) + + // dereference pointers + for v.Kind() == reflect.Ptr { + v = v.Elem() + } + + switch v.Kind() { + case reflect.Map: + key := reflect.ValueOf(path) + if v.Type().Key() != key.Type() { + return nil, fmt.Errorf("cannot select map key %s from %s", path, v.Type()) + } + v = v.MapIndex(key) + if !v.IsValid() { + return nil, fmt.Errorf("map key %s not found", path) + } + case reflect.Slice: + index, err := strconv.Atoi(path) + if err != nil { + return nil, fmt.Errorf("cannot select slice index %s from %s", path, v.Type()) + } + if index < 0 || index >= v.Len() { + return nil, fmt.Errorf("slice index %s out of range", path) + } + v = v.Index(index) + case reflect.Struct: + f := v.FieldByName(path) + if f.IsValid() { + v = f + } else { + + m := v.MethodByName(path) + if m.IsValid() { + + // get all the arg types of the method + remainingTypes := []reflect.Type{} + for i := 0; i < m.Type().NumIn(); i++ { + remainingTypes = append(remainingTypes, m.Type().In(i)) + } + args := []reflect.Value{} + + for len(remainingTypes) > 0 { + switch remainingTypes[0] { + case reflect.TypeOf((*context.Context)(nil)).Elem(): + args = append(args, reflect.ValueOf(s.Session().Ctx)) + remainingTypes = remainingTypes[1:] + case reflect.TypeOf((*testing.T)(nil)).Elem(): + args = append(args, reflect.ValueOf(s.Suite.TestingT)) + remainingTypes = remainingTypes[1:] + case reflect.TypeOf((*gorm.DB)(nil)).Elem(): + args = append(args, reflect.ValueOf(s.Suite.DB)) + remainingTypes = remainingTypes[1:] + } + } + if len(remainingTypes) > 0 { + return nil, fmt.Errorf("cannot statisfy method %s arg type: %s", path, remainingTypes[0]) + } + + result := m.Call(args) + switch m.Type().NumOut() { + case 1: + value = result[0].Interface() + return value, nil + case 2: + value = result[0].Interface() + if err, ok := result[1].Interface().(error); ok && err != nil { + return nil, err + } + return value, nil + case 0: + return nil, fmt.Errorf("method %s returns to few values", path) + default: + return nil, fmt.Errorf("method %s returns to many values", path) + } + + } + + return nil, fmt.Errorf("struct field %s not found", path) + } + + default: + return nil, fmt.Errorf("can't navigate to '%s' on type of %s", path, v.Type()) + } + return v.Interface(), nil +} + +func pipeline(pipes []string, value any, err error) (any, error) { + for _, pipe := range pipes { + fn := PipeFunctions[pipe] + if fn == nil { + return nil, fmt.Errorf("unknown pipe: %s", pipe) + } + value, err = fn(value, err) + } + return value, err +} + +var PipeFunctions = map[string]func(any, error) (any, error){ + "json": func(value any, err error) (any, error) { + if err != nil { + return value, err + } + buf := bytes.NewBuffer(nil) + encoder := json.NewEncoder(buf) + encoder.SetIndent("", " ") + err = encoder.Encode(value) + if err != nil { + return value, err + } + return buf.String(), err + }, + "json_escape": func(value any, err error) (any, error) { + if err != nil { + return value, err + } + data, err := json.Marshal(fmt.Sprintf("%v", value)) + if err != nil { + return value, err + } + return strings.TrimSuffix(strings.TrimPrefix(string(data), `"`), `"`), nil + }, + "string": func(value any, err error) (any, error) { + if err != nil { + return value, err + } + return fmt.Sprintf("%v", value), nil + }, } func contains(s []string, e string) bool { diff --git a/internal/cucumber/http_response.go b/internal/cucumber/http_response.go index 727472adb..ec598c75f 100644 --- a/internal/cucumber/http_response.go +++ b/internal/cucumber/http_response.go @@ -67,7 +67,6 @@ func init() { ctx.Step(`^the response code should be (\d+)$`, s.theResponseCodeShouldBe) ctx.Step(`^the response should match json:$`, s.TheResponseShouldMatchJsonDoc) ctx.Step(`^the response should contain json:$`, s.TheResponseShouldContainJsonDoc) - ctx.Step(`^the \${([^"]*)} should contain json:$`, s.theVariableShouldContainJson) ctx.Step(`^the response should match:$`, s.theResponseShouldMatchText) ctx.Step(`^the response should match "([^"]*)"$`, s.theResponseShouldMatchText) ctx.Step(`^I store the "([^"]*)" selection from the response as \${([^"]*)}$`, s.iStoreTheSelectionFromTheResponseAs) @@ -80,6 +79,9 @@ func init() { ctx.Step(`^the "([^"]*)" selection from the response should match json:$`, s.theSelectionFromTheResponseShouldMatchJson) ctx.Step(`^\${([^"]*)} is not empty$`, s.vpc_idIsNotEmpty) ctx.Step(`^"([^"]*)" should match "([^"]*)"$`, s.textShouldMatchText) + ctx.Step(`^\${([^"]*)} should match:$`, s.theVariableShouldMatchText) + ctx.Step(`^\${([^"]*)} should match yaml:$`, s.theVariableShouldMatchYaml) + ctx.Step(`^\${([^"]*)} should contain json:$`, s.theVariableShouldContainJson) }) } @@ -173,6 +175,40 @@ func (s *TestScenario) textShouldMatchText(actual, expected string) error { return nil } +func (s *TestScenario) theVariableShouldMatchText(variable string, expected *godog.DocString) error { + expanded, err := s.Expand(expected.Content) + if err != nil { + return err + } + + actual, err := s.ResolveString(variable) + if err != nil { + return err + } + + if expanded != actual { + diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(expanded), + B: difflib.SplitLines(actual), + FromFile: "Expected", + FromDate: "", + ToFile: "Actual", + ToDate: "", + Context: 1, + }) + return fmt.Errorf("actual does not match expected, diff:\n%s", diff) + } + return nil +} + +func (s *TestScenario) theVariableShouldMatchYaml(variable string, expected *godog.DocString) error { + actual, err := s.ResolveString(variable) + if err != nil { + return err + } + return s.YamlMustMatch(actual, expected.Content, true) +} + func (s *TestScenario) theResponseShouldMatchText(expected string) error { session := s.Session() diff --git a/internal/database/database.go b/internal/database/database.go index 654e75268..2140db719 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -13,6 +13,7 @@ import ( _ "github.com/nexodus-io/nexodus/internal/database/migration_20231120_0000" _ "github.com/nexodus-io/nexodus/internal/database/migration_20231130_0000" _ "github.com/nexodus-io/nexodus/internal/database/migration_20231206_0000" + _ "github.com/nexodus-io/nexodus/internal/database/migration_20231211_0000" "sort" "github.com/cenkalti/backoff/v4" diff --git a/internal/database/migration_20231211_0000/migration.go b/internal/database/migration_20231211_0000/migration.go new file mode 100644 index 000000000..1e3134db1 --- /dev/null +++ b/internal/database/migration_20231211_0000/migration.go @@ -0,0 +1,75 @@ +package migration_20231211_0000 + +import ( + "github.com/google/uuid" + "github.com/nexodus-io/nexodus/internal/database/migration_20231031_0000" + . "github.com/nexodus-io/nexodus/internal/database/migrations" +) + +type Site struct { + migration_20231031_0000.Base + Revision uint64 `gorm:"type:bigserial;index"` + OwnerID uuid.UUID `gorm:"type:uuid;index"` + VpcID uuid.UUID `gorm:"type:uuid;index"` + OrganizationID uuid.UUID `gorm:"type:uuid;index"` + RegKeyID uuid.UUID `gorm:"type:uuid;index"` + BearerToken string + Hostname string `gorm:"index"` + Os string + Name string + Platform string + PublicKey string + LinkSecret string +} + +type VPC struct { + CaKey string + CaCertificates []string `gorm:"type:JSONB; serializer:json"` + Revision uint64 `gorm:"type:bigserial;index:"` +} + +func init() { + migrationId := "20231211-0000" + CreateMigrationFromActions(migrationId, + CreateTableAction(&Site{}), + ExecActionIf(` + CREATE OR REPLACE FUNCTION sites_revision_trigger() RETURNS TRIGGER LANGUAGE plpgsql AS ' + BEGIN + NEW.revision := nextval(''sites_revision_seq''); + RETURN NEW; + END;' + `, ` + DROP FUNCTION IF EXISTS sites_revision_trigger + `, NotOnSqlLite), + ExecActionIf(` + CREATE OR REPLACE TRIGGER sites_revision_trigger BEFORE INSERT OR UPDATE ON sites + FOR EACH ROW EXECUTE PROCEDURE sites_revision_trigger(); + `, ` + DROP TRIGGER IF EXISTS sites_revision_trigger ON sites + `, NotOnSqlLite), + ExecAction( + `CREATE UNIQUE INDEX IF NOT EXISTS "idx_sites_public_key" ON "sites" ("public_key")`, + `DROP INDEX IF EXISTS idx_sites_public_key`, + ), + ExecAction( + `CREATE UNIQUE INDEX IF NOT EXISTS "idx_sites_bearer_token" ON "sites" ("bearer_token")`, + `DROP INDEX IF EXISTS idx_sites_bearer_token`, + ), + AddTableColumnsAction(&VPC{}), + ExecActionIf(` + CREATE OR REPLACE FUNCTION vpcs_revision_trigger() RETURNS TRIGGER LANGUAGE plpgsql AS ' + BEGIN + NEW.revision := nextval(''vpcs_revision_seq''); + RETURN NEW; + END;' + `, ` + DROP FUNCTION IF EXISTS vpcs_revision_trigger + `, NotOnSqlLite), + ExecActionIf(` + CREATE OR REPLACE TRIGGER vpcs_revision_trigger BEFORE INSERT OR UPDATE ON vpcs + FOR EACH ROW EXECUTE PROCEDURE vpcs_revision_trigger(); + `, ` + DROP TRIGGER IF EXISTS vpcs_revision_trigger ON vpcs + `, NotOnSqlLite), + ) +} diff --git a/internal/docs-private/docs.go b/internal/docs-private/docs.go index e4033e5a9..07b87f9ff 100644 --- a/internal/docs-private/docs.go +++ b/internal/docs-private/docs.go @@ -154,7 +154,17 @@ const docTemplate = `{ "user": " Grants read and write access to resources owned by this user" } } - } + }, + "tags": [ + { + "description": "X509 Certificate related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "CA" + }, + { + "description": "Skupper Site related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "Sites" + } + ] }` // SwaggerInfo holds exported Swagger Info so clients can modify it diff --git a/internal/docs-private/swagger.json b/internal/docs-private/swagger.json index f305abb0f..0ab841973 100644 --- a/internal/docs-private/swagger.json +++ b/internal/docs-private/swagger.json @@ -147,5 +147,15 @@ "user": " Grants read and write access to resources owned by this user" } } - } + }, + "tags": [ + { + "description": "X509 Certificate related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "CA" + }, + { + "description": "Skupper Site related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "Sites" + } + ] } \ No newline at end of file diff --git a/internal/docs-private/swagger.yaml b/internal/docs-private/swagger.yaml index 77863ab8f..b5e3b4a6a 100644 --- a/internal/docs-private/swagger.yaml +++ b/internal/docs-private/swagger.yaml @@ -99,3 +99,10 @@ securityDefinitions: user: ' Grants read and write access to resources owned by this user' type: oauth2 swagger: "2.0" +tags: +- description: X509 Certificate related APIs, these APIs are experimental and disabled + by default. Use the feature flag apis to check if they are enabled on the server. + name: CA +- description: Skupper Site related APIs, these APIs are experimental and disabled + by default. Use the feature flag apis to check if they are enabled on the server. + name: Sites diff --git a/internal/docs/docs.go b/internal/docs/docs.go index 4797f16b1..d53375904 100644 --- a/internal/docs/docs.go +++ b/internal/docs/docs.go @@ -22,6 +22,53 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/ca/sign": { + "post": { + "description": "Signs a certificate signing request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CA" + ], + "summary": "Signs a certificate signing request", + "operationId": "SignCSR", + "parameters": [ + { + "description": "Certificate signing request", + "name": "CertificateSigningRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CertificateSigningRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.CertificateSigningResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ValidationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, "/api/devices": { "get": { "description": "Lists all devices", @@ -1713,6 +1760,296 @@ const docTemplate = `{ } } }, + "/api/sites": { + "get": { + "description": "Lists all sites", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "List Sites", + "operationId": "ListSites", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Site" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + }, + "post": { + "description": "Adds a new site", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Add Sites", + "operationId": "CreateSite", + "parameters": [ + { + "description": "Add Site", + "name": "Site", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.AddSite" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/models.ConflictsError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, + "/api/sites/{id}": { + "get": { + "description": "Gets a site by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Get Sites", + "operationId": "GetSite", + "parameters": [ + { + "type": "string", + "description": "Site ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + }, + "delete": { + "description": "Deletes an existing site and associated IPAM lease", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Delete Site", + "operationId": "DeleteSite", + "parameters": [ + { + "type": "string", + "description": "Site ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + }, + "patch": { + "description": "Updates a site by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Update Sites", + "operationId": "UpdateSite", + "parameters": [ + { + "type": "string", + "description": "Site ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Site Update", + "name": "update", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateSite" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, "/api/users": { "get": { "description": "Lists all users", @@ -2481,6 +2818,72 @@ const docTemplate = `{ } } }, + "/api/vpcs/{id}/sites": { + "get": { + "description": "Lists all sites for this VPC", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VPC" + ], + "summary": "List Sites", + "operationId": "ListSitesInVPC", + "parameters": [ + { + "type": "integer", + "description": "greater than revision", + "name": "gt_revision", + "in": "query" + }, + { + "type": "string", + "description": "VPC ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Site" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, "/check/auth": { "get": { "description": "Checks if the user is currently authenticated", @@ -2857,6 +3260,24 @@ const docTemplate = `{ } } }, + "models.AddSite": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "vpc_id": { + "type": "string", + "example": "694aa002-5d19-495e-980b-3d8fd508ea10" + } + } + }, "models.AddVPC": { "type": "object", "properties": { @@ -2889,6 +3310,46 @@ const docTemplate = `{ } } }, + "models.CertificateSigningRequest": { + "type": "object", + "properties": { + "duration": { + "description": "Requested 'duration' (i.e. lifetime) of the Certificate. Note that the\nissuer may choose to ignore the requested duration, just like any other\nrequested attribute.\n+optional", + "type": "string" + }, + "is_ca": { + "description": "Requested basic constraints isCA value. Note that the issuer may choose\nto ignore the requested isCA value, just like any other requested attribute.\n\nNOTE: If the CSR in the ` + "`" + `Request` + "`" + ` field has a BasicConstraints extension,\nit must have the same isCA value as specified here.\n\nIf true, this will automatically add the ` + "`" + `cert sign` + "`" + ` usage to the list\nof requested ` + "`" + `usages` + "`" + `.\n+optional", + "type": "boolean" + }, + "request": { + "description": "The PEM-encoded X.509 certificate signing request to be submitted to the\nissuer for signing.\n\nIf the CSR has a BasicConstraints extension, its isCA attribute must\nmatch the ` + "`" + `isCA` + "`" + ` value of this CertificateRequest.\nIf the CSR has a KeyUsage extension, its key usages must match the\nkey usages in the ` + "`" + `usages` + "`" + ` field of this CertificateRequest.\nIf the CSR has a ExtKeyUsage extension, its extended key usages\nmust match the extended key usages in the ` + "`" + `usages` + "`" + ` field of this\nCertificateRequest.", + "type": "string", + "example": "-----BEGIN CERTIFICATE REQUEST-----(...)-----END CERTIFICATE REQUEST-----" + }, + "usages": { + "description": "Requested key usages and extended key usages.\n\nNOTE: If the CSR in the ` + "`" + `Request` + "`" + ` field has uses the KeyUsage or\nExtKeyUsage extension, these extensions must have the same values\nas specified here without any additional values.\n\nIf unset, defaults to ` + "`" + `digital signature` + "`" + ` and ` + "`" + `key encipherment` + "`" + `.\n+optional", + "type": "array", + "items": { + "$ref": "#/definitions/models.KeyUsage" + } + } + } + }, + "models.CertificateSigningResponse": { + "type": "object", + "properties": { + "ca": { + "description": "The PEM encoded X.509 certificate of the signer, also known as the CA\n(Certificate Authority).\nThis is set on a best-effort basis by different issuers.\nIf not set, the CA is assumed to be unknown/not available.\n+optional", + "type": "string", + "example": "-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----" + }, + "certificate": { + "description": "The PEM encoded X.509 certificate resulting from the certificate\nsigning request.\nIf not set, the CertificateRequest has either not been completed or has\nfailed. More information on failure can be found by checking the\n` + "`" + `conditions` + "`" + ` field.\n+optional", + "type": "string", + "example": "-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----" + } + } + }, "models.ConflictsError": { "type": "object", "properties": { @@ -3068,6 +3529,59 @@ const docTemplate = `{ } } }, + "models.KeyUsage": { + "type": "string", + "enum": [ + "signing", + "digital signature", + "content commitment", + "key encipherment", + "key agreement", + "data encipherment", + "cert sign", + "crl sign", + "encipher only", + "decipher only", + "any", + "server auth", + "client auth", + "code signing", + "email protection", + "s/mime", + "ipsec end system", + "ipsec tunnel", + "ipsec user", + "timestamping", + "ocsp signing", + "microsoft sgc", + "netscape sgc" + ], + "x-enum-varnames": [ + "UsageSigning", + "UsageDigitalSignature", + "UsageContentCommitment", + "UsageKeyEncipherment", + "UsageKeyAgreement", + "UsageDataEncipherment", + "UsageCertSign", + "UsageCRLSign", + "UsageEncipherOnly", + "UsageDecipherOnly", + "UsageAny", + "UsageServerAuth", + "UsageClientAuth", + "UsageCodeSigning", + "UsageEmailProtection", + "UsageSMIME", + "UsageIPsecEndSystem", + "UsageIPsecTunnel", + "UsageIPsecUser", + "UsageTimestamping", + "UsageOCSPSigning", + "UsageMicrosoftSGC", + "UsageNetscapeSGC" + ] + }, "models.LoginEndRequest": { "type": "object", "properties": { @@ -3253,6 +3767,48 @@ const docTemplate = `{ } } }, + "models.Site": { + "type": "object", + "properties": { + "bearer_token": { + "description": "the token nexd should use to reconcile Site state.", + "type": "string" + }, + "hostname": { + "type": "string", + "example": "myhost" + }, + "id": { + "type": "string", + "example": "aa22666c-0f57-45cb-a449-16efecc04f2e" + }, + "link_secret": { + "type": "string" + }, + "name": { + "type": "string" + }, + "os": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "vpc_id": { + "type": "string", + "example": "694aa002-5d19-495e-980b-3d8fd508ea10" + } + } + }, "models.TunnelIP": { "type": "object", "properties": { @@ -3347,6 +3903,24 @@ const docTemplate = `{ } } }, + "models.UpdateSite": { + "type": "object", + "properties": { + "hostname": { + "type": "string", + "example": "myhost" + }, + "link_secret": { + "type": "string" + }, + "os": { + "type": "string" + }, + "revision": { + "type": "integer" + } + } + }, "models.UpdateVPC": { "type": "object", "properties": { @@ -3406,6 +3980,12 @@ const docTemplate = `{ "models.VPC": { "type": "object", "properties": { + "ca_certificates": { + "type": "array", + "items": { + "type": "string" + } + }, "description": { "type": "string" }, @@ -3424,6 +4004,9 @@ const docTemplate = `{ }, "private_cidr": { "type": "boolean" + }, + "revision": { + "type": "integer" } } }, @@ -3480,7 +4063,17 @@ const docTemplate = `{ "user": " Grants read and write access to resources owned by this user" } } - } + }, + "tags": [ + { + "description": "X509 Certificate related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "CA" + }, + { + "description": "Skupper Site related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "Sites" + } + ] }` // SwaggerInfo holds exported Swagger Info so clients can modify it diff --git a/internal/docs/swagger.json b/internal/docs/swagger.json index f65126a1a..22c36609f 100644 --- a/internal/docs/swagger.json +++ b/internal/docs/swagger.json @@ -15,6 +15,53 @@ }, "basePath": "/", "paths": { + "/api/ca/sign": { + "post": { + "description": "Signs a certificate signing request", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "CA" + ], + "summary": "Signs a certificate signing request", + "operationId": "SignCSR", + "parameters": [ + { + "description": "Certificate signing request", + "name": "CertificateSigningRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.CertificateSigningRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.CertificateSigningResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.ValidationError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, "/api/devices": { "get": { "description": "Lists all devices", @@ -1706,6 +1753,296 @@ } } }, + "/api/sites": { + "get": { + "description": "Lists all sites", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "List Sites", + "operationId": "ListSites", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Site" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + }, + "post": { + "description": "Adds a new site", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Add Sites", + "operationId": "CreateSite", + "parameters": [ + { + "description": "Add Site", + "name": "Site", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.AddSite" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "409": { + "description": "Conflict", + "schema": { + "$ref": "#/definitions/models.ConflictsError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, + "/api/sites/{id}": { + "get": { + "description": "Gets a site by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Get Sites", + "operationId": "GetSite", + "parameters": [ + { + "type": "string", + "description": "Site ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + }, + "delete": { + "description": "Deletes an existing site and associated IPAM lease", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Delete Site", + "operationId": "DeleteSite", + "parameters": [ + { + "type": "string", + "description": "Site ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + }, + "patch": { + "description": "Updates a site by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Sites" + ], + "summary": "Update Sites", + "operationId": "UpdateSite", + "parameters": [ + { + "type": "string", + "description": "Site ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Site Update", + "name": "update", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/models.UpdateSite" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Site" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, "/api/users": { "get": { "description": "Lists all users", @@ -2474,6 +2811,72 @@ } } }, + "/api/vpcs/{id}/sites": { + "get": { + "description": "Lists all sites for this VPC", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "VPC" + ], + "summary": "List Sites", + "operationId": "ListSitesInVPC", + "parameters": [ + { + "type": "integer", + "description": "greater than revision", + "name": "gt_revision", + "in": "query" + }, + { + "type": "string", + "description": "VPC ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Site" + } + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "429": { + "description": "Too Many Requests", + "schema": { + "$ref": "#/definitions/models.BaseError" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/models.InternalServerError" + } + } + } + } + }, "/check/auth": { "get": { "description": "Checks if the user is currently authenticated", @@ -2850,6 +3253,24 @@ } } }, + "models.AddSite": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "vpc_id": { + "type": "string", + "example": "694aa002-5d19-495e-980b-3d8fd508ea10" + } + } + }, "models.AddVPC": { "type": "object", "properties": { @@ -2882,6 +3303,46 @@ } } }, + "models.CertificateSigningRequest": { + "type": "object", + "properties": { + "duration": { + "description": "Requested 'duration' (i.e. lifetime) of the Certificate. Note that the\nissuer may choose to ignore the requested duration, just like any other\nrequested attribute.\n+optional", + "type": "string" + }, + "is_ca": { + "description": "Requested basic constraints isCA value. Note that the issuer may choose\nto ignore the requested isCA value, just like any other requested attribute.\n\nNOTE: If the CSR in the `Request` field has a BasicConstraints extension,\nit must have the same isCA value as specified here.\n\nIf true, this will automatically add the `cert sign` usage to the list\nof requested `usages`.\n+optional", + "type": "boolean" + }, + "request": { + "description": "The PEM-encoded X.509 certificate signing request to be submitted to the\nissuer for signing.\n\nIf the CSR has a BasicConstraints extension, its isCA attribute must\nmatch the `isCA` value of this CertificateRequest.\nIf the CSR has a KeyUsage extension, its key usages must match the\nkey usages in the `usages` field of this CertificateRequest.\nIf the CSR has a ExtKeyUsage extension, its extended key usages\nmust match the extended key usages in the `usages` field of this\nCertificateRequest.", + "type": "string", + "example": "-----BEGIN CERTIFICATE REQUEST-----(...)-----END CERTIFICATE REQUEST-----" + }, + "usages": { + "description": "Requested key usages and extended key usages.\n\nNOTE: If the CSR in the `Request` field has uses the KeyUsage or\nExtKeyUsage extension, these extensions must have the same values\nas specified here without any additional values.\n\nIf unset, defaults to `digital signature` and `key encipherment`.\n+optional", + "type": "array", + "items": { + "$ref": "#/definitions/models.KeyUsage" + } + } + } + }, + "models.CertificateSigningResponse": { + "type": "object", + "properties": { + "ca": { + "description": "The PEM encoded X.509 certificate of the signer, also known as the CA\n(Certificate Authority).\nThis is set on a best-effort basis by different issuers.\nIf not set, the CA is assumed to be unknown/not available.\n+optional", + "type": "string", + "example": "-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----" + }, + "certificate": { + "description": "The PEM encoded X.509 certificate resulting from the certificate\nsigning request.\nIf not set, the CertificateRequest has either not been completed or has\nfailed. More information on failure can be found by checking the\n`conditions` field.\n+optional", + "type": "string", + "example": "-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----" + } + } + }, "models.ConflictsError": { "type": "object", "properties": { @@ -3061,6 +3522,59 @@ } } }, + "models.KeyUsage": { + "type": "string", + "enum": [ + "signing", + "digital signature", + "content commitment", + "key encipherment", + "key agreement", + "data encipherment", + "cert sign", + "crl sign", + "encipher only", + "decipher only", + "any", + "server auth", + "client auth", + "code signing", + "email protection", + "s/mime", + "ipsec end system", + "ipsec tunnel", + "ipsec user", + "timestamping", + "ocsp signing", + "microsoft sgc", + "netscape sgc" + ], + "x-enum-varnames": [ + "UsageSigning", + "UsageDigitalSignature", + "UsageContentCommitment", + "UsageKeyEncipherment", + "UsageKeyAgreement", + "UsageDataEncipherment", + "UsageCertSign", + "UsageCRLSign", + "UsageEncipherOnly", + "UsageDecipherOnly", + "UsageAny", + "UsageServerAuth", + "UsageClientAuth", + "UsageCodeSigning", + "UsageEmailProtection", + "UsageSMIME", + "UsageIPsecEndSystem", + "UsageIPsecTunnel", + "UsageIPsecUser", + "UsageTimestamping", + "UsageOCSPSigning", + "UsageMicrosoftSGC", + "UsageNetscapeSGC" + ] + }, "models.LoginEndRequest": { "type": "object", "properties": { @@ -3246,6 +3760,48 @@ } } }, + "models.Site": { + "type": "object", + "properties": { + "bearer_token": { + "description": "the token nexd should use to reconcile Site state.", + "type": "string" + }, + "hostname": { + "type": "string", + "example": "myhost" + }, + "id": { + "type": "string", + "example": "aa22666c-0f57-45cb-a449-16efecc04f2e" + }, + "link_secret": { + "type": "string" + }, + "name": { + "type": "string" + }, + "os": { + "type": "string" + }, + "owner_id": { + "type": "string" + }, + "platform": { + "type": "string" + }, + "public_key": { + "type": "string" + }, + "revision": { + "type": "integer" + }, + "vpc_id": { + "type": "string", + "example": "694aa002-5d19-495e-980b-3d8fd508ea10" + } + } + }, "models.TunnelIP": { "type": "object", "properties": { @@ -3340,6 +3896,24 @@ } } }, + "models.UpdateSite": { + "type": "object", + "properties": { + "hostname": { + "type": "string", + "example": "myhost" + }, + "link_secret": { + "type": "string" + }, + "os": { + "type": "string" + }, + "revision": { + "type": "integer" + } + } + }, "models.UpdateVPC": { "type": "object", "properties": { @@ -3399,6 +3973,12 @@ "models.VPC": { "type": "object", "properties": { + "ca_certificates": { + "type": "array", + "items": { + "type": "string" + } + }, "description": { "type": "string" }, @@ -3417,6 +3997,9 @@ }, "private_cidr": { "type": "boolean" + }, + "revision": { + "type": "integer" } } }, @@ -3473,5 +4056,15 @@ "user": " Grants read and write access to resources owned by this user" } } - } + }, + "tags": [ + { + "description": "X509 Certificate related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "CA" + }, + { + "description": "Skupper Site related APIs, these APIs are experimental and disabled by default. Use the feature flag apis to check if they are enabled on the server.", + "name": "Sites" + } + ] } \ No newline at end of file diff --git a/internal/docs/swagger.yaml b/internal/docs/swagger.yaml index 29346538c..225b68815 100644 --- a/internal/docs/swagger.yaml +++ b/internal/docs/swagger.yaml @@ -94,6 +94,18 @@ definitions: vpc_id: type: string type: object + models.AddSite: + properties: + name: + type: string + platform: + type: string + public_key: + type: string + vpc_id: + example: 694aa002-5d19-495e-980b-3d8fd508ea10 + type: string + type: object models.AddVPC: properties: description: @@ -116,6 +128,77 @@ definitions: example: something bad type: string type: object + models.CertificateSigningRequest: + properties: + duration: + description: |- + Requested 'duration' (i.e. lifetime) of the Certificate. Note that the + issuer may choose to ignore the requested duration, just like any other + requested attribute. + +optional + type: string + is_ca: + description: |- + Requested basic constraints isCA value. Note that the issuer may choose + to ignore the requested isCA value, just like any other requested attribute. + + NOTE: If the CSR in the `Request` field has a BasicConstraints extension, + it must have the same isCA value as specified here. + + If true, this will automatically add the `cert sign` usage to the list + of requested `usages`. + +optional + type: boolean + request: + description: |- + The PEM-encoded X.509 certificate signing request to be submitted to the + issuer for signing. + + If the CSR has a BasicConstraints extension, its isCA attribute must + match the `isCA` value of this CertificateRequest. + If the CSR has a KeyUsage extension, its key usages must match the + key usages in the `usages` field of this CertificateRequest. + If the CSR has a ExtKeyUsage extension, its extended key usages + must match the extended key usages in the `usages` field of this + CertificateRequest. + example: '-----BEGIN CERTIFICATE REQUEST-----(...)-----END CERTIFICATE REQUEST-----' + type: string + usages: + description: |- + Requested key usages and extended key usages. + + NOTE: If the CSR in the `Request` field has uses the KeyUsage or + ExtKeyUsage extension, these extensions must have the same values + as specified here without any additional values. + + If unset, defaults to `digital signature` and `key encipherment`. + +optional + items: + $ref: '#/definitions/models.KeyUsage' + type: array + type: object + models.CertificateSigningResponse: + properties: + ca: + description: |- + The PEM encoded X.509 certificate of the signer, also known as the CA + (Certificate Authority). + This is set on a best-effort basis by different issuers. + If not set, the CA is assumed to be unknown/not available. + +optional + example: '-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----' + type: string + certificate: + description: |- + The PEM encoded X.509 certificate resulting from the certificate + signing request. + If not set, the CertificateRequest has either not been completed or has + failed. More information on failure can be found by checking the + `conditions` field. + +optional + example: '-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----' + type: string + type: object models.ConflictsError: properties: error: @@ -242,6 +325,56 @@ definitions: user_id: type: string type: object + models.KeyUsage: + enum: + - signing + - digital signature + - content commitment + - key encipherment + - key agreement + - data encipherment + - cert sign + - crl sign + - encipher only + - decipher only + - any + - server auth + - client auth + - code signing + - email protection + - s/mime + - ipsec end system + - ipsec tunnel + - ipsec user + - timestamping + - ocsp signing + - microsoft sgc + - netscape sgc + type: string + x-enum-varnames: + - UsageSigning + - UsageDigitalSignature + - UsageContentCommitment + - UsageKeyEncipherment + - UsageKeyAgreement + - UsageDataEncipherment + - UsageCertSign + - UsageCRLSign + - UsageEncipherOnly + - UsageDecipherOnly + - UsageAny + - UsageServerAuth + - UsageClientAuth + - UsageCodeSigning + - UsageEmailProtection + - UsageSMIME + - UsageIPsecEndSystem + - UsageIPsecTunnel + - UsageIPsecUser + - UsageTimestamping + - UsageOCSPSigning + - UsageMicrosoftSGC + - UsageNetscapeSGC models.LoginEndRequest: properties: request_url: @@ -370,6 +503,35 @@ definitions: to_port: type: integer type: object + models.Site: + properties: + bearer_token: + description: the token nexd should use to reconcile Site state. + type: string + hostname: + example: myhost + type: string + id: + example: aa22666c-0f57-45cb-a449-16efecc04f2e + type: string + link_secret: + type: string + name: + type: string + os: + type: string + owner_id: + type: string + platform: + type: string + public_key: + type: string + revision: + type: integer + vpc_id: + example: 694aa002-5d19-495e-980b-3d8fd508ea10 + type: string + type: object models.TunnelIP: properties: address: @@ -437,6 +599,18 @@ definitions: $ref: '#/definitions/models.SecurityRule' type: array type: object + models.UpdateSite: + properties: + hostname: + example: myhost + type: string + link_secret: + type: string + os: + type: string + revision: + type: integer + type: object models.UpdateVPC: properties: description: @@ -476,6 +650,10 @@ definitions: type: object models.VPC: properties: + ca_certificates: + items: + type: string + type: array description: type: string id: @@ -489,6 +667,8 @@ definitions: type: string private_cidr: type: boolean + revision: + type: integer type: object models.ValidationError: properties: @@ -529,6 +709,37 @@ info: title: Nexodus API version: "1.0" paths: + /api/ca/sign: + post: + consumes: + - application/json + description: Signs a certificate signing request + operationId: SignCSR + parameters: + - description: Certificate signing request + in: body + name: CertificateSigningRequest + required: true + schema: + $ref: '#/definitions/models.CertificateSigningRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.CertificateSigningResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.ValidationError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: Signs a certificate signing request + tags: + - CA /api/devices: get: consumes: @@ -1660,6 +1871,200 @@ paths: summary: Update Security Group tags: - SecurityGroup + /api/sites: + get: + consumes: + - application/json + description: Lists all sites + operationId: ListSites + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Site' + type: array + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.BaseError' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/models.BaseError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: List Sites + tags: + - Sites + post: + consumes: + - application/json + description: Adds a new site + operationId: CreateSite + parameters: + - description: Add Site + in: body + name: Site + required: true + schema: + $ref: '#/definitions/models.AddSite' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Site' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.BaseError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.BaseError' + "409": + description: Conflict + schema: + $ref: '#/definitions/models.ConflictsError' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/models.BaseError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: Add Sites + tags: + - Sites + /api/sites/{id}: + delete: + consumes: + - application/json + description: Deletes an existing site and associated IPAM lease + operationId: DeleteSite + parameters: + - description: Site ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + schema: + $ref: '#/definitions/models.Site' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.BaseError' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/models.BaseError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: Delete Site + tags: + - Sites + get: + consumes: + - application/json + description: Gets a site by ID + operationId: GetSite + parameters: + - description: Site ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Site' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.BaseError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.BaseError' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.BaseError' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/models.BaseError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: Get Sites + tags: + - Sites + patch: + consumes: + - application/json + description: Updates a site by ID + operationId: UpdateSite + parameters: + - description: Site ID + in: path + name: id + required: true + type: string + - description: Site Update + in: body + name: update + required: true + schema: + $ref: '#/definitions/models.UpdateSite' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Site' + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.BaseError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.BaseError' + "404": + description: Not Found + schema: + $ref: '#/definitions/models.BaseError' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/models.BaseError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: Update Sites + tags: + - Sites /api/users: get: consumes: @@ -2175,6 +2580,50 @@ paths: summary: List Security Groups in a VPC tags: - VPC + /api/vpcs/{id}/sites: + get: + consumes: + - application/json + description: Lists all sites for this VPC + operationId: ListSitesInVPC + parameters: + - description: greater than revision + in: query + name: gt_revision + type: integer + - description: VPC ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/models.Site' + type: array + "400": + description: Bad Request + schema: + $ref: '#/definitions/models.BaseError' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/models.BaseError' + "429": + description: Too Many Requests + schema: + $ref: '#/definitions/models.BaseError' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/models.InternalServerError' + summary: List Sites + tags: + - VPC /check/auth: get: consumes: @@ -2348,3 +2797,10 @@ securityDefinitions: user: ' Grants read and write access to resources owned by this user' type: oauth2 swagger: "2.0" +tags: +- description: X509 Certificate related APIs, these APIs are experimental and disabled + by default. Use the feature flag apis to check if they are enabled on the server. + name: CA +- description: Skupper Site related APIs, these APIs are experimental and disabled + by default. Use the feature flag apis to check if they are enabled on the server. + name: Sites diff --git a/internal/fflags/fflags.go b/internal/fflags/fflags.go index bb4016f5b..38fb337eb 100644 --- a/internal/fflags/fflags.go +++ b/internal/fflags/fflags.go @@ -16,41 +16,43 @@ import ( // features, for example. type FFlags struct { logger *zap.SugaredLogger -} - -type FFlag struct { - env string - defaultValue bool -} - -var hardCodedFlags = map[string]FFlag{ - "multi-organization": {"NEXAPI_FFLAG_MULTI_ORGANIZATION", true}, - "security-groups": {"NEXAPI_FFLAG_SECURITY_GROUPS", true}, + Flags map[string]func() bool } func NewFFlags(logger *zap.SugaredLogger) *FFlags { return &FFlags{ logger: logger, + Flags: map[string]func() bool{}, + } +} +func (f *FFlags) RegisterEnvFlag(name, env string, defaultValue bool) { + result := defaultValue + if envValue, err := strconv.ParseBool(os.Getenv(env)); err == nil { + result = envValue } + f.RegisterFlag(name, func() bool { + return result + }) +} + +func (f *FFlags) RegisterFlag(name string, fn func() bool) { + f.Flags[name] = fn } -func (f *FFlags) getFlagValue(c *gin.Context, name string, fflag FFlag) bool { +func (f *FFlags) getFlagValue(c *gin.Context, name string, fn func() bool) bool { ctxName := fmt.Sprintf("nexodus.fflag.%s", name) if _, found := c.Get(ctxName); found { return c.GetBool(ctxName) } - if envValue, err := strconv.ParseBool(os.Getenv(fflag.env)); err == nil { - return envValue - } - return fflag.defaultValue + return fn() } // ListFlags returns a map of all currently defined feature flags and // whether those features are enabled (true) or not (false). func (f *FFlags) ListFlags(c *gin.Context) map[string]bool { result := map[string]bool{} - for name, fflag := range hardCodedFlags { - result[name] = f.getFlagValue(c, name, fflag) + for name, fn := range f.Flags { + result[name] = f.getFlagValue(c, name, fn) } return result } @@ -59,11 +61,9 @@ func (f *FFlags) ListFlags(c *gin.Context) map[string]bool { // flag is enabled (true) or not (false). An error is returned if // the flag name is invalid. func (f *FFlags) GetFlag(c *gin.Context, flag string) (bool, error) { - fflag, ok := hardCodedFlags[flag] - + fn, ok := f.Flags[flag] if !ok { - f.logger.Errorf("Invalid feature flag name: %s", flag) - return false, fmt.Errorf("Invalid feature flag name: %s", flag) + return false, fmt.Errorf("invalid feature flag name: %s", flag) } - return f.getFlagValue(c, flag, fflag), nil + return f.getFlagValue(c, flag, fn), nil } diff --git a/internal/handlers/api.go b/internal/handlers/api.go index 63a6ab554..0357ba549 100644 --- a/internal/handlers/api.go +++ b/internal/handlers/api.go @@ -14,6 +14,7 @@ import ( "github.com/nexodus-io/nexodus/internal/signalbus" "github.com/redis/go-redis/v9" "net/http" + "net/url" "strconv" "github.com/nexodus-io/nexodus/internal/database" @@ -51,10 +52,12 @@ type API struct { fetchManager fetchmgr.FetchManager onlineTracker *DeviceTracker URL string + URLParsed *url.URL PrivateKey *rsa.PrivateKey Certificates []*x509.Certificate SmtpServer email.SmtpServer SmtpFrom string + caKeyPair CertificateKeyPair } func NewAPI( @@ -67,8 +70,23 @@ func NewAPI( signalBus signalbus.SignalBus, redis *redis.Client, sessionManager *session.Manager, + caKeyPair CertificateKeyPair, ) (*API, error) { + fflags.RegisterEnvFlag("multi-organization", "NEXAPI_FFLAG_MULTI_ORGANIZATION", true) + fflags.RegisterEnvFlag("security-groups", "NEXAPI_FFLAG_SECURITY_GROUPS", true) + fflags.RegisterEnvFlag("devices-api", "NEXAPI_FFLAG_DEVICES", true) + fflags.RegisterEnvFlag("sites-api", "NEXAPI_FFLAG_SITES", false) + fflags.RegisterFlag("ca-api", func() bool { + if !fflags.Flags["sites-api"]() { + return false + } + if caKeyPair.Certificate == nil { + return false + } + return true + }) + ctx, span := tracer.Start(parent, "NewAPI") defer span.End() @@ -101,6 +119,7 @@ func NewAPI( sessionManager: sessionManager, fetchManager: fetchManager, onlineTracker: onlineTracker, + caKeyPair: caKeyPair, } if err := api.populateStore(ctx); err != nil { diff --git a/internal/handlers/ca.go b/internal/handlers/ca.go new file mode 100644 index 000000000..3884d762b --- /dev/null +++ b/internal/handlers/ca.go @@ -0,0 +1,410 @@ +package handlers + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "errors" + "fmt" + "github.com/gin-gonic/gin" + "github.com/nexodus-io/nexodus/internal/models" + "gorm.io/gorm" + "math/big" + "net/http" + "net/url" + "strings" + "time" +) + +type CertificateKeyPair struct { + Certificate *x509.Certificate + Key any + CertificatePem []byte +} + +func ParseCertificateKeyPair(certPEMBlock, keyPEMBlock []byte) (result CertificateKeyPair, err error) { + result.CertificatePem = certPEMBlock + certDERBlock, _ := pem.Decode(certPEMBlock) + if certDERBlock == nil || certDERBlock.Type != "CERTIFICATE" { + return CertificateKeyPair{}, fmt.Errorf("cert pem block type is not CERTIFICATE") + } + result.Certificate, err = x509.ParseCertificate(certDERBlock.Bytes) + if err != nil { + return CertificateKeyPair{}, fmt.Errorf("failed to parse the certificate: %w", err) + } + + keyDERBlock, _ := pem.Decode(keyPEMBlock) + if keyDERBlock == nil || !strings.HasSuffix(keyDERBlock.Type, "PRIVATE KEY") { + return CertificateKeyPair{}, fmt.Errorf("key pem block type is not PRIVATE KEY: %s", keyDERBlock.Type) + } + result.Key, err = x509.ParsePKCS1PrivateKey(keyDERBlock.Bytes) + if err != nil { + return CertificateKeyPair{}, fmt.Errorf("failed to parse the PKCS1 key: %w", err) + } + + return result, nil +} + +func newSerialNumber() (*big.Int, error) { + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + return rand.Int(rand.Reader, serialNumberLimit) +} + +// SignCSR signs a certificate signing request +// @Summary Signs a certificate signing request +// @Description Signs a certificate signing request +// @Id SignCSR +// @Tags CA +// @Accept json +// @Produce json +// @Param CertificateSigningRequest body models.CertificateSigningRequest true "Certificate signing request" +// @Success 201 {object} models.CertificateSigningResponse +// @Failure 400 {object} models.ValidationError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/ca/sign [post] +func (api *API) SignCSR(c *gin.Context) { + _, span := tracer.Start(c.Request.Context(), "SignCSR") + defer span.End() + + if !api.FlagCheck(c, "ca-api") { + return + } + + tx := api.db.WithContext(c) + tokenClaims, apiResponseError := NxodusClaims(c, tx) + if apiResponseError != nil { + c.JSON(apiResponseError.Status, apiResponseError.Body) + return + } + + var siteId string + if tokenClaims != nil { + switch tokenClaims.Scope { + case "device-token": + siteId = tokenClaims.ID + default: + c.JSON(http.StatusForbidden, models.NewApiError(errors.New("a device token is required"))) + return + } + } else { + c.JSON(http.StatusForbidden, models.NewApiError(errors.New("a device token is required"))) + return + } + + //if certURI == nil { + // c.JSON(http.StatusForbidden, models.NewApiError(errors.New("cannot determine certificate url"))) + // return + //} + + var request models.CertificateSigningRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPayloadError(err)) + return + } + + if len(request.Request) == 0 { + c.JSON(http.StatusBadRequest, models.NewFieldNotPresentError("request")) + return + } + + csrPEM, _ := pem.Decode([]byte(request.Request)) + if csrPEM == nil { + c.JSON(http.StatusBadRequest, models.NewFieldValidationError("request", "unexpected content")) + return + } + if csrPEM.Type != "CERTIFICATE REQUEST" && csrPEM.Type != "NEW CERTIFICATE REQUEST" { + c.JSON(http.StatusBadRequest, models.NewFieldValidationError("pem", "unexpected type, expected CERTIFICATE REQUEST, got: "+csrPEM.Type)) + return + } + csr, err := x509.ParseCertificateRequest(csrPEM.Bytes) + if err != nil { + c.JSON(http.StatusBadRequest, models.NewFieldValidationError("request", "parse failed: "+err.Error())) + return + } + err = csr.CheckSignature() + if err != nil { + c.JSON(http.StatusBadRequest, models.NewFieldValidationError("request", "invalid signature: "+err.Error())) + return + } + + expiration := time.Now().AddDate(5, 0, 0) + if request.Duration != nil { + expiration = time.Now().Add(request.Duration.Duration) + } + + serialNumber, err := newSerialNumber() + if err != nil { + api.SendInternalServerError(c, fmt.Errorf("failed to generate certificate serial number: %w", err)) + return + } + + ku, eku, err := KeyUsagesForCertificateOrCertificateRequest(request.IsCA, request.Usages...) + if err != nil { + c.JSON(http.StatusBadRequest, models.NewFieldValidationError("usages", err.Error())) + return + } + + // get the site and teh VPC CA + site := models.Site{} + sendVPCNotify := false + err = api.transaction(c, func(tx *gorm.DB) error { + + if res := tx.Joins("Vpc").First(&site, "sites.id = ?", siteId); res.Error != nil { + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + return NewApiResponseError(http.StatusNotFound, models.NewNotFoundError("site")) + } + return err + } + + // allocate the VPC CA on demand + if len(site.Vpc.CaCertificates) == 0 { + cert, key, err := api.CreateVPCCertKeyPair(site.Vpc) + if err != nil { + return err + } + + site.Vpc.CaCertificates = []string{cert} + site.Vpc.CaKey = key + if res := tx.Select("ca_certificates", "ca_key"). + Where("ca_key is NULL"). + Updates(site.Vpc); res.Error != nil { + return err + } + sendVPCNotify = true + } + + return nil + }) + if err != nil { + var apiResponseError *ApiResponseError + if errors.As(err, &apiResponseError) { + c.JSON(apiResponseError.Status, apiResponseError.Body) + } else { + api.SendInternalServerError(c, err) + } + return + } + if sendVPCNotify { + api.signalBus.Notify(fmt.Sprintf("/vpc=%s", site.VpcID.String())) + } + + template := &x509.Certificate{ + SerialNumber: serialNumber, + Subject: csr.Subject, + ExtraExtensions: csr.Extensions, + NotBefore: time.Now(), NotAfter: expiration, + DNSNames: csr.DNSNames, + // this CA only enforces the spiffe URI in the CSR for now + URIs: []*url.URL{ + { + Scheme: "spiffe", + Host: api.URLParsed.Host, + Path: fmt.Sprintf("/o/%s/v/%s/s/%s", site.OrganizationID, site.VpcID, site.ID), + }, + }, + KeyUsage: ku, + ExtKeyUsage: eku, + } + + if len(template.DNSNames) == 0 { + template.DNSNames = append(template.DNSNames, csr.Subject.CommonName) + } + + if len(csr.EmailAddresses) > 0 { + template.ExtKeyUsage = append(template.ExtKeyUsage, x509.ExtKeyUsageEmailProtection) + } + + vpcCaKeyPair, err := ParseCertificateKeyPair([]byte(site.Vpc.CaCertificates[0]), []byte(site.Vpc.CaKey)) + if err != nil { + api.SendInternalServerError(c, err) + return + } + + cert, err := x509.CreateCertificate(rand.Reader, template, vpcCaKeyPair.Certificate, csr.PublicKey, vpcCaKeyPair.Key) + if err != nil { + api.SendInternalServerError(c, fmt.Errorf("failed to generate certificate: %w", err)) + return + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert}) + + err = VerifyCertificate(certPEM, append(vpcCaKeyPair.CertificatePem, api.caKeyPair.CertificatePem...), template.ExtKeyUsage...) + if err != nil { + api.SendInternalServerError(c, fmt.Errorf("failed to verify generated certificate: %w", err)) + return + } + + c.JSON(http.StatusOK, models.CertificateSigningResponse{ + Certificate: string(certPEM), + CA: string(append(vpcCaKeyPair.CertificatePem, api.caKeyPair.CertificatePem...)), + //Certificate: string(append(certPEM, vpcCaKeyPair.CertificatePem...)), + //CA: string(api.caKeyPair.CertificatePem), + }) + +} + +func (api *API) CreateVPCCertKeyPair(vpc *models.VPC) (string, string, error) { + + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return "", "", err + } + vpcsURI, err := url.Parse(fmt.Sprintf("%s/api/vpcs/%s", api.URL, vpc.ID)) + if err != nil { + return "", "", err + } + + serialNumber, err := newSerialNumber() + if err != nil { + return "", "", fmt.Errorf("failed to generate certificate serial number: %w", err) + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + CommonName: vpcsURI.String(), + }, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(5, 0, 0), + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment, + BasicConstraintsValid: true, + IsCA: true, + URIs: []*url.URL{ + { + Scheme: "spiffe", + Host: api.URLParsed.Host, + Path: fmt.Sprintf("/o/%s/v/%s", vpc.OrganizationID, vpc.ID), + }, + }, + } + + cert, err := x509.CreateCertificate(rand.Reader, &template, api.caKeyPair.Certificate, privateKey.Public(), api.caKeyPair.Key) + if err != nil { + return "", "", fmt.Errorf("failed to generate certificate: %w", err) + } + + keyPEM := string(pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})) + certPEM := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert})) + + err = VerifyCertificate([]byte(certPEM), []byte(api.caKeyPair.CertificatePem), x509.ExtKeyUsageAny) + if err != nil { + return "", "", fmt.Errorf("failed to verify vpc certificate: %w", err) + } + + return certPEM, keyPEM, nil +} + +func KeyUsagesForCertificateOrCertificateRequest(isCA bool, usages ...models.KeyUsage) (ku x509.KeyUsage, eku []x509.ExtKeyUsage, err error) { + var unk []models.KeyUsage + if isCA { + ku |= x509.KeyUsageCertSign + } + if len(usages) == 0 { + usages = []models.KeyUsage{models.UsageDigitalSignature, models.UsageKeyEncipherment} + } + for _, u := range usages { + if kuse, ok := keyUsages[u]; ok { + ku |= kuse + } else if ekuse, ok := extKeyUsages[u]; ok { + eku = append(eku, ekuse) + } else { + unk = append(unk, u) + } + } + if len(unk) > 0 { + err = fmt.Errorf("unknown key usages: %v", unk) + } + return +} + +var keyUsages = map[models.KeyUsage]x509.KeyUsage{ + models.UsageSigning: x509.KeyUsageDigitalSignature, + models.UsageDigitalSignature: x509.KeyUsageDigitalSignature, + models.UsageContentCommitment: x509.KeyUsageContentCommitment, + models.UsageKeyEncipherment: x509.KeyUsageKeyEncipherment, + models.UsageKeyAgreement: x509.KeyUsageKeyAgreement, + models.UsageDataEncipherment: x509.KeyUsageDataEncipherment, + models.UsageCertSign: x509.KeyUsageCertSign, + models.UsageCRLSign: x509.KeyUsageCRLSign, + models.UsageEncipherOnly: x509.KeyUsageEncipherOnly, + models.UsageDecipherOnly: x509.KeyUsageDecipherOnly, +} +var extKeyUsages = map[models.KeyUsage]x509.ExtKeyUsage{ + models.UsageAny: x509.ExtKeyUsageAny, + models.UsageServerAuth: x509.ExtKeyUsageServerAuth, + models.UsageClientAuth: x509.ExtKeyUsageClientAuth, + models.UsageCodeSigning: x509.ExtKeyUsageCodeSigning, + models.UsageEmailProtection: x509.ExtKeyUsageEmailProtection, + models.UsageSMIME: x509.ExtKeyUsageEmailProtection, + models.UsageIPsecEndSystem: x509.ExtKeyUsageIPSECEndSystem, + models.UsageIPsecTunnel: x509.ExtKeyUsageIPSECTunnel, + models.UsageIPsecUser: x509.ExtKeyUsageIPSECUser, + models.UsageTimestamping: x509.ExtKeyUsageTimeStamping, + models.UsageOCSPSigning: x509.ExtKeyUsageOCSPSigning, + models.UsageMicrosoftSGC: x509.ExtKeyUsageMicrosoftServerGatedCrypto, + models.UsageNetscapeSGC: x509.ExtKeyUsageNetscapeServerGatedCrypto, +} + +func VerifyCertificate(certPEMBlock []byte, caCertPEMBlock []byte, keyUsages ...x509.ExtKeyUsage) error { + + // Decode the PEM block to get the DER-encoded certificate + pemBlock, _ := pem.Decode(certPEMBlock) + if pemBlock == nil { + return fmt.Errorf("error decoding PEM block") + } + + // Parse the DER-encoded certificate + cert, err := x509.ParseCertificate(pemBlock.Bytes) + if err != nil { + return fmt.Errorf("error parsing certificate: %w", err) + } + + // Create a pool of trusted CA certificates + roots, intermediates, err := NewCertPools(caCertPEMBlock) + if err != nil { + return fmt.Errorf("error parsing ca certificates: %w", err) + } + + // Verify that the certificate was signed by the CA + opts := x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: keyUsages, + } + + if _, err := cert.Verify(opts); err != nil { + return err + } + return nil +} + +// NewCertPools creates x509 cert pools from the given PEM bytes. +func NewCertPools(pemBytes []byte) (*x509.CertPool, *x509.CertPool, error) { + certs := []*x509.Certificate{} + for { + var block *pem.Block + block, pemBytes = pem.Decode(pemBytes) + if block == nil { + break + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, nil, err + } + certs = append(certs, cert) + } + if len(certs) == 0 { + return nil, nil, fmt.Errorf("no certificates found") + } + roots := x509.NewCertPool() + intermediates := x509.NewCertPool() + for i, cert := range certs { + if i == len(certs)-1 { + roots.AddCert(cert) + } else { + intermediates.AddCert(cert) + } + } + return roots, intermediates, nil +} diff --git a/internal/handlers/envoy_authz.go b/internal/handlers/envoy_authz.go index b68ba48d5..2be18814f 100644 --- a/internal/handlers/envoy_authz.go +++ b/internal/handlers/envoy_authz.go @@ -46,6 +46,9 @@ func (api *API) Check(ctx context.Context, checkReq *auth.CheckRequest) (*auth.C } else if strings.HasPrefix(authorizationHeader, "Bearer DT:") { token := strings.TrimPrefix(authorizationHeader, "Bearer ") return checkDeviceToken(ctx, api, token) + } else if strings.HasPrefix(authorizationHeader, "Bearer ST:") { + token := strings.TrimPrefix(authorizationHeader, "Bearer ") + return checkSiteToken(ctx, api, token) } return okResponse, nil } @@ -159,6 +162,65 @@ func checkRegistrationToken(ctx context.Context, api *API, token string) (*auth. }, nil } +func checkSiteToken(ctx context.Context, api *API, token string) (*auth.CheckResponse, error) { + + var site models.Site + db := api.db.WithContext(ctx) + result := db.First(&site, "bearer_token = ?", token) + if result.Error != nil { + message := "internal server error" + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + message = "invalid site token" + } + return denyCheckResponse(401, models.NewBaseError(message)) + } + + var user models.User + result = db.First(&user, "id = ?", site.OwnerID) + if result.Error != nil { + + message := "internal server error" + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + message = "invalid reg key user" + } + return denyCheckResponse(401, models.NewBaseError(message)) + } + + // replace it with a JWT token... + claims := models.NexodusClaims{ + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: api.URL, + ID: site.ID.String(), + Subject: user.IdpID, + }, + VpcID: site.VpcID, + Scope: "device-token", + } + + jwttoken, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(api.PrivateKey) + if err != nil { + return denyCheckResponse(401, models.NewBaseError("internal server error")) + } + + return &auth.CheckResponse{ + Status: &status.Status{Code: int32(codes.OK)}, + HttpResponse: &auth.CheckResponse_OkResponse{ + OkResponse: &auth.OkHttpResponse{ + Headers: []*core.HeaderValueOption{ + { + AppendAction: core.HeaderValueOption_OVERWRITE_IF_EXISTS_OR_ADD, + Header: &core.HeaderValue{ + Key: "authorization", + Value: "Bearer " + jwttoken, + }, + }, + }, + }, + }, + }, nil + +} + func checkDeviceToken(ctx context.Context, api *API, token string) (*auth.CheckResponse, error) { var device models.Device db := api.db.WithContext(ctx) diff --git a/internal/handlers/events.go b/internal/handlers/events.go index c971004ff..dc5da82f0 100644 --- a/internal/handlers/events.go +++ b/internal/handlers/events.go @@ -143,6 +143,37 @@ func (api *API) WatchEvents(c *gin.Context) { fetch: fetcher.Fetch, }) + case "site": + + fetcher := api.fetchManager.Open("org-sites:"+vpcId.String(), deviceCacheSize, func(db *gorm.DB, gtRevision uint64) (fetchmgr.ResourceList, error) { + var items siteList + db = db.Unscoped().Limit(100).Order("revision") + if gtRevision != 0 { + db = db.Where("revision > ?", gtRevision) + } + db = db.Where("vpc_id = ?", vpcId.String()) + result := db.Find(&items) + if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, result.Error + } + + userId := api.GetCurrentUserID(c) + for i := range items { + hideSiteBearerToken(items[i], tokenClaims, userId) + } + + return items, nil + }) + defer fetcher.Close() + + watches = append(watches, Watch{ + kind: r.Kind, + gtRevision: r.GtRevision, + atTail: r.AtTail, + signal: fmt.Sprintf("/sites/vpc=%s", vpcId.String()), + fetch: fetcher.Fetch, + }) + case "security-group": watches = append(watches, Watch{ kind: r.Kind, @@ -224,6 +255,26 @@ func (api *API) WatchEvents(c *gin.Context) { }, }) + case "vpc": + watches = append(watches, Watch{ + kind: r.Kind, + gtRevision: r.GtRevision, + atTail: r.AtTail, + signal: fmt.Sprintf("/vpc=%s", vpcId.String()), + fetch: func(db *gorm.DB, gtRevision uint64) (fetchmgr.ResourceList, error) { + var items vpcList + db = db.Unscoped().Limit(100).Order("revision") + if gtRevision != 0 { + db = db.Where("revision > ?", gtRevision) + } + db = db.Where("organization_id = ?", vpc.OrganizationID.String()) + result := db.Find(&items) + if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, result.Error + } + return items, nil + }, + }) default: c.JSON(http.StatusBadRequest, models.NewInvalidField(fmt.Sprintf("request[%d].kind", i))) } diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 51f7264ed..354a99f85 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -73,7 +73,7 @@ func (suite *HandlerTestSuite) SetupSuite() { fflags := fflags.NewFFlags(suite.logger) store := inmem.New() - suite.api, err = NewAPI(context.Background(), suite.logger, db, ipamClient, fflags, store, signalbus.NewSignalBus(), redisClient, nil) + suite.api, err = NewAPI(context.Background(), suite.logger, db, ipamClient, fflags, store, signalbus.NewSignalBus(), redisClient, nil, CertificateKeyPair{}) if err != nil { suite.T().Fatal(err) } diff --git a/internal/handlers/site.go b/internal/handlers/site.go new file mode 100644 index 000000000..27b6b38de --- /dev/null +++ b/internal/handlers/site.go @@ -0,0 +1,535 @@ +package handlers + +import ( + "errors" + "fmt" + "github.com/nexodus-io/nexodus/internal/handlers/fetchmgr" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/nexodus-io/nexodus/internal/models" + "github.com/nexodus-io/nexodus/internal/wgcrypto" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + "golang.zx2c4.com/wireguard/wgctrl/wgtypes" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type siteList []*models.Site + +func (d siteList) Item(i int) (any, uint64, gorm.DeletedAt) { + item := d[i] + return item, item.Revision, item.DeletedAt +} + +func (d siteList) Len() int { + return len(d) +} + +// ListSites lists all sites +// @Summary List Sites +// @Description Lists all sites +// @Id ListSites +// @Tags Sites +// @Accept json +// @Produce json +// @Success 200 {object} []models.Site +// @Failure 401 {object} models.BaseError +// @Failure 429 {object} models.BaseError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/sites [get] +func (api *API) ListSites(c *gin.Context) { + ctx, span := tracer.Start(c.Request.Context(), "ListSites") + defer span.End() + sites := make([]models.Site, 0) + + db := api.db.WithContext(ctx) + db = api.SiteIsOwnedByCurrentUser(c, db) + db = FilterAndPaginate(db, &models.Site{}, c, "hostname") + result := db.Find(&sites) + if result.Error != nil { + api.SendInternalServerError(c, errors.New("error fetching keys from db")) + return + } + + tokenClaims, err := NxodusClaims(c, api.db.WithContext(ctx)) + if err != nil { + c.JSON(err.Status, err.Body) + return + } + + // only show the site token when using the reg token that created the site. + userId := api.GetCurrentUserID(c) + for i := range sites { + hideSiteBearerToken(&sites[i], tokenClaims, userId) + } + c.JSON(http.StatusOK, sites) +} + +func encryptSiteBearerToken(token string, publicKey string) string { + key, err := wgtypes.ParseKey(publicKey) + if err != nil { + return "" + } + sealed, err := wgcrypto.SealV1(key[:], []byte(token)) + if err != nil { + return "" + } + + return sealed.String() +} + +func hideSiteBearerToken(site *models.Site, claims *models.NexodusClaims, currentUserId uuid.UUID) { + + var hide bool + if claims != nil { + switch claims.Scope { + case "reg-token": + hide = claims.ID != site.RegKeyID.String() + case "device-token": + hide = claims.ID != site.ID.String() + default: + hide = currentUserId != site.OwnerID + } + } else { + hide = currentUserId != site.OwnerID + } + + if hide { + site.BearerToken = "" + } else { + site.BearerToken = encryptSiteBearerToken(site.BearerToken, site.PublicKey) + } +} + +func (api *API) SiteIsOwnedByCurrentUser(c *gin.Context, db *gorm.DB) *gorm.DB { + userId := api.GetCurrentUserID(c) + return db.Where("owner_id = ?", userId) +} + +// GetSite gets a site by ID +// @Summary Get Sites +// @Description Gets a site by ID +// @Id GetSite +// @Tags Sites +// @Accept json +// @Produce json +// @Param id path string true "Site ID" +// @Success 200 {object} models.Site +// @Failure 401 {object} models.BaseError +// @Failure 400 {object} models.BaseError +// @Failure 404 {object} models.BaseError +// @Failure 429 {object} models.BaseError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/sites/{id} [get] +func (api *API) GetSite(c *gin.Context) { + ctx, span := tracer.Start(c.Request.Context(), "GetSite", trace.WithAttributes( + attribute.String("id", c.Param("id")), + )) + defer span.End() + k, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPathParameterError("id")) + return + } + var site models.Site + + db := api.db.WithContext(ctx) + db = api.SiteIsOwnedByCurrentUser(c, db) + result := db.First(&site, "id = ?", k) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.Status(http.StatusNotFound) + return + } + + tokenClaims, err2 := NxodusClaims(c, api.db.WithContext(ctx)) + if err2 != nil { + c.JSON(err2.Status, err2.Body) + return + } + + // only show the site token when using the reg token that created the site. + userId := api.GetCurrentUserID(c) + hideSiteBearerToken(&site, tokenClaims, userId) + + c.JSON(http.StatusOK, site) +} + +// UpdateSite updates a Site +// @Summary Update Sites +// @Description Updates a site by ID +// @Id UpdateSite +// @Tags Sites +// @Accept json +// @Produce json +// @Param id path string true "Site ID" +// @Param update body models.UpdateSite true "Site Update" +// @Success 200 {object} models.Site +// @Failure 401 {object} models.BaseError +// @Failure 400 {object} models.BaseError +// @Failure 404 {object} models.BaseError +// @Failure 429 {object} models.BaseError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/sites/{id} [patch] +func (api *API) UpdateSite(c *gin.Context) { + ctx, span := tracer.Start(c.Request.Context(), "UpdateSite", trace.WithAttributes( + attribute.String("id", c.Param("id")), + )) + defer span.End() + siteId, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPathParameterError("id")) + return + } + var request models.UpdateSite + + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPayloadError(err)) + return + } + + var site models.Site + var tokenClaims *models.NexodusClaims + err = api.transaction(ctx, func(tx *gorm.DB) error { + + db := api.SiteIsOwnedByCurrentUser(c, tx) + db = FilterAndPaginate(db, &models.Site{}, c, "hostname") + + result := db.First(&site, "id = ?", siteId) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return NewApiResponseError(http.StatusNotFound, models.NewNotFoundError("site")) + } + + var err2 *ApiResponseError + tokenClaims, err2 = NxodusClaims(c, tx) + if err2 != nil { + return err2 + } + + if tokenClaims != nil { + switch tokenClaims.Scope { + case "reg-token": + if tokenClaims.ID != site.RegKeyID.String() { + return NewApiResponseError(http.StatusForbidden, models.NewApiError(errors.New("reg key does not have access"))) + } + case "device-token": + if tokenClaims.ID != site.ID.String() { + return NewApiResponseError(http.StatusForbidden, models.NewApiError(errors.New("reg key does not have access"))) + } + } + } + + var vpc models.VPC + if result = tx.First(&vpc, "id = ?", site.VpcID); result.Error != nil { + return NewApiResponseError(http.StatusNotFound, models.NewNotFoundError("vpc")) + } + + if request.Hostname != nil { + site.Hostname = *request.Hostname + } + if request.Os != nil { + site.Os = *request.Os + } + if request.LinkSecret != nil { + site.LinkSecret = *request.LinkSecret + } + + if res := tx. + Clauses(clause.Returning{Columns: []clause.Column{{Name: "revision"}}}). + Save(&site); res.Error != nil { + return res.Error + } + + return nil + }) + + if err != nil { + var apiResponseError *ApiResponseError + if errors.As(err, &apiResponseError) { + c.JSON(apiResponseError.Status, apiResponseError.Body) + } else { + api.SendInternalServerError(c, err) + } + return + } + + hideSiteBearerToken(&site, tokenClaims, api.GetCurrentUserID(c)) + + api.signalBus.Notify(fmt.Sprintf("/sites/vpc=%s", site.VpcID.String())) + c.JSON(http.StatusOK, site) +} + +// CreateSite handles adding a new site +// @Summary Add Sites +// @Id CreateSite +// @Tags Sites +// @Description Adds a new site +// @Accept json +// @Produce json +// @Param Site body models.AddSite true "Add Site" +// @Success 201 {object} models.Site +// @Failure 400 {object} models.BaseError +// @Failure 401 {object} models.BaseError +// @Failure 409 {object} models.ConflictsError +// @Failure 429 {object} models.BaseError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/sites [post] +func (api *API) CreateSite(c *gin.Context) { + ctx, span := tracer.Start(c.Request.Context(), "AddSite") + defer span.End() + var request models.AddSite + // Call BindJSON to bind the received JSON + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPayloadError(err)) + return + } + + if request.PublicKey == "" { + c.JSON(http.StatusBadRequest, models.NewFieldNotPresentError("public_key")) + return + } + if request.VpcID == uuid.Nil { + c.JSON(http.StatusBadRequest, models.NewFieldNotPresentError("vpc_id")) + return + } + + var tokenClaims *models.NexodusClaims + var site models.Site + err := api.transaction(ctx, func(tx *gorm.DB) error { + + var vpc models.VPC + if result := api.VPCIsReadableByCurrentUser(c, tx). + Preload("Organization"). + First(&vpc, "id = ?", request.VpcID); result.Error != nil { + return NewApiResponseError(http.StatusNotFound, models.NewNotFoundError("vpc")) + } + + res := tx.Where("public_key = ?", request.PublicKey).First(&site) + if res.Error == nil { + return NewApiResponseError(http.StatusConflict, models.NewConflictsError(site.ID.String())) + } + if res.Error != nil && !errors.Is(res.Error, gorm.ErrRecordNotFound) { + return res.Error + } + + var err2 *ApiResponseError + tokenClaims, err2 = NxodusClaims(c, tx) + if err2 != nil { + return err2 + } + if tokenClaims != nil && tokenClaims.Scope != "reg-token" { + tokenClaims = nil + } + + siteId := uuid.Nil + regKeyID := uuid.Nil + var err error + if tokenClaims != nil { + regKeyID, err = uuid.Parse(tokenClaims.ID) + if err != nil { + return NewApiResponseError(http.StatusBadRequest, fmt.Errorf("invalid reg key id")) + } + + // is the user token restricted to operating on a single site? + if tokenClaims.DeviceID != uuid.Nil { + err = tx.Where("id = ?", tokenClaims.DeviceID).First(&site).Error + if err == nil { + // If we get here the site exists but has a different public key, so assume + // the reg toke has been previously used. + return NewApiResponseError(http.StatusBadRequest, models.NewApiError(errRegKeyExhausted)) + } + + siteId = tokenClaims.DeviceID + } + + if tokenClaims.VpcID != request.VpcID { + return NewApiResponseError(http.StatusBadRequest, models.NewFieldValidationError("vpc_id", "does not match the reg key vpc_id")) + } + } + if siteId == uuid.Nil { + siteId = uuid.New() + } + + // lets use a wg private key as the token, since it should be hard to guess. + siteToken, err := wgtypes.GeneratePrivateKey() + if err != nil { + return err + } + + site = models.Site{ + Base: models.Base{ + ID: siteId, + }, + OwnerID: api.GetCurrentUserID(c), + VpcID: vpc.ID, + OrganizationID: vpc.OrganizationID, + PublicKey: request.PublicKey, + Platform: request.Platform, + Name: request.Name, + RegKeyID: regKeyID, + BearerToken: "ST:" + siteToken.String(), + } + + if res := tx. + Clauses(clause.Returning{Columns: []clause.Column{{Name: "revision"}}}). + Create(&site); res.Error != nil { + return res.Error + } + span.SetAttributes( + attribute.String("id", site.ID.String()), + ) + return nil + }) + + if err != nil { + var apiResponseError *ApiResponseError + if errors.As(err, &apiResponseError) { + c.JSON(apiResponseError.Status, apiResponseError.Body) + } else { + api.SendInternalServerError(c, err) + } + return + } + + hideSiteBearerToken(&site, tokenClaims, api.GetCurrentUserID(c)) + + api.signalBus.Notify(fmt.Sprintf("/sites/vpc=%s", site.VpcID.String())) + c.JSON(http.StatusCreated, site) +} + +// DeleteSite handles deleting an existing site and associated ipam lease +// @Summary Delete Site +// @Description Deletes an existing site and associated IPAM lease +// @Id DeleteSite +// @Tags Sites +// @Accept json +// @Produce json +// @Param id path string true "Site ID" +// @Success 204 {object} models.Site +// @Failure 400 {object} models.BaseError +// @Failure 429 {object} models.BaseError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/sites/{id} [delete] +func (api *API) DeleteSite(c *gin.Context) { + ctx, span := tracer.Start(c.Request.Context(), "DeleteSite") + defer span.End() + siteID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPathParameterError("id")) + return + } + + site := models.Site{} + db := api.db.WithContext(ctx) + if res := api.SiteIsOwnedByCurrentUser(c, db). + First(&site, "id = ?", siteID); res.Error != nil { + + if errors.Is(res.Error, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, models.NewNotFoundError("site")) + } else { + c.JSON(http.StatusBadRequest, models.NewApiError(res.Error)) + } + return + } + + var vpc models.VPC + result := db. + First(&vpc, "id = ?", site.VpcID) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + api.SendInternalServerError(c, result.Error) + } + + // Null out unique fields to that a new site can be created later with the same values + if res := api.db.WithContext(ctx). + Model(&site). + Clauses(clause.Returning{Columns: []clause.Column{{Name: "revision"}}}). + Where("id = ?", site.Base.ID). + Updates(map[string]interface{}{ + "bearer_token": nil, + "public_key": nil, + "deleted_at": gorm.DeletedAt{Time: time.Now(), Valid: true}, + }); res.Error != nil { + api.SendInternalServerError(c, res.Error) + return + } + + api.signalBus.Notify(fmt.Sprintf("/sites/vpc=%s", site.VpcID.String())) + + c.JSON(http.StatusOK, site) +} + +// ListSitesInVPC lists all sites in an VPC +// @Summary List Sites +// @Description Lists all sites for this VPC +// @Id ListSitesInVPC +// @Tags VPC +// @Accept json +// @Produce json +// @Param gt_revision query uint64 false "greater than revision" +// @Param id path string true "VPC ID" +// @Success 200 {object} []models.Site +// @Failure 400 {object} models.BaseError +// @Failure 401 {object} models.BaseError +// @Failure 429 {object} models.BaseError +// @Failure 500 {object} models.InternalServerError "Internal Server Error" +// @Router /api/vpcs/{id}/sites [get] +func (api *API) ListSitesInVPC(c *gin.Context) { + + ctx, span := tracer.Start(c.Request.Context(), "ListSitesInVPC", + trace.WithAttributes( + attribute.String("vpc_id", c.Param("id")), + )) + defer span.End() + + vpcId, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.NewBadPathParameterError("id")) + return + } + var vpc models.VPC + db := api.db.WithContext(ctx) + result := api.VPCIsReadableByCurrentUser(c, db). + First(&vpc, "id = ?", vpcId.String()) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + c.JSON(http.StatusNotFound, models.NewNotFoundError("vpc")) + } else { + api.SendInternalServerError(c, result.Error) + } + return + } + + var query Query + if err := c.ShouldBindQuery(&query); err != nil { + c.JSON(http.StatusBadRequest, models.NewApiError(err)) + return + } + + tokenClaims, err2 := NxodusClaims(c, api.db.WithContext(ctx)) + if err2 != nil { + c.JSON(err2.Status, err2.Body) + return + } + + api.sendList(c, ctx, func(db *gorm.DB) (fetchmgr.ResourceList, error) { + db = db.Where("vpc_id = ?", vpcId.String()) + db = FilterAndPaginateWithQuery(db, &models.Site{}, c, query, "hostname") + + var items siteList + result := db.Find(&items) + if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, result.Error + } + + userId := api.GetCurrentUserID(c) + for i := range items { + hideSiteBearerToken(items[i], tokenClaims, userId) + } + return items, nil + }) + +} diff --git a/internal/handlers/vpc.go b/internal/handlers/vpc.go index 748bbe6ce..6d6386189 100644 --- a/internal/handlers/vpc.go +++ b/internal/handlers/vpc.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "github.com/nexodus-io/nexodus/internal/util" + "gorm.io/gorm/clause" "net/http" "github.com/gin-gonic/gin" @@ -104,7 +105,9 @@ func (api *API) CreateVPC(c *gin.Context) { Ipv6Cidr: request.Ipv6Cidr, } - if res := tx.Create(&vpc); res.Error != nil { + if res := tx. + Clauses(clause.Returning{Columns: []clause.Column{{Name: "revision"}}}). + Create(&vpc); res.Error != nil { if database.IsDuplicateError(res.Error) { return NewApiResponseError(http.StatusConflict, models.NewConflictsError(vpc.ID.String())) } @@ -386,7 +389,9 @@ func (api *API) UpdateVPC(c *gin.Context) { vpc.Description = *request.Description } - if res := tx.Save(&vpc); res.Error != nil { + if res := tx. + Clauses(clause.Returning{Columns: []clause.Column{{Name: "revision"}}}). + Save(&vpc); res.Error != nil { return res.Error } return nil @@ -401,5 +406,18 @@ func (api *API) UpdateVPC(c *gin.Context) { } return } + + api.signalBus.Notify(fmt.Sprintf("/vpc=%s", vpc.ID.String())) c.JSON(http.StatusOK, vpc) } + +type vpcList []*models.VPC + +func (d vpcList) Item(i int) (any, uint64, gorm.DeletedAt) { + item := d[i] + return item, item.Revision, item.DeletedAt +} + +func (d vpcList) Len() int { + return len(d) +} diff --git a/internal/models/ca.go b/internal/models/ca.go new file mode 100644 index 000000000..5540a8f29 --- /dev/null +++ b/internal/models/ca.go @@ -0,0 +1,91 @@ +package models + +// CertificateSigningRequest is a certificate signing request +type CertificateSigningRequest struct { + + // Requested 'duration' (i.e. lifetime) of the Certificate. Note that the + // issuer may choose to ignore the requested duration, just like any other + // requested attribute. + // +optional + Duration *Duration `json:"duration,omitempty" swaggertype:"string"` + + // The PEM-encoded X.509 certificate signing request to be submitted to the + // issuer for signing. + // + // If the CSR has a BasicConstraints extension, its isCA attribute must + // match the `isCA` value of this CertificateRequest. + // If the CSR has a KeyUsage extension, its key usages must match the + // key usages in the `usages` field of this CertificateRequest. + // If the CSR has a ExtKeyUsage extension, its extended key usages + // must match the extended key usages in the `usages` field of this + // CertificateRequest. + Request string `json:"request" example:"-----BEGIN CERTIFICATE REQUEST-----(...)-----END CERTIFICATE REQUEST-----"` + + // Requested basic constraints isCA value. Note that the issuer may choose + // to ignore the requested isCA value, just like any other requested attribute. + // + // NOTE: If the CSR in the `Request` field has a BasicConstraints extension, + // it must have the same isCA value as specified here. + // + // If true, this will automatically add the `cert sign` usage to the list + // of requested `usages`. + // +optional + IsCA bool `json:"is_ca,omitempty"` + + // Requested key usages and extended key usages. + // + // NOTE: If the CSR in the `Request` field has uses the KeyUsage or + // ExtKeyUsage extension, these extensions must have the same values + // as specified here without any additional values. + // + // If unset, defaults to `digital signature` and `key encipherment`. + // +optional + Usages []KeyUsage `json:"usages,omitempty"` +} + +// CertificateSigningResponse is a certificate signing response +type CertificateSigningResponse struct { + + // The PEM encoded X.509 certificate resulting from the certificate + // signing request. + // If not set, the CertificateRequest has either not been completed or has + // failed. More information on failure can be found by checking the + // `conditions` field. + // +optional + Certificate string `json:"certificate,omitempty" example:"-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----"` + + // The PEM encoded X.509 certificate of the signer, also known as the CA + // (Certificate Authority). + // This is set on a best-effort basis by different issuers. + // If not set, the CA is assumed to be unknown/not available. + // +optional + CA string `json:"ca,omitempty" example:"-----BEGIN CERTIFICATE-----(...)-----END CERTIFICATE-----"` +} + +type KeyUsage string + +const ( + UsageSigning KeyUsage = "signing" + UsageDigitalSignature KeyUsage = "digital signature" + UsageContentCommitment KeyUsage = "content commitment" + UsageKeyEncipherment KeyUsage = "key encipherment" + UsageKeyAgreement KeyUsage = "key agreement" + UsageDataEncipherment KeyUsage = "data encipherment" + UsageCertSign KeyUsage = "cert sign" + UsageCRLSign KeyUsage = "crl sign" + UsageEncipherOnly KeyUsage = "encipher only" + UsageDecipherOnly KeyUsage = "decipher only" + UsageAny KeyUsage = "any" + UsageServerAuth KeyUsage = "server auth" + UsageClientAuth KeyUsage = "client auth" + UsageCodeSigning KeyUsage = "code signing" + UsageEmailProtection KeyUsage = "email protection" + UsageSMIME KeyUsage = "s/mime" + UsageIPsecEndSystem KeyUsage = "ipsec end system" + UsageIPsecTunnel KeyUsage = "ipsec tunnel" + UsageIPsecUser KeyUsage = "ipsec user" + UsageTimestamping KeyUsage = "timestamping" + UsageOCSPSigning KeyUsage = "ocsp signing" + UsageMicrosoftSGC KeyUsage = "microsoft sgc" + UsageNetscapeSGC KeyUsage = "netscape sgc" +) diff --git a/internal/models/duration.go b/internal/models/duration.go new file mode 100644 index 000000000..5cd86fdca --- /dev/null +++ b/internal/models/duration.go @@ -0,0 +1,34 @@ +package models + +import ( + "encoding/json" + "time" +) + +// Duration is a wrapper around time.Duration which supports correct +// marshaling to YAML and JSON. In particular, it marshals into strings, which +// can be used as map keys in json. +type Duration struct { + time.Duration +} + +// UnmarshalJSON implements the json.Unmarshaller interface. +func (d *Duration) UnmarshalJSON(b []byte) error { + var str string + err := json.Unmarshal(b, &str) + if err != nil { + return err + } + + pd, err := time.ParseDuration(str) + if err != nil { + return err + } + d.Duration = pd + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Duration.String()) +} diff --git a/internal/models/site.go b/internal/models/site.go new file mode 100644 index 000000000..52d69bfca --- /dev/null +++ b/internal/models/site.go @@ -0,0 +1,40 @@ +package models + +import ( + "github.com/google/uuid" +) + +// Site is a unique, end-user Site. +// Sites belong to one User and may be onboarded into an organization +type Site struct { + Base + Revision uint64 `json:"revision" gorm:"type:bigserial;index:"` + OwnerID uuid.UUID `json:"owner_id" gorm:"type:uuid"` + VpcID uuid.UUID `json:"vpc_id" gorm:"type:uuid" example:"694aa002-5d19-495e-980b-3d8fd508ea10"` + OrganizationID uuid.UUID `json:"-" gorm:"type:uuid"` // Denormalized from the VPC record for performance + RegKeyID uuid.UUID `json:"-" gorm:"type:uuid"` // the reg key id that created the Site (if it was created with a registration token) + BearerToken string `json:"bearer_token,omitempty"` // the token nexd should use to reconcile Site state. + Hostname string `json:"hostname" example:"myhost"` + Os string `json:"os"` + Name string `json:"name"` + Platform string `json:"platform"` + PublicKey string `json:"public_key"` + LinkSecret string `json:"link_secret"` + Vpc *VPC `json:"-"` +} + +// AddSite is the information needed to add a new Site. +type AddSite struct { + VpcID uuid.UUID `json:"vpc_id" example:"694aa002-5d19-495e-980b-3d8fd508ea10"` + Name string `json:"name"` + Platform string `json:"platform"` + PublicKey string `json:"public_key"` +} + +// UpdateSite is the information needed to update a Site. +type UpdateSite struct { + Os *string `json:"os"` + Hostname *string `json:"hostname" example:"myhost"` + Revision *uint64 `json:"revision"` + LinkSecret *string `json:"link_secret"` +} diff --git a/internal/models/vpc.go b/internal/models/vpc.go index fed5fcda7..a94a2fde9 100644 --- a/internal/models/vpc.go +++ b/internal/models/vpc.go @@ -7,13 +7,15 @@ import ( // VPC contains Devices type VPC struct { Base - OrganizationID uuid.UUID `json:"organization_id"` - Description string `json:"description"` - PrivateCidr bool `json:"private_cidr"` - Ipv4Cidr string `json:"ipv4_cidr"` - Ipv6Cidr string `json:"ipv6_cidr"` - - Organization *Organization `json:"-"` + OrganizationID uuid.UUID `json:"organization_id"` + Description string `json:"description"` + PrivateCidr bool `json:"private_cidr"` + Ipv4Cidr string `json:"ipv4_cidr"` + Ipv6Cidr string `json:"ipv6_cidr"` + CaKey string `json:"-"` + CaCertificates []string `json:"ca_certificates,omitempty" gorm:"type:JSONB; serializer:json"` + Organization *Organization `json:"-"` + Revision uint64 `json:"revision" gorm:"type:bigserial;index:"` } type AddVPC struct { diff --git a/internal/routers/routers.go b/internal/routers/routers.go index 9877d08fd..415c1b17d 100644 --- a/internal/routers/routers.go +++ b/internal/routers/routers.go @@ -158,6 +158,13 @@ func NewAPIRouter(ctx context.Context, o APIRouterOptions) (*gin.Engine, error) apiGroup.DELETE("/devices/:id/metadata/:key", api.DeleteDeviceMetadataKey) apiGroup.DELETE("/devices/:id/metadata", api.DeleteDeviceMetadata) + // Sites + apiGroup.GET("/sites", api.ListSites) + apiGroup.GET("/sites/:id", api.GetSite) + apiGroup.PATCH("/sites/:id", api.UpdateSite) + apiGroup.POST("/sites", api.CreateSite) + apiGroup.DELETE("/sites/:id", api.DeleteSite) + // Security Groups apiGroup.GET("/security-groups", api.ListSecurityGroups) apiGroup.GET("/security-groups/:id", api.GetSecurityGroup) @@ -168,9 +175,11 @@ func NewAPIRouter(ctx context.Context, o APIRouterOptions) (*gin.Engine, error) // List / Watch Event API used by nexd apiGroup.POST("/vpcs/:id/events", api.WatchEvents) apiGroup.GET("/vpcs/:id/devices", api.ListDevicesInVPC) + apiGroup.GET("/vpcs/:id/sites", api.ListSitesInVPC) apiGroup.GET("/vpcs/:id/metadata", api.ListMetadataInVPC) apiGroup.GET("/vpcs/:id/security-groups", api.ListSecurityGroupsInVPC) + apiGroup.POST("/ca/sign", api.SignCSR) } privateGroup := r.Group("/private") diff --git a/internal/routers/token.rego b/internal/routers/token.rego index 0892fb135..6d844064a 100644 --- a/internal/routers/token.rego +++ b/internal/routers/token.rego @@ -79,14 +79,14 @@ allow if { } allow if { - "devices" = input.path[1] + input.path[1] in ["devices", "sites"] action_is_read valid_keycloak_token contains(token_payload.scope, "read:devices") } allow if { - "devices" = input.path[1] + input.path[1] in ["devices", "sites"] action_is_write valid_keycloak_token contains(token_payload.scope, "write:devices") @@ -148,30 +148,30 @@ allow if { "me" = input.path[2] } -# reg token can create a device +# reg token can create a device or site allow if { valid_nexodus_token contains(token_payload.scope, "reg-token") input.method == "POST" count(input.path) == 2 - "devices" = input.path[1] + input.path[1] in ["devices", "sites"] } -# reg token can update a device +# reg token can update a device or site allow if { valid_nexodus_token contains(token_payload.scope, "reg-token") input.method == "PATCH" count(input.path) == 3 - "devices" = input.path[1] + input.path[1] in ["devices", "sites"] } -# reg token can get a devices/orgs/vpcs +# reg token can get a devices/orgs/vpcs/sites allow if { valid_nexodus_token contains(token_payload.scope, "reg-token") input.method == "GET" - input.path[1] in ["organizations", "vpcs", "devices"] + input.path[1] in ["organizations", "vpcs", "devices", "sites"] } # device tokens can read/update a device @@ -179,7 +179,7 @@ allow if { valid_nexodus_token contains(token_payload.scope, "device-token") input.method in ["GET", "PATCH"] - "devices" = input.path[1] + input.path[1] in ["devices", "sites"] } allow if { @@ -197,6 +197,12 @@ allow if { contains(token_payload.scope, "device-token") } +allow if { + "ca" = input.path[1] + valid_token + # contains(token_payload.scope, "read:users") +} + action_is_read if input.method in ["GET"] action_is_write := input.method in ["POST", "PATCH", "DELETE", "PUT"] diff --git a/ui/src/App.tsx b/ui/src/App.tsx index d2e523a6b..68e872003 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,11 +1,9 @@ -import { Admin, CustomRoutes, Resource, fetchUtils } from "react-admin"; +import { Admin, CustomRoutes, Resource } from "react-admin"; import { Route } from "react-router"; -import simpleRestProvider from "ra-data-simple-rest"; -import { goOidcAgentAuthProvider } from "./providers/AuthProvider"; - // icons import DeviceIcon from "@mui/icons-material/Devices"; +import SiteIcon from "@mui/icons-material/BorderOuter"; import OrganizationIcon from "@mui/icons-material/People"; import UserIcon from "@mui/icons-material/Person"; import InvitationIcon from "@mui/icons-material/Rsvp"; @@ -13,14 +11,14 @@ import RegKeyIcon from "@mui/icons-material/Key"; import VPCIcon from "@mui/icons-material/Cloud"; // pages -import { UserShow, UserList } from "./pages/Users"; +import { UserList, UserShow } from "./pages/Users"; import { DeviceEdit, DeviceList, DeviceShow } from "./pages/Devices"; import { + OrganizationCreate, OrganizationList, OrganizationShow, - OrganizationCreate, } from "./pages/Organizations"; -import { VPCList, VPCShow, VPCCreate } from "./pages/VPCs"; +import { VPCCreate, VPCList, VPCShow } from "./pages/VPCs"; import Dashboard from "./pages/Dashboard"; import LoginPage from "./pages/Login"; import Layout from "./layout/Layout"; @@ -39,39 +37,8 @@ import { RegKeyList, RegKeyShow, } from "./pages/RegKeys"; - -const fetchJson = (url: string, options: any = {}) => { - // Includes the encrypted session cookie in requests to the API - options.credentials = "include"; - // some of the PUT api calls should be converted to PATCH - if (options.method === "PUT") { - if ( - url.startsWith(`${backend}/api/reg-keys/`) || - url.startsWith(`${backend}/api/devices/`) || - url.startsWith(`${backend}/api/security-groups/`) || - url.startsWith(`${backend}/api/vpcs/`) - ) { - options.method = "PATCH"; - } - } - return fetchUtils.fetchJson(url, options); -}; - -const backend = `${window.location.protocol}//api.${window.location.host}`; -const authProvider = goOidcAgentAuthProvider(backend); -const baseDataProvider = simpleRestProvider( - `${backend}/api`, - fetchJson, - "X-Total-Count", -); -const dataProvider = { - ...baseDataProvider, - getFlag: (name: string) => { - return fetchJson(`${backend}/api/fflags/${name}`).then( - (response) => response, - ); - }, -}; +import { SiteEdit, SiteList, SiteShow } from "./pages/Sites"; +import { authProvider, dataProvider } from "./DataProvider"; const App = () => { return ( @@ -124,6 +91,14 @@ const App = () => { edit={DeviceEdit} recordRepresentation={(record) => `${record.hostname}`} /> + `${record.hostname}`} + /> { + // Includes the encrypted session cookie in requests to the API + options.credentials = "include"; + // some of the PUT api calls should be converted to PATCH + if (options.method === "PUT") { + if ( + url.startsWith(`${backend}/api/reg-keys/`) || + url.startsWith(`${backend}/api/devices/`) || + url.startsWith(`${backend}/api/security-groups/`) || + url.startsWith(`${backend}/api/vpcs/`) + ) { + options.method = "PATCH"; + } + } + return fetchUtils.fetchJson(url, options); +}; +const backend = `${window.location.protocol}//api.${window.location.host}`; +export const authProvider = goOidcAgentAuthProvider(backend); +const baseDataProvider = simpleRestProvider( + `${backend}/api`, + fetchJson, + "X-Total-Count", +); +export const dataProvider = { + ...baseDataProvider, + getFlag: (name: string) => { + return fetchJson(`${backend}/api/fflags/${name}`).then( + (response) => response, + ); + }, + getFlags: async () => { + return (await fetchJson(`${backend}/api/fflags`)).json as { + [index: string]: boolean; + }; + }, +}; diff --git a/ui/src/layout/Menus.tsx b/ui/src/layout/Menus.tsx index 1cc3725e9..e7e13f432 100644 --- a/ui/src/layout/Menus.tsx +++ b/ui/src/layout/Menus.tsx @@ -1,13 +1,27 @@ import { DashboardMenuItem, MenuItemLink, Menu } from "react-admin"; import SecurityIcon from "@mui/icons-material/Security"; import DeviceIcon from "@mui/icons-material/Devices"; +import SiteIcon from "@mui/icons-material/BorderOuter"; import OrganizationIcon from "@mui/icons-material/People"; import InvitationIcon from "@mui/icons-material/Rsvp"; import { MenuProps } from "react-admin"; import RegKeyIcon from "@mui/icons-material/Key"; import VPCIcon from "@mui/icons-material/Cloud"; +import { dataProvider } from "../DataProvider"; +import { useEffect, useState } from "react"; export const CustomMenu = (props: MenuProps) => { + const [flags, setFlags] = useState({} as { [index: string]: boolean }); + useEffect(() => { + (async () => { + try { + setFlags(await dataProvider.getFlags()); + } catch (e) { + console.log(e); + } + })(); + }, []); + return ( @@ -23,24 +37,36 @@ export const CustomMenu = (props: MenuProps) => { leftIcon={} placeholder="" /> - } - placeholder="" - /> + {flags["devices-api"] && ( + } + placeholder="" + /> + )} + {flags["sites-api"] && ( + } + placeholder="" + /> + )} } placeholder="" /> - } - placeholder="" - /> + {flags["devices-api"] && ( + } + placeholder="" + /> + )} ( +
+ + +
+); + +export const SiteList = () => ( + + } + bulkActionButtons={} + > + + + + + + +); + +const SiteAccordion: FC = () => { + const record = useRecordContext(); + if (record && record.id !== undefined) { + return ( + + + + ); + } + return null; +}; + +const SiteAccordionDetails: FC = ({ id }) => { + // Use the same layout as SiteShow + return ( + +
+ +
+
+ ); +}; + +export const SiteShow: FC = () => ( + + + +); + +const SiteShowLayout: FC = () => { + const record = useRecordContext(); + if (!record) return null; + return ( + + + + + + + + + + + ); +}; + +export const SiteEdit = () => { + const { identity, isLoading, error } = useGetIdentity(); + if (isLoading || error) { + return
; + } + return ( + + + + + + ); +};