import r from 'jsrsasign'; import { CERTMANAGER, KUBERNETES, UI_PROJECT_SECRET, UI_PROJECT_SECRET_COPY } from '@shell/config/labels-annotations'; import { base64Decode, base64Encode } from '@shell/utils/crypto'; import { removeObjects } from '@shell/utils/array'; import { MANAGEMENT, SERVICE_ACCOUNT, VIRTUAL_TYPES } from '@shell/config/types'; import { set } from '@shell/utils/object'; import { NAME as MANAGER } from '@shell/config/product/manager'; import SteveModel from '@shell/plugins/steve/steve-class'; import { colorForState, stateDisplay, STATES_ENUM } from '@shell/plugins/dashboard-store/resource-class'; import { diffFrom } from '@shell/utils/time'; import day from 'dayjs'; import { steveCleanForDownload } from '@shell/plugins/steve/resource-utils'; import { STORE } from '@shell/store/store-types'; import { escapeHtml } from '@shell/utils/string'; export const TYPES = { OPAQUE: 'Opaque', SERVICE_ACCT: 'kubernetes.io/service-account-token', DOCKER: 'kubernetes.io/dockercfg', DOCKER_JSON: 'kubernetes.io/dockerconfigjson', BASIC: 'kubernetes.io/basic-auth', SSH: 'kubernetes.io/ssh-auth', TLS: 'kubernetes.io/tls', BOOTSTRAP: 'bootstrap.kubernetes.io/token', ISTIO_TLS: 'istio.io/key-and-cert', HELM_RELEASE: 'helm.sh/release.v1', FLEET_CLUSTER: 'fleet.cattle.io/cluster-registration-values', CLOUD_CREDENTIAL: 'provisioning.cattle.io/cloud-credential', RKE_AUTH_CONFIG: 'rke.cattle.io/auth-config' }; /** Class a cert as expiring if in eight days */ const certExpiringPeriod = 1000 * 60 * 60 * 24 * 8; export default class Secret extends SteveModel { _cachedCertInfo; get hasSensitiveData() { return true; } get isCertificate() { return this._type === TYPES.TLS; } get isRegistry() { return this._type === TYPES.DOCKER_JSON; } get isCloudCredential() { return this._type === TYPES.CLOUD_CREDENTIAL || (this.metadata.namespace === 'cattle-global-data' && this.metadata.generateName === 'cc-'); } // For Fleet SSH secrets - does the secret have the 'known_hosts' data key? get supportsSshKnownHosts() { return this._type === TYPES.SSH && !!this.data && 'known_hosts' in this.data; } get issuer() { const { metadata:{ annotations = {} } } = this; if (annotations[CERTMANAGER.ISSUER]) { return annotations[CERTMANAGER.ISSUER]; } else if (this.isCertificate) { return this.cachedCertInfo?.issuer; } else { return null; } } get notAfter() { if (this.isCertificate) { return this.cachedCertInfo?.notAfter; } else { return null; } } get cn() { if (this.isCertificate) { return this.cachedCertInfo?.cn; } return null; } // show plus n more for cert names get plusMoreNames() { if (this.isCertificate) { return this.unrepeatedSans.length; } return null; } // use text-warning' or 'text-error' if cert is expiring within 8 days or is expired get dateClass() { if (this.isCertificate) { switch (this.certState) { case STATES_ENUM.EXPIRING: return 'text-warning'; case STATES_ENUM.EXPIRED: return 'text-error'; default: return ''; } } return null; } get details() { const out = [ { label: this.t('secret.type'), content: this.typeDisplay } ]; if ( this._type === TYPES.SERVICE_ACCT ) { const name = this.metadata?.annotations?.[KUBERNETES.SERVICE_ACCOUNT_NAME]; if ( name ) { out.push({ label: 'Service Account', formatter: 'LinkName', formatterOpts: { value: name, type: SERVICE_ACCOUNT, namespace: this.namespace, }, content: name, }); } } if (this.cn) { out.push({ label: this.t('secret.certificate.cn'), content: this.plusMoreNames ? `${ this.cn } ${ this.t('secret.certificate.plusMore', { n: this.plusMoreNames }) }` : this.cn }); } if (this.issuer) { out.push({ label: this.t('secret.certificate.issuer'), content: this.issuer }); } if (this.notAfter) { out.push({ label: 'Expires', formatter: 'Date', formatterOpts: { class: this.dateClass }, content: this.notAfter }); } return out; } get canUpdate() { if (this.isProjectSecretCopy) { return false; } if ( !this.hasLink('update') ) { return false; } if ( this._type === TYPES.SERVICE_ACCT ) { return false; } return this.$rootGetters['type-map/optionsFor'](this.type).isEditable; } get canDelete() { // Deleting a copy / synced secret that was created in a namespace in a project that has a project scoped secret // will only be temporary (the parent project scoped secret will recreate it) return this.isProjectSecretCopy ? false : super.canDelete; } get canCreate() { return this.isProjectSecretCopy ? false : super.canCreate; } get canEditYaml() { return this.isProjectSecretCopy ? false : super.canEditYaml; } get keysDisplay() { const keys = [ ...Object.keys(this.data || []), ...Object.keys(this.binaryData || []) ]; if ( !keys.length ) { return '(none)'; } // if ( keys.length >= 4 ) { // return `${keys[0]}, ${keys[1]}, ${keys[2]} and ${keys.length - 3} more`; // } return keys.join(', '); } // decode some secret data to show in list view get dataPreview() { if (this._type === TYPES.DOCKER_JSON) { const encodedJSON = this.data['.dockerconfigjson']; if (encodedJSON) { const decodedJSON = base64Decode(encodedJSON); try { const auths = JSON.parse(decodedJSON).auths; const out = []; for (const domain in auths) { out.push(domain); } return out.join(', '); } catch (e) { return decodedJSON; } } } else if (this._type === TYPES.TLS) { return this.certInfo || this.keysDisplay; } else if ( this._type === TYPES.BASIC ) { return base64Decode(this.data.username); } else if ( this._type === TYPES.SSH ) { return this.sshUser; } else if ( this._type === TYPES.SERVICE_ACCT ) { return this.metadata?.annotations?.['kubernetes.io/service-account.name']; } return this.keysDisplay; } get sshUser() { if ( this._type !== TYPES.SSH ) { return null; } const pub = base64Decode(this.data['ssh-publickey']); if ( !pub ) { return null; } if ( pub.startsWith('----') ) { // PEM format const match = pub.match(/from OpenSSH by ([^"]+)"/); if ( match ) { return match[1]; } } else if ( pub.startsWith('ssh-') ) { // OpenSSH format const parts = pub.replace(/\n/g, '').split(/\s+/); if ( parts && parts.length === 3 ) { return parts[2]; } } return null; } get subTypeDisplay() { const type = this._type || ''; const fallback = type.replace(/^kubernetes.io\//, ''); return this.$rootGetters['i18n/withFallback'](`secret.types."${ type }"`, null, fallback); } // parse TLS certs and return issuer, notAfter, cn, sans get certInfo() { const pem = base64Decode(this.data['tls.crt']); let issuer, notBefore, notAfter, cn, sans, x; const END_MARKER = '-----END CERTIFICATE-----'; if (pem) { const certs = pem.split(END_MARKER); let first = pem; if (certs.length > 1) { first = `${ certs[0] }${ END_MARKER }`; } try { x = new r.X509(); x.readCertPEM(first); const issuerString = x.getIssuerString(); issuer = issuerString.slice(issuerString.indexOf('CN=') + 3); notBefore = r.zulutodate(x.getNotBefore()); notAfter = r.zulutodate(x.getNotAfter()); const cnString = x.getSubjectString(); cn = cnString.slice(cnString.indexOf('CN=') + 3); } catch { return null; } try { sans = x.getExtSubjectAltName(); } catch (e) { sans = []; } const certInfo = { issuer, notBefore, notAfter, cn, sans }; return certInfo; } return null; } get cachedCertInfo() { if (!this._cachedCertInfo) { this._cachedCertInfo = this.certInfo; } return this._cachedCertInfo; } // use for + n more name display get unrepeatedSans() { if (this._type === TYPES.TLS ) { const certInfo = this.cachedCertInfo; if (certInfo?.sans?.filter) { const commonBases = certInfo?.sans .filter((name) => name.indexOf('*.') === 0 || name.indexOf('www.') === 0) .map((name) => name.substr(name.indexOf('.'))); const displaySans = removeObjects(certInfo?.sans, commonBases); return displaySans; } return certInfo?.sans?.array || certInfo?.sans || []; } return null; } get timeTilExpiration() { if (this._type === TYPES.TLS) { const certInfo = this.cachedCertInfo; if (!certInfo?.notAfter) { return null; } const expiration = certInfo.notAfter; const timeThen = expiration.valueOf(); const timeNow = Date.now(); const timeTilExpiration = timeThen - timeNow; return timeTilExpiration < 0 ? 0 : timeTilExpiration; } return null; } get timeTilExpirationDate() { return this.timeTilExpiration > 0 ? this.cachedCertInfo?.notAfter?.valueOf() : null; } get decodedData() { const out = {}; for ( const k in this.data || {} ) { out[k] = base64Decode(this.data[k]); } return out; } get setData() { return (key, value) => { // or (mapOfNewData) const isMap = key && typeof key === 'object'; if ( !this.data || isMap ) { set(this, 'data', {}); } let neu; if ( isMap ) { neu = key; } else { neu = { [key]: value }; } for ( const k in neu ) { // The key is quoted so that keys like '.dockerconfigjson' that contain dot don't get parsed into an object path set(this.data, `"${ k }"`, base64Encode(neu[k])); } }; } get doneRoute() { if ( this.$rootGetters['currentProduct'].name === MANAGER ) { return 'c-cluster-manager-secret'; } else { return 'c-cluster-product-resource'; } } get certLifetime() { if (this._type === TYPES.TLS) { const certInfo = this.cachedCertInfo; if (certInfo) { return diffFrom(day(certInfo.notBefore), day(certInfo.notAfter), (key, args) => this.t(key, args)).string; } } return null; } /** * Get the model `state` for secrets of type cert */ get certState() { if (this._type !== TYPES.TLS) { return undefined; } if (typeof this.timeTilExpiration !== 'number' || this.timeTilExpiration > certExpiringPeriod ) { return ''; } else if (this.timeTilExpiration > 0) { return STATES_ENUM.EXPIRING; } else { return STATES_ENUM.EXPIRED; } } /** * Get the model `state display` for secrets of type cert */ get certStateDisplay() { if (this._type !== TYPES.TLS) { return undefined; } return stateDisplay(this.certState); } /** * Get the model `state background` for secrets of type cert */ get certStateBackground() { if (this._type !== TYPES.TLS) { return undefined; } const color = colorForState(this.certState); return color.replace('text-', 'bg-'); } cleanForSave(data, forNew) { const val = super.cleanForSave(data, forNew); // Secrets on create with _type will return validation error // Secrets on edit without _type will return http error if (forNew) { delete val._type; } return val; } async cleanForDownload(yaml) { // secret resource contains the type attribute // ref: https://kubernetes.io/docs/reference/kubernetes-api/config-and-storage-resources/secret-v1/ // ref: https://kubernetes.io/docs/concepts/configuration/secret/#secret-types return steveCleanForDownload(yaml, { rootKeys: ['id', 'links', 'actions'] }); } /** * is this a project scoped secret .... or also a cloned project scoped secret */ get isProjectScopedRelated() { return !!this.metadata.labels?.[UI_PROJECT_SECRET]; } /** * is this a project scoped secret */ get isProjectScoped() { return this.isProjectScopedRelated && !this.isProjectSecretCopy && this.$rootGetters['isRancher']; } get projectScopedClusterId() { if (!this.projectScopedProjectId) { return undefined; } const clusterId = this.metadata.namespace.replace(`-${ this.projectScopedProjectId }`, ''); // default and system pss don't follow the patter of -, so if they match assume its one of them return clusterId === this.metadata.namespace ? 'local' : clusterId; } get projectScopedProjectId() { return this.metadata.labels?.[UI_PROJECT_SECRET]; } get isProjectSecretCopy() { return this.metadata?.annotations?.[UI_PROJECT_SECRET_COPY] === 'true'; } get projectCluster() { if (!this.isProjectScoped) { return undefined; } return this.$rootGetters[`${ STORE.MANAGEMENT }/byId`](MANAGEMENT.CLUSTER, this.projectScopedClusterId); } /** * If this is a project scoped secret, return it */ get project() { if (!this.isProjectScoped ) { return undefined; } return this.$rootGetters[`${ STORE.MANAGEMENT }/byId`](MANAGEMENT.PROJECT, `${ this.projectScopedClusterId }/${ this.projectScopedProjectId }`); } get projectScopedSecretCluster() { if (!this.isProjectScoped ) { return undefined; } return this.$rootGetters[`${ STORE.MANAGEMENT }/byId`](MANAGEMENT.PROJECT, `${ this.projectScopedClusterId }/${ this.projectScopedProjectId }`); } get clusterAndProjectLabel() { if (!this.isProjectScoped) { return ''; } const clusterName = this.projectCluster?.nameDisplay; // project is going to be empty if upstream and trying to show pss from downstream clusters // we only ever have the current clusters projects. if we change this (fetch them in the list) // we wipe out the header ns filter projects const projectName = this.project?.nameDisplay || this.projectScopedProjectId; if (clusterName && projectName) { return `${ projectName } (${ clusterName })`; } return ''; } get detailLocation() { if (this.isProjectScoped) { const id = this.id?.replace(/.*\//, ''); return { name: `c-cluster-product-${ VIRTUAL_TYPES.PROJECT_SECRETS }-namespace-id`, params: { product: this.$rootGetters['productId'], cluster: this.$rootGetters['clusterId'], namespace: this.metadata?.namespace, resource: VIRTUAL_TYPES.PROJECT_SECRETS, id, } }; } return this._detailLocation; } get listLocation() { if (!this.isProjectScoped) { return super.listLocation; } return { name: 'c-cluster-product-resource', params: { product: this.$rootGetters['productId'], cluster: this.$rootGetters['clusterId'], resource: VIRTUAL_TYPES.PROJECT_SECRETS, } }; } get parentNameOverride() { if (!this.isProjectScoped) { return super.parentNameOverride; } return this.$rootGetters['i18n/t'](`typeLabel."${ VIRTUAL_TYPES.PROJECT_SECRETS }"`, { count: 1 })?.trim(); } get parentLocationOverride() { if (!this.isProjectScoped) { return super.parentNameOverride; } return this.listLocation; } get groupByProject() { if (!this.isProjectScoped) { return undefined; } return this.t('resourceTable.groupLabel.project', { name: escapeHtml(this?.project?.nameDisplay || '') }, true); } get fullDetailPageOverride() { return true; } }