diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index 763323c0..f276e943 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -9,6 +9,7 @@ metadata: - 300 - 313 - 2256 + - 1964 sources: root: /root/.unidesk/.state/secrets @@ -120,6 +121,27 @@ sources: createIfMissing: enabled: false externalFiles: + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + type: env + requiredKeys: + - SELFMEDIA_ADMIN_USERNAME + - SELFMEDIA_ADMIN_PASSWORD + - SELFMEDIA_API_KEY + - SELFMEDIA_SESSION_SECRET + createIfMissing: + enabled: true + values: + SELFMEDIA_ADMIN_USERNAME: admin + randomBase64Url: + SELFMEDIA_ADMIN_PASSWORD: + bytes: 32 + prefix: smp_ + SELFMEDIA_API_KEY: + bytes: 32 + prefix: smk_ + SELFMEDIA_SESSION_SECRET: + bytes: 32 + prefix: sms_ - sourceRef: ~/.env/TOKEN type: raw-file required: true @@ -167,6 +189,23 @@ targets: enabled: true kubernetesSecrets: + - name: selfmedia-access + targetId: selfmedia-nc01 + secretName: selfmedia-access + type: Opaque + data: + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_ADMIN_USERNAME + targetKey: admin-username + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_ADMIN_PASSWORD + targetKey: admin-password + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_API_KEY + targetKey: api-key + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_SESSION_SECRET + targetKey: session-secret - name: selfmedia-runtime targetId: selfmedia-nc01 secretName: selfmedia-runtime diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml index 82dacec9..ec4d9586 100644 --- a/config/selfmedia.yaml +++ b/config/selfmedia.yaml @@ -172,6 +172,20 @@ delivery: mountPath: /home/codex/.codex/state subPath: state secrets: + access: + sourceRef: /root/.unidesk/.env/selfmedia-access.env + target: + secretName: selfmedia-access + readOnly: true + files: + - targetKey: admin-username + mountPath: /run/secrets/selfmedia-access/admin-username + - targetKey: admin-password + mountPath: /run/secrets/selfmedia-access/admin-password + - targetKey: api-key + mountPath: /run/secrets/selfmedia-access/api-key + - targetKey: session-secret + mountPath: /run/secrets/selfmedia-access/session-secret runtime: sourceRef: ~/.env/TOKEN target: diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 3ae1a934..ce94512e 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -40,7 +40,7 @@ interface SecretDistributionConfig { version: number; kind: "unidesk-secret-distribution"; metadata: { id: string; owner: string; relatedIssues: number[] }; - sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] }; + sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: SourceFileConfig[] }; targets: DistributionTarget[]; kubernetesSecrets: KubernetesSecretConfig[]; } @@ -311,7 +311,7 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig files: arrayOfRecords(sources.files, `${label}.sources.files`).map((item, index) => parseSourceFile(item, `${label}.sources.files[${index}]`)), externalFiles: sources.externalFiles === undefined ? [] - : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)), + : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseExternalSourceFile(item, `${label}.sources.externalFiles[${index}]`)), }, targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)), kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)), @@ -320,13 +320,13 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig return config; } -function parseSourceFile(record: Record, path: string): EnvSourceFileConfig { +function parseSourceFile(record: Record, path: string, external = false): EnvSourceFileConfig { const type = stringField(record, "type", path); if (type !== "env") throw new Error(`${path}.type must be env`); const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path); const randomBase64UrlRaw = createRaw.randomBase64Url === undefined ? {} : objectField(createRaw, "randomBase64Url", `${path}.createIfMissing`); return { - sourceRef: sourceRefField(record, "sourceRef", path), + sourceRef: external ? externalSourceRefField(record, "sourceRef", path) : sourceRefField(record, "sourceRef", path), type, requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)), required: true, @@ -339,15 +339,16 @@ function parseSourceFile(record: Record, path: string): EnvSour }; } -function parseRawSourceFile(record: Record, path: string): RawSourceFileConfig { +function parseExternalSourceFile(record: Record, path: string): SourceFileConfig { const type = stringField(record, "type", path); + if (type === "env") return parseSourceFile(record, path, true); if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`); const createRaw = objectField(record, "createIfMissing", path); const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`); - const randomHex = createRaw.randomHex === undefined + const randomHex: Record = createRaw.randomHex === undefined ? {} : { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) }; - const randomBase64Url = createRaw.randomBase64Url === undefined + const randomBase64Url: Record = createRaw.randomBase64Url === undefined ? {} : { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) }; if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`); @@ -414,7 +415,8 @@ function validateDistributionConfig(config: SecretDistributionConfig): void { for (const item of secret.data) { const source = sources.get(item.sourceRef); if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`); - if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); + const requiredKeys: readonly string[] = source.requiredKeys; + if (!requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`); targetKeys.add(item.targetKey); } @@ -429,7 +431,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean, const root = secretRoot(config); const materials = new Map(); const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => { - const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef); + const sourcePath = sourcePathFor(root, source); const exists = existsSync(sourcePath); const existingText = exists ? readFileSync(sourcePath, "utf8") : ""; const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {}; @@ -502,6 +504,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean, presence, bytes, fingerprint, + ownerOnly: true, + fileMode: "0600", valuesPrinted: false, }; } @@ -517,6 +521,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean, generatedKeys, unmaterializedGeneratedKeys, fingerprint: material.fingerprint, + ownerOnly: true, + fileMode: "0600", valuesPrinted: false, }; }); @@ -967,6 +973,12 @@ function externalSourcePath(sourceRef: string): string { throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`); } +function sourcePathFor(root: string, source: SourceFileConfig): string { + return source.sourceRef.startsWith("~/") || isAbsolute(source.sourceRef) + ? externalSourcePath(source.sourceRef) + : join(root, source.sourceRef); +} + function sourceDataKeyField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`); diff --git a/scripts/src/selfmedia-config.ts b/scripts/src/selfmedia-config.ts index 4f4dde63..7cbb60e5 100644 --- a/scripts/src/selfmedia-config.ts +++ b/scripts/src/selfmedia-config.ts @@ -158,6 +158,7 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`); if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`); if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`); + validateAccessSecret(deployment, `${path}.deployment`); const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`); if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`); return { @@ -206,6 +207,29 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe }; } +function validateAccessSecret(deployment: Record, path: string): void { + const secrets = record(deployment.secrets, `${path}.secrets`); + const access = record(secrets.access, `${path}.secrets.access`); + const target = record(access.target, `${path}.secrets.access.target`); + absolutePath(access.sourceRef, `${path}.secrets.access.sourceRef`); + kubernetesName(target.secretName, `${path}.secrets.access.target.secretName`); + if (boolean(target.readOnly, `${path}.secrets.access.target.readOnly`) !== true) { + throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.readOnly must be true`); + } + if (!Array.isArray(target.files) || target.files.length === 0) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files must be a non-empty array`); + const targetKeys = new Set(); + const mountPaths = new Set(); + target.files.forEach((value, index) => { + const file = record(value, `${path}.secrets.access.target.files[${index}]`); + const targetKey = text(file.targetKey, `${path}.secrets.access.target.files[${index}].targetKey`); + const mountPath = absolutePath(file.mountPath, `${path}.secrets.access.target.files[${index}].mountPath`); + if (targetKeys.has(targetKey)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate targetKey ${targetKey}`); + if (mountPaths.has(mountPath)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate mountPath ${mountPath}`); + targetKeys.add(targetKey); + mountPaths.add(mountPath); + }); +} + function parseCutoverTarget(id: string, value: Record): SelfMediaCutoverTarget { const path = `cutover.targets.${id}`; const source = record(value.source, `${path}.source`);