From 3855e6d39b54e09a00393c28a625b9816557e361 Mon Sep 17 00:00:00 2001 From: Ronni Skansing Date: Tue, 4 Nov 2025 20:00:59 +0100 Subject: [PATCH] self signed certificates Signed-off-by: Ronni Skansing --- backend/database/domain.go | 13 +- backend/model/domain.go | 34 ++++- backend/repository/domain.go | 3 + backend/service/domain.go | 165 ++++++++++++++++++++++++ frontend/src/lib/api/api.js | 6 + frontend/src/routes/domain/+page.svelte | 49 ++++++- 6 files changed, 255 insertions(+), 15 deletions(-) diff --git a/backend/database/domain.go b/backend/database/domain.go index 445ba9b..8961702 100644 --- a/backend/database/domain.go +++ b/backend/database/domain.go @@ -12,17 +12,18 @@ const ( // Domain is gorm data model type Domain struct { - ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid;"` - CreatedAt *time.Time `gorm:"not null;index;"` - UpdatedAt *time.Time `gorm:"not null;index;"` - CompanyID *uuid.UUID `gorm:"index;type:uuid;"` + ID uuid.UUID `gorm:"primary_key;not null;unique;type:uuid;"` + CreatedAt *time.Time `gorm:"not null;index;"` + UpdatedAt *time.Time `gorm:"not null;index;"` + CompanyID *uuid.UUID `gorm:"index;type:uuid;"` ProxyID *uuid.UUID `gorm:"index;type:uuid;"` - Name string `gorm:"not null;unique;"` - Type string `gorm:"not null;default:'regular';"` + Name string `gorm:"not null;unique;"` + Type string `gorm:"not null;default:'regular';"` ProxyTargetDomain string ManagedTLSCerts bool `gorm:"not null;index;default:false"` OwnManagedTLS bool `gorm:"not null;index;default:false"` + SelfSignedTLS bool `gorm:"not null;index;default:false"` HostWebsite bool `gorm:"not null;"` PageContent string PageNotFoundContent string diff --git a/backend/model/domain.go b/backend/model/domain.go index b9ea991..da55fe6 100644 --- a/backend/model/domain.go +++ b/backend/model/domain.go @@ -22,6 +22,7 @@ type Domain struct { HostWebsite nullable.Nullable[bool] `json:"hostWebsite"` ManagedTLS nullable.Nullable[bool] `json:"managedTLS"` OwnManagedTLS nullable.Nullable[bool] `json:"ownManagedTLS"` + SelfSignedTLS nullable.Nullable[bool] `json:"selfSignedTLS"` // private key OwnManagedTLSKey nullable.Nullable[string] `json:"ownManagedTLSKey"` // cert @@ -84,10 +85,28 @@ func (d *Domain) Validate() error { ownManagedTLS, err := d.OwnManagedTLS.Get() ownManagedTLSSet := err == nil && ownManagedTLS - // cant both have managed and own managed tls - if managedTLS, err := d.ManagedTLS.Get(); err == nil && managedTLS && ownManagedTLSSet { + selfSignedTLS, err := d.SelfSignedTLS.Get() + selfSignedTLSSet := err == nil && selfSignedTLS + + managedTLS, err := d.ManagedTLS.Get() + managedTLSSet := err == nil && managedTLS + + // count how many TLS options are enabled + tlsCount := 0 + if managedTLSSet { + tlsCount++ + } + if ownManagedTLSSet { + tlsCount++ + } + if selfSignedTLSSet { + tlsCount++ + } + + // only one TLS option can be enabled at a time + if tlsCount > 1 { return errs.NewValidationError(errors.New( - "Domain TLS can not both be managed and own managed", + "Domain TLS can only have one option enabled: managed, own managed, or self-signed", )) } if ownManagedTLS { @@ -189,6 +208,14 @@ func (d *Domain) ToDBMap() map[string]any { m["own_managed_tls"] = d.OwnManagedTLS.MustGet() } } + if d.SelfSignedTLS.IsSpecified() { + m["self_signed_tls"] = false + if d.SelfSignedTLS.IsNull() { + m["self_signed_tls"] = nil + } else { + m["self_signed_tls"] = d.SelfSignedTLS.MustGet() + } + } if d.ProxyID.IsSpecified() { if d.ProxyID.IsNull() { m["proxy_id"] = nil @@ -210,6 +237,7 @@ type DomainOverview struct { HostWebsite bool `json:"hostWebsite"` ManagedTLS bool `json:"managedTLS"` OwnManagedTLS bool `json:"ownManagedTLS"` + SelfSignedTLS bool `json:"selfSignedTLS"` RedirectURL string `json:"redirectURL"` CompanyID *uuid.UUID `json:"companyID"` ProxyID *uuid.UUID `json:"proxyID"` diff --git a/backend/repository/domain.go b/backend/repository/domain.go index 51dc482..7f8a461 100644 --- a/backend/repository/domain.go +++ b/backend/repository/domain.go @@ -285,6 +285,7 @@ func ToDomain(row *database.Domain) *model.Domain { managedTLS := nullable.NewNullableWithValue(row.ManagedTLSCerts) ownManagedTLS := nullable.NewNullableWithValue(row.OwnManagedTLS) + selfSignedTLS := nullable.NewNullableWithValue(row.SelfSignedTLS) hostWebsite := nullable.NewNullableWithValue(row.HostWebsite) staticPage := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.PageContent)) staticNotFound := nullable.NewNullableWithValue(*vo.NewOptionalString1MBMust(row.PageNotFoundContent)) @@ -299,6 +300,7 @@ func ToDomain(row *database.Domain) *model.Domain { ProxyTargetDomain: proxyTargetDomain, ManagedTLS: managedTLS, OwnManagedTLS: ownManagedTLS, + SelfSignedTLS: selfSignedTLS, HostWebsite: hostWebsite, PageContent: staticPage, PageNotFoundContent: staticNotFound, @@ -351,6 +353,7 @@ func ToDomainSubset(dbDomain *database.Domain) *model.DomainOverview { HostWebsite: dbDomain.HostWebsite, ManagedTLS: dbDomain.ManagedTLSCerts, OwnManagedTLS: dbDomain.OwnManagedTLS, + SelfSignedTLS: dbDomain.SelfSignedTLS, RedirectURL: dbDomain.RedirectURL, CompanyID: dbDomain.CompanyID, ProxyID: dbDomain.ProxyID, diff --git a/backend/service/domain.go b/backend/service/domain.go index 28e534e..8fd7569 100644 --- a/backend/service/domain.go +++ b/backend/service/domain.go @@ -17,6 +17,7 @@ import ( "github.com/caddyserver/certmagic" "github.com/google/uuid" "github.com/oapi-codegen/nullable" + "github.com/phishingclub/phishingclub/acme" "github.com/phishingclub/phishingclub/build" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/errs" @@ -156,6 +157,10 @@ func (d *Domain) createDomain( if err != nil { return nil, errs.Wrap(err) } + domain, err = d.handleSelfSignedTLS(ctx, domain) + if err != nil { + return nil, errs.Wrap(err) + } // create domain createdDomainID, err := d.DomainRepository.Insert( ctx, @@ -579,6 +584,12 @@ func (d *Domain) updateDomain( current.OwnManagedTLS.Set(v) ownManagedTLSIsSet = v } + wasSelfSignedTLS := current.SelfSignedTLS.MustGet() + selfSignedTLSIsSet := false + if v, err := incoming.SelfSignedTLS.Get(); err == nil { + current.SelfSignedTLS.Set(v) + selfSignedTLSIsSet = v + } ownManagedTLSKeyIsSet := false if v, err := incoming.OwnManagedTLSKey.Get(); err == nil { current.OwnManagedTLSKey.Set(v) @@ -629,6 +640,13 @@ func (d *Domain) updateDomain( d.Logger.Warnf("failed to remove own managed TLS", "error", err) } } + // if previously was self-signed but not anymore, remove the certs and cache + if wasSelfSignedTLS && !selfSignedTLSIsSet { + err = d.removeSelfSignedTLS(current) + if err != nil { + d.Logger.Warnf("failed to remove self-signed TLS", "error", err) + } + } // if previously own managed TLS and now is own managed if !wasOwnManagedTLS && ownManagedTLSIsSet { if ownManagedTLSKeyIsSet && ownManagedTLSPemIsSet { @@ -652,6 +670,13 @@ func (d *Domain) updateDomain( } } } + // if previously not self-signed and now is self-signed + if !wasSelfSignedTLS && selfSignedTLSIsSet { + current, err = d.handleSelfSignedTLS(ctx, current) + if err != nil { + return fmt.Errorf("failed to handle self-signed TLS: %s", err) + } + } // when updating, the own managed tls can previous be set with uploaded // key and cert, so only if all of them are provided, we handle them // update domain @@ -754,6 +779,20 @@ func (d *Domain) deleteDomain( if domain.ManagedTLS.MustGet() { d.removeManagedDomainTLS(ctx, domain.Name.MustGet().String()) } + // clean up if TLS was own managed + if domain.OwnManagedTLS.MustGet() { + err = d.removeOwnManagedTLS(domain) + if err != nil { + d.Logger.Warnf("failed to remove own managed TLS during deletion", "error", err) + } + } + // clean up if TLS was self-signed + if domain.SelfSignedTLS.MustGet() { + err = d.removeSelfSignedTLS(domain) + if err != nil { + d.Logger.Warnf("failed to remove self-signed TLS during deletion", "error", err) + } + } d.AuditLogAuthorized(ae) return nil } @@ -931,6 +970,132 @@ func (d *Domain) removeOwnManagedTLS( return nil } +func (d *Domain) handleSelfSignedTLS( + ctx context.Context, + domain *model.Domain) (*model.Domain, error) { + selfSignedTLS, err := domain.SelfSignedTLS.Get() + if err != nil || !selfSignedTLS { + return domain, nil + } + + name := domain.Name.MustGet().String() + + // create root filesystem for secure certificate operations + root, err := os.OpenRoot(d.OwnManagedCertificatePath) + if err != nil { + return nil, fmt.Errorf("failed to open certificate path: %s", err) + } + defer root.Close() + + // validate domain name directory access + _, err = root.Stat(name) + if err != nil && !os.IsNotExist(err) { + return nil, fmt.Errorf("invalid domain name for certificate path: %s", err) + } + + // build path for certificate operations + certDir := filepath.Join(d.OwnManagedCertificatePath, name) + certKeyPath := filepath.Join(certDir, "cert.key") + certPemPath := filepath.Join(certDir, "cert.pem") + + // generate self-signed certificate + info := acme.NewInformationWithDefault() + err = acme.CreateSelfSignedCert( + d.Logger, + info, + []string{name}, + certPemPath, + certKeyPath, + ) + if err != nil { + d.Logger.Errorw( + "failed to generate self-signed certificate", + "domain", name, + "error", err, + ) + return nil, errs.Wrap(err) + } + + // read generated certificate files for caching + keyBytes, err := os.ReadFile(certKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to read generated key file: %s", err) + } + + pemBytes, err := os.ReadFile(certPemPath) + if err != nil { + return nil, fmt.Errorf("failed to read generated cert file: %s", err) + } + + // cache in certmagic + hash, err := d.CertMagicConfig.CacheUnmanagedCertificatePEMBytes( + ctx, + pemBytes, + keyBytes, + []string{name}, + ) + if err != nil { + d.Logger.Errorw( + "failed to cache self-signed cert for", name, + "error", err, + ) + return nil, errs.Wrap(err) + } + + d.Logger.Debugw("cached self-signed TLS", + "domain", name, + "hash", hash, + ) + + domain.SelfSignedTLS = nullable.NewNullableWithValue(true) + domain.ManagedTLS = nullable.NewNullableWithValue(false) + domain.OwnManagedTLS = nullable.NewNullableWithValue(false) + + return domain, nil +} + +func (d *Domain) removeSelfSignedTLS( + domain *model.Domain, +) error { + name := domain.Name.MustGet().String() + + // create root filesystem for secure certificate operations + root, err := os.OpenRoot(d.OwnManagedCertificatePath) + if err != nil { + return fmt.Errorf("failed to open certificate path for '%s': %s", name, err) + } + defer root.Close() + + // validate domain name directory exists + _, err = root.Stat(name) + if err != nil { + if os.IsNotExist(err) { + // directory doesn't exist, nothing to delete + return nil + } + return fmt.Errorf("failed to access certificate directory for '%s': %s", name, err) + } + + // build safe path for deletion (validated by OpenRoot) + path := filepath.Join(d.OwnManagedCertificatePath, name) + err = d.FileService.DeleteAll(path) + if err != nil { + return fmt.Errorf("failed to delete self-signed TLS for '%s' as: %s", name, err) + } + d.Logger.Debugw("removed storage for self-signed TLS", "name", name) + + // remove from certmagic cache + certs := d.CertMagicCache.AllMatchingCertificates(name) + for _, cert := range certs { + d.CertMagicCache.Remove([]string{cert.Hash()}) + d.Logger.Debugw("removed cached TLS", + "domain", name, + "hash", cert.Hash(), + ) + } + return nil +} + // validateProxyDomain validates proxy domain configuration func (d *Domain) validateProxyDomain(ctx context.Context, domain *model.Domain) error { // validate proxy target domain format diff --git a/frontend/src/lib/api/api.js b/frontend/src/lib/api/api.js index 2e7286e..341c9ae 100644 --- a/frontend/src/lib/api/api.js +++ b/frontend/src/lib/api/api.js @@ -1198,6 +1198,7 @@ export class API { * @param {string} domain.proxyTargetDomain * @param {boolean} domain.managedTLS * @param {boolean} domain.ownManagedTLS + * @param {boolean} domain.selfSignedTLS * @param {string} domain.ownManagedTLSKey * @param {string} domain.ownManagedTLSPem * @param {boolean} domain.hostWebsite @@ -1213,6 +1214,7 @@ export class API { proxyTargetDomain, managedTLS, ownManagedTLS, + selfSignedTLS, ownManagedTLSKey, ownManagedTLSPem, hostWebsite, @@ -1227,6 +1229,7 @@ export class API { proxyTargetDomain: proxyTargetDomain, managedTLS: managedTLS, ownManagedTLS: ownManagedTLS, + selfSignedTLS: selfSignedTLS, ownManagedTLSKey: ownManagedTLSKey, ownManagedTLSPem: ownManagedTLSPem, hostWebsite: hostWebsite, @@ -1246,6 +1249,7 @@ export class API { * @param {string} [domain.proxyTargetDomain] * @param {boolean} domain.managedTLS * @param {boolean} domain.ownManagedTLS + * @param {boolean} domain.selfSignedTLS * @param {string} domain.ownManagedTLSKey * @param {string} domain.ownManagedTLSPem * @param {boolean} [domain.hostWebsite] @@ -1261,6 +1265,7 @@ export class API { proxyTargetDomain, managedTLS, ownManagedTLS, + selfSignedTLS, ownManagedTLSKey, ownManagedTLSPem, hostWebsite, @@ -1272,6 +1277,7 @@ export class API { const payload = { managedTLS: managedTLS, ownManagedTLS: ownManagedTLS, + selfSignedTLS: selfSignedTLS, ownManagedTLSKey: ownManagedTLSKey, ownManagedTLSPem: ownManagedTLSPem, companyID: companyID diff --git a/frontend/src/routes/domain/+page.svelte b/frontend/src/routes/domain/+page.svelte index 9e891f8..eef5135 100644 --- a/frontend/src/routes/domain/+page.svelte +++ b/frontend/src/routes/domain/+page.svelte @@ -50,6 +50,7 @@ name: null, managedTLS: true, // managed TLS ownManagedTLS: false, // custom certificates + selfSignedTLS: false, // self-signed certificates ownManagedTLSKey: null, ownManagedTLSPem: null, hostWebsite: true, @@ -102,6 +103,23 @@ modalText = ''; modalText = getModalText('domain', modalMode); } + + // handle mutual exclusivity of TLS options + $: if (formValues.managedTLS) { + formValues.ownManagedTLS = false; + formValues.selfSignedTLS = false; + } + + $: if (formValues.selfSignedTLS) { + formValues.managedTLS = false; + formValues.ownManagedTLS = false; + } + + $: if (formValues.ownManagedTLS) { + formValues.managedTLS = false; + formValues.selfSignedTLS = false; + } + // hooks onMount(() => { const context = appStateService.getContext(); @@ -192,6 +210,7 @@ id: formValues.id, managedTLS: formValues.managedTLS, ownManagedTLS: formValues.ownManagedTLS, + selfSignedTLS: formValues.selfSignedTLS, ownManagedTLSKey: formValues.ownManagedTLSKey, ownManagedTLSPem: formValues.ownManagedTLSPem, companyID: contextCompanyID @@ -200,10 +219,11 @@ // for regular domains, send all fields updateData = { id: formValues.id, - type: 'regular', - proxyTargetDomain: '', + type: isProxyDomain ? 'proxy' : 'regular', + proxyTargetDomain: formValues.proxyTargetDomain || '', managedTLS: formValues.managedTLS, ownManagedTLS: formValues.ownManagedTLS, + selfSignedTLS: formValues.selfSignedTLS, ownManagedTLSKey: formValues.ownManagedTLSKey, ownManagedTLSPem: formValues.ownManagedTLSPem, hostWebsite: formValues.hostWebsite, @@ -289,6 +309,7 @@ proxyTargetDomain: '', managedTLS: formValues.managedTLS, ownManagedTLS: formValues.ownManagedTLS, + selfSignedTLS: formValues.selfSignedTLS, ownManagedTLSKey: formValues.ownManagedTLSKey, ownManagedTLSPem: formValues.ownManagedTLSPem, hostWebsite: formValues.hostWebsite, @@ -328,6 +349,7 @@ id: formValues.id, managedTLS: formValues.managedTLS, ownManagedTLS: formValues.ownManagedTLS, + selfSignedTLS: formValues.selfSignedTLS, ownManagedTLSKey: formValues.ownManagedTLSKey, ownManagedTLSPem: formValues.ownManagedTLSPem, companyID: contextCompanyID @@ -340,6 +362,7 @@ proxyTargetDomain: '', managedTLS: formValues.managedTLS, ownManagedTLS: formValues.ownManagedTLS, + selfSignedTLS: formValues.selfSignedTLS, ownManagedTLSKey: formValues.ownManagedTLSKey, ownManagedTLSPem: formValues.ownManagedTLSPem, hostWebsite: formValues.hostWebsite, @@ -420,13 +443,14 @@ name: domain.name, managedTLS: domain.managedTLS, ownManagedTLS: domain.ownManagedTLS, - ownManagedTLSKey: null, - ownManagedTLSPem: null, + selfSignedTLS: domain.selfSignedTLS, + ownManagedTLSKey: domain.ownManagedTLSKey, + ownManagedTLSPem: domain.ownManagedTLSPem, hostWebsite: domain.hostWebsite, pageContent: domain.pageContent, pageNotFoundContent: domain.pageNotFoundContent, redirectURL: domain.redirectURL, - staticContent: domain.staticContent + staticContent: domain.staticContent || '' }; // Store domain object for proxy info display @@ -497,6 +521,7 @@ name: domain.name, managedTLS: domain.managedTLS, ownManagedTLS: domain.ownManagedTLS, + selfSignedTLS: domain.selfSignedTLS, ownManagedTLSKey: null, ownManagedTLSPem: null, hostWebsite: domain.hostWebsite, @@ -528,6 +553,7 @@ name: null, managedTLS: true, // managed TLS ownManagedTLS: false, // custom certificates + selfSignedTLS: false, // self-signed certificates ownManagedTLSKey: null, ownManagedTLSPem: null, hostWebsite: true, @@ -565,6 +591,7 @@ name: domain.name, managedTLS: domain.managedTLS, ownManagedTLS: domain.ownManagedTLS, + selfSignedTLS: domain.selfSignedTLS, ownManagedTLSKey: null, ownManagedTLSPem: null, hostWebsite: domain.hostWebsite, @@ -660,7 +687,7 @@ - +
@@ -796,6 +823,16 @@ toolTipText="Managed TLS via. public certificate authority" /> + +