1.0.27
This commit is contained in:
38 files changed
+1115
-274
No files matched your search
@@ -223,7 +223,7 @@ function setSessionCookie(cookies: Cookies, sessionId: string, maxAge: number, r
|
||||
path: '/',
|
||||
httpOnly: true, // Prevents XSS attacks from reading cookie
|
||||
secure: isSecureContext(request), // Protocol-aware: checks x-forwarded-proto or NODE_ENV
|
||||
sameSite: 'strict', // CSRF protection
|
||||
sameSite: 'lax', // Lax required for OIDC/SSO cross-site redirects
|
||||
maxAge: maxAge // Session timeout in seconds
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Dockhand Container Label Controls
|
||||
*
|
||||
* Docker container labels that control Dockhand behavior:
|
||||
* - dockhand.update=false — Skip this container during auto-updates and batch updates
|
||||
* - dockhand.hidden=true — Hide this container from the Dockhand UI
|
||||
* - dockhand.notify=false — Suppress notifications for this container's events
|
||||
*
|
||||
* All label values are case-insensitive and accept: true/yes/1 and false/no/0.
|
||||
* The opt-out model means labels override DB settings (label wins).
|
||||
*/
|
||||
|
||||
/** Recognized Dockhand label keys */
|
||||
export const DOCKHAND_LABELS = {
|
||||
UPDATE: 'dockhand.update',
|
||||
HIDDEN: 'dockhand.hidden',
|
||||
NOTIFY: 'dockhand.notify',
|
||||
} as const;
|
||||
|
||||
const TRUTHY_VALUES = new Set(['true', 'yes', '1']);
|
||||
const FALSY_VALUES = new Set(['false', 'no', '0']);
|
||||
|
||||
/**
|
||||
* Parse a label value as a boolean.
|
||||
* Returns true for: true, TRUE, yes, YES, 1
|
||||
* Returns false for: false, FALSE, no, NO, 0
|
||||
* Returns undefined for missing or unrecognized values.
|
||||
*/
|
||||
function parseLabelBool(value: string | undefined | null): boolean | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (TRUTHY_VALUES.has(normalized)) return true;
|
||||
if (FALSY_VALUES.has(normalized)) return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a label value from a Docker labels object.
|
||||
*/
|
||||
function getLabel(labels: Record<string, string> | undefined | null, key: string): string | undefined {
|
||||
if (!labels) return undefined;
|
||||
return labels[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container should be skipped during auto-updates.
|
||||
* Returns true if dockhand.update is explicitly set to false/no/0.
|
||||
* Default (no label): allow updates (opt-out model).
|
||||
*/
|
||||
export function isUpdateDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.UPDATE));
|
||||
return value === false; // explicitly disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a container should be hidden from the UI.
|
||||
* Returns true if dockhand.hidden is explicitly set to true/yes/1.
|
||||
* Default (no label): visible (opt-out model).
|
||||
*/
|
||||
export function isHiddenByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.HIDDEN));
|
||||
return value === true; // explicitly hidden
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if notifications should be suppressed for this container.
|
||||
* Returns true if dockhand.notify is explicitly set to false/no/0.
|
||||
* Default (no label): send notifications (opt-out model).
|
||||
*/
|
||||
export function isNotifyDisabledByLabel(labels: Record<string, string> | undefined | null): boolean {
|
||||
const value = parseLabelBool(getLabel(labels, DOCKHAND_LABELS.NOTIFY));
|
||||
return value === false; // explicitly disabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all Dockhand label states from a container's labels.
|
||||
* Useful for including in API responses so the frontend knows about label overrides.
|
||||
*/
|
||||
export function getDockhandLabels(labels: Record<string, string> | undefined | null): {
|
||||
updateDisabled: boolean;
|
||||
hidden: boolean;
|
||||
notifyDisabled: boolean;
|
||||
} {
|
||||
return {
|
||||
updateDisabled: isUpdateDisabledByLabel(labels),
|
||||
hidden: isHiddenByLabel(labels),
|
||||
notifyDisabled: isNotifyDisabledByLabel(labels),
|
||||
};
|
||||
}
|
||||
+62
-6
@@ -78,6 +78,7 @@ import {
|
||||
|
||||
import type { AllGridPreferences, GridId, GridColumnPreferences } from '$lib/types';
|
||||
import { encrypt, decrypt } from './encryption.js';
|
||||
import { parseEnvInterpolation } from './env-interpolation';
|
||||
|
||||
// Re-export for backwards compatibility
|
||||
export { db, isPostgres, isSqlite };
|
||||
@@ -2066,6 +2067,7 @@ export async function getGitStacksByRepositoryId(repositoryId: number): Promise<
|
||||
}
|
||||
|
||||
export async function deleteGitRepository(id: number): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting git repository id=${id} (will cascade-delete git_stacks, set null on stack_sources FKs)`);
|
||||
await db.delete(gitRepositories).where(eq(gitRepositories.id, id));
|
||||
return true;
|
||||
}
|
||||
@@ -2522,6 +2524,7 @@ export async function updateGitStack(id: number, data: Partial<GitStackData>): P
|
||||
}
|
||||
|
||||
export async function deleteGitStack(id: number): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting git_stacks row id=${id}`);
|
||||
await db.delete(gitStacks).where(eq(gitStacks.id, id));
|
||||
return true;
|
||||
}
|
||||
@@ -2781,11 +2784,21 @@ export async function upsertStackSource(data: {
|
||||
const existing = await getStackSource(data.stackName, data.environmentId);
|
||||
|
||||
if (existing) {
|
||||
const newRepoId = data.gitRepositoryId || null;
|
||||
const newStackId = data.gitStackId || null;
|
||||
const changes: string[] = [];
|
||||
if (data.sourceType !== existing.sourceType) changes.push(`sourceType: ${existing.sourceType} → ${data.sourceType}`);
|
||||
if (newRepoId !== existing.gitRepositoryId) changes.push(`gitRepoId: ${existing.gitRepositoryId} → ${newRepoId}`);
|
||||
if (newStackId !== existing.gitStackId) changes.push(`gitStackId: ${existing.gitStackId} → ${newStackId}`);
|
||||
if (changes.length > 0) {
|
||||
console.log(`[GitStack] Updating stack_sources "${data.stackName}" env=${data.environmentId}: ${changes.join(', ')}`);
|
||||
}
|
||||
|
||||
await db.update(stackSources)
|
||||
.set({
|
||||
sourceType: data.sourceType,
|
||||
gitRepositoryId: data.gitRepositoryId || null,
|
||||
gitStackId: data.gitStackId || null,
|
||||
gitRepositoryId: newRepoId,
|
||||
gitStackId: newStackId,
|
||||
composePath: data.composePath ?? null,
|
||||
envPath: data.envPath ?? null,
|
||||
updatedAt: new Date().toISOString()
|
||||
@@ -2793,6 +2806,7 @@ export async function upsertStackSource(data: {
|
||||
.where(eq(stackSources.id, existing.id));
|
||||
return getStackSource(data.stackName, data.environmentId) as Promise<StackSourceData>;
|
||||
} else {
|
||||
console.log(`[GitStack] Creating stack_sources "${data.stackName}" env=${data.environmentId} type=${data.sourceType} repoId=${data.gitRepositoryId || null} stackId=${data.gitStackId || null}`);
|
||||
await db.insert(stackSources).values({
|
||||
stackName: data.stackName,
|
||||
environmentId: data.environmentId ?? null,
|
||||
@@ -2826,6 +2840,7 @@ export async function updateStackSource(
|
||||
}
|
||||
|
||||
export async function deleteStackSource(stackName: string, environmentId?: number | null): Promise<boolean> {
|
||||
console.log(`[GitStack] Deleting stack_sources "${stackName}" env=${environmentId}`);
|
||||
// Delete matching record (either with specific envId or NULL)
|
||||
await db.delete(stackSources)
|
||||
.where(and(
|
||||
@@ -3193,14 +3208,16 @@ export async function getAuditLogs(filters: AuditLogFilters = {}): Promise<Audit
|
||||
// Labels filter - find environments with matching labels first
|
||||
let labelFilteredEnvIds: number[] | undefined;
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
// Get environments that have ANY of the specified labels
|
||||
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
|
||||
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||
labelFilteredEnvIds = allEnvs
|
||||
.filter(env => {
|
||||
if (!env.labels) return false;
|
||||
try {
|
||||
const envLabels = JSON.parse(env.labels) as string[];
|
||||
return filters.labels!.some(label => envLabels.includes(label));
|
||||
return labelFilterMode === 'all'
|
||||
? filters.labels!.every(label => envLabels.includes(label))
|
||||
: filters.labels!.some(label => envLabels.includes(label));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -3408,14 +3425,16 @@ export async function getContainerEvents(filters: ContainerEventFilters = {}): P
|
||||
// Labels filter - find environments with matching labels first
|
||||
let labelFilteredEnvIds: number[] | undefined;
|
||||
if (filters.labels && filters.labels.length > 0) {
|
||||
// Get environments that have ANY of the specified labels
|
||||
const labelFilterMode = await getSetting('label_filter_mode') ?? 'any';
|
||||
const allEnvs = await db.select({ id: environments.id, labels: environments.labels }).from(environments);
|
||||
labelFilteredEnvIds = allEnvs
|
||||
.filter(env => {
|
||||
if (!env.labels) return false;
|
||||
try {
|
||||
const envLabels = JSON.parse(env.labels) as string[];
|
||||
return filters.labels!.some(label => envLabels.includes(label));
|
||||
return labelFilterMode === 'all'
|
||||
? filters.labels!.every(label => envLabels.includes(label))
|
||||
: filters.labels!.some(label => envLabels.includes(label));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -4629,6 +4648,43 @@ export async function getSecretKeyNames(
|
||||
return new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the set of env var keys that should be masked in container inspect responses.
|
||||
* Handles two cases:
|
||||
* 1. Direct match: env var key == secret key in DB (e.g., DB_PASS=${DB_PASS})
|
||||
* 2. Interpolation: env var key differs from secret key (e.g., MYSQL_PASSWORD=${db_secret})
|
||||
* Detected by parsing the compose file for ${variable} references in environment: sections.
|
||||
*
|
||||
* @param composeContent - Optional compose file content. If provided, interpolation
|
||||
* references are parsed to detect secrets injected under different key names.
|
||||
*/
|
||||
export async function getSecretKeysToMask(
|
||||
stackName: string,
|
||||
environmentId?: number | null,
|
||||
composeContent?: string | null
|
||||
): Promise<Set<string>> {
|
||||
const vars = await getStackEnvVars(stackName, environmentId, true);
|
||||
const secretKeyNames = new Set(vars.filter(v => v.isSecret).map(v => v.key));
|
||||
|
||||
if (secretKeyNames.size === 0) return secretKeyNames;
|
||||
|
||||
// If we have compose content, parse interpolation references to find
|
||||
// container env keys that map to secret interpolation variables.
|
||||
// e.g., "MYSQL_PASSWORD=${db_secret}" → if db_secret is a secret, mask MYSQL_PASSWORD too.
|
||||
if (composeContent) {
|
||||
const interpolated = parseEnvInterpolation(composeContent);
|
||||
for (const [containerKey, varName] of interpolated) {
|
||||
if (secretKeyNames.has(varName)) {
|
||||
secretKeyNames.add(containerKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return secretKeyNames;
|
||||
}
|
||||
|
||||
export { parseEnvInterpolation } from './env-interpolation';
|
||||
|
||||
/**
|
||||
* Get count of environment variables for a stack.
|
||||
* @param stackName - Name of the stack
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Parse compose YAML to extract environment variable interpolation mappings.
|
||||
* Returns pairs of [containerEnvKey, interpolationVariable].
|
||||
*
|
||||
* Handles patterns:
|
||||
* - VAR=${ref}
|
||||
* - VAR=${ref:-default}
|
||||
* - VAR=${ref:+alt}
|
||||
* - VAR=${ref?error}
|
||||
*
|
||||
* Only extracts from `environment:` sections (list format: `- KEY=value`).
|
||||
*/
|
||||
export function parseEnvInterpolation(composeContent: string): Array<[string, string]> {
|
||||
const results: Array<[string, string]> = [];
|
||||
|
||||
// Step 1: Find lines matching `- ENV_KEY=...${...}...`
|
||||
const linePattern = /^\s*-\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)/gm;
|
||||
let lineMatch;
|
||||
while ((lineMatch = linePattern.exec(composeContent)) !== null) {
|
||||
const containerKey = lineMatch[1];
|
||||
const valueStr = lineMatch[2];
|
||||
|
||||
// Step 2: Extract all ${VAR} references from the value
|
||||
const varPattern = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:[:\-\+\?][^}]*)?\}/g;
|
||||
let varMatch;
|
||||
while ((varMatch = varPattern.exec(valueStr)) !== null) {
|
||||
const varName = varMatch[1];
|
||||
// Only add if names differ — same-name case handled by direct key matching
|
||||
if (containerKey !== varName) {
|
||||
results.push([containerKey, varName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
+68
-2
@@ -16,6 +16,70 @@ import {
|
||||
} from './db';
|
||||
import { deployStack, getStackDir } from './stacks';
|
||||
|
||||
const MERGED_CA_BUNDLE_PATH = '/tmp/dockhand-merged-ca-bundle.crt';
|
||||
let mergedCaBundleReady = false;
|
||||
|
||||
/**
|
||||
* Create a merged CA bundle combining system CAs with the custom cert from
|
||||
* NODE_EXTRA_CA_CERTS. GIT_SSL_CAINFO replaces the default CA store, so without
|
||||
* merging, public CAs (GitHub, GitLab) break.
|
||||
*/
|
||||
function getMergedCaBundlePath(): string {
|
||||
if (mergedCaBundleReady && existsSync(MERGED_CA_BUNDLE_PATH)) {
|
||||
console.log(`[Git] Using cached merged CA bundle: ${MERGED_CA_BUNDLE_PATH}`);
|
||||
return MERGED_CA_BUNDLE_PATH;
|
||||
}
|
||||
|
||||
const customCertPath = process.env.NODE_EXTRA_CA_CERTS!;
|
||||
console.log(`[Git] NODE_EXTRA_CA_CERTS set to: ${customCertPath}`);
|
||||
|
||||
const systemCaPaths = [
|
||||
process.env.SSL_CERT_FILE,
|
||||
'/etc/ssl/certs/ca-certificates.crt',
|
||||
'/etc/pki/tls/certs/ca-bundle.crt',
|
||||
'/etc/ssl/cert.pem'
|
||||
];
|
||||
|
||||
let systemCaContent = '';
|
||||
let systemCaSource = '';
|
||||
for (const caPath of systemCaPaths) {
|
||||
if (caPath && existsSync(caPath)) {
|
||||
try {
|
||||
systemCaContent = readFileSync(caPath, 'utf-8');
|
||||
systemCaSource = caPath;
|
||||
console.log(`[Git] Found system CA bundle: ${caPath} (${systemCaContent.split('-----BEGIN CERTIFICATE-----').length - 1} certs)`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.log(`[Git] Failed to read system CA bundle ${caPath}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!systemCaSource) {
|
||||
console.log(`[Git] No system CA bundle found, using custom cert only: ${customCertPath}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const customCaContent = readFileSync(customCertPath, 'utf-8');
|
||||
const customCertCount = customCaContent.split('-----BEGIN CERTIFICATE-----').length - 1;
|
||||
console.log(`[Git] Custom CA file contains ${customCertCount} cert(s)`);
|
||||
|
||||
const merged = systemCaContent
|
||||
? systemCaContent.trimEnd() + '\n' + customCaContent.trimEnd() + '\n'
|
||||
: customCaContent;
|
||||
writeFileSync(MERGED_CA_BUNDLE_PATH, merged);
|
||||
mergedCaBundleReady = true;
|
||||
|
||||
const totalCerts = merged.split('-----BEGIN CERTIFICATE-----').length - 1;
|
||||
console.log(`[Git] Created merged CA bundle: ${MERGED_CA_BUNDLE_PATH} (${totalCerts} total certs — system from ${systemCaSource || 'none'} + custom from ${customCertPath})`);
|
||||
} catch (err) {
|
||||
console.warn(`[Git] Failed to create merged CA bundle, falling back to custom cert only: ${customCertPath}`, err);
|
||||
return customCertPath;
|
||||
}
|
||||
|
||||
return MERGED_CA_BUNDLE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect stdout, stderr and exit code from a spawned process.
|
||||
*/
|
||||
@@ -153,9 +217,11 @@ async function buildGitEnv(credential: GitCredential | null): Promise<GitEnv> {
|
||||
SSH_AUTH_SOCK: ''
|
||||
};
|
||||
|
||||
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js)
|
||||
// Pass custom CA certificate to git CLI (NODE_EXTRA_CA_CERTS only affects Node.js).
|
||||
// GIT_SSL_CAINFO replaces the default CA store, so we merge system CAs with the
|
||||
// custom cert so both self-signed repos and public repos (GitHub etc.) work (#967).
|
||||
if (process.env.NODE_EXTRA_CA_CERTS) {
|
||||
env.GIT_SSL_CAINFO = process.env.NODE_EXTRA_CA_CERTS;
|
||||
env.GIT_SSL_CAINFO = getMergedCaBundlePath();
|
||||
}
|
||||
|
||||
// Ensure current UID is resolvable for SSH/git operations
|
||||
|
||||
+20
-17
@@ -9,6 +9,7 @@ import { db, hawserTokens, environments, eq, and } from './db/drizzle.js';
|
||||
import { logContainerEvent, type ContainerEventAction } from './db.js';
|
||||
import { containerEventEmitter } from './event-collector.js';
|
||||
import { sendEnvironmentNotification } from './notifications.js';
|
||||
import { isNotifyDisabledByLabel } from './container-labels.js';
|
||||
import { pushMetric } from './metrics-store.js';
|
||||
import { secureGetRandomValues, secureRandomUUID } from './crypto-fallback.js';
|
||||
import { hashPassword, verifyPassword } from './auth.js';
|
||||
@@ -191,24 +192,26 @@ export async function handleEdgeContainerEvent(
|
||||
// Broadcast to SSE clients
|
||||
containerEventEmitter.emit('event', savedEvent);
|
||||
|
||||
// Prepare notification
|
||||
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
|
||||
const containerLabel = event.containerName || event.containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
|
||||
? 'error'
|
||||
: event.action === 'stop'
|
||||
? 'warning'
|
||||
: event.action === 'start'
|
||||
? 'success'
|
||||
: 'info';
|
||||
// Check dockhand.notify label before sending notification
|
||||
// Docker includes container labels in actorAttributes
|
||||
if (!isNotifyDisabledByLabel(event.actorAttributes)) {
|
||||
const actionLabel = event.action.charAt(0).toUpperCase() + event.action.slice(1);
|
||||
const containerLabel = event.containerName || event.containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
event.action === 'die' || event.action === 'kill' || event.action === 'oom'
|
||||
? 'error'
|
||||
: event.action === 'stop'
|
||||
? 'warning'
|
||||
: event.action === 'start'
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
// Send notification
|
||||
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
|
||||
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
||||
}, event.image);
|
||||
await sendEnvironmentNotification(environmentId, event.action as ContainerEventAction, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${event.action}${event.image ? ` (${event.image})` : ''}`,
|
||||
type: notificationType as 'success' | 'error' | 'warning' | 'info'
|
||||
}, event.image);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error);
|
||||
console.error('[Hawser] Error handling container event:', errorMsg);
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
|
||||
import { getAdditionalVolumeBinds } from './mount-dedupe';
|
||||
|
||||
describe('getAdditionalVolumeBinds', () => {
|
||||
it('skips volume mounts when the target already exists in HostConfig.Mounts', () => {
|
||||
const additionalBinds = getAdditionalVolumeBinds(
|
||||
{
|
||||
Binds: ['/volume1/backups:/backup'],
|
||||
Mounts: [{ Target: '/data' }]
|
||||
},
|
||||
[
|
||||
{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' },
|
||||
{ Type: 'bind', Name: 'ignored', Destination: '/backup' }
|
||||
]
|
||||
);
|
||||
|
||||
assert.deepEqual(additionalBinds, []);
|
||||
});
|
||||
|
||||
it('adds volume mounts that are missing from HostConfig', () => {
|
||||
const additionalBinds = getAdditionalVolumeBinds(
|
||||
{
|
||||
Binds: ['/volume1/backups:/backup'],
|
||||
Mounts: []
|
||||
},
|
||||
[{ Type: 'volume', Name: 'docsight_docsis_data', Destination: '/data' }]
|
||||
);
|
||||
|
||||
assert.deepEqual(additionalBinds, ['docsight_docsis_data:/data']);
|
||||
});
|
||||
});
|
||||
@@ -9,17 +9,7 @@ import {
|
||||
type NotificationEventType
|
||||
} from './db';
|
||||
|
||||
// Escape special characters for Telegram Markdown
|
||||
function escapeTelegramMarkdown(text: string): string {
|
||||
// Escape characters that have special meaning in Telegram Markdown
|
||||
return text
|
||||
.replace(/\\/g, '\\\\') // Escape backslashes first
|
||||
.replace(/_/g, '\\_') // Underscore (italic)
|
||||
.replace(/\*/g, '\\*') // Asterisk (bold)
|
||||
.replace(/\[/g, '\\[') // Opening bracket (link)
|
||||
.replace(/\]/g, '\\]') // Closing bracket (link)
|
||||
.replace(/`/g, '\\`'); // Backtick (code)
|
||||
}
|
||||
import { escapeTelegramMarkdown, parseTelegramUrl, buildGotifyUrl, parseWorkflowsUrl, buildWorkflowsHttpUrl } from '$lib/utils/notification-parsers';
|
||||
|
||||
/** Drain a response body to release the underlying socket/TLS connection. */
|
||||
async function drainResponse(response: Response): Promise<void> {
|
||||
@@ -279,21 +269,18 @@ async function sendMattermost(appriseUrl: string, payload: NotificationPayload):
|
||||
|
||||
// Telegram
|
||||
async function sendTelegram(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// tgram://bot_token/chat_id:topic_id?
|
||||
const match = appriseUrl.match(/^tgram:\/\/([^/]+)\/([^:\/]+)(?::(\d+))?$/);
|
||||
if (!match) {
|
||||
const parsed = parseTelegramUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Telegram URL format. Expected: tgram://bot_token/chat_id or tgram://bot_token/chat_id:topic_id' };
|
||||
}
|
||||
|
||||
const [, botToken, chatId, topicIdStr] = match;
|
||||
const { botToken, chatId, topicId } = parsed;
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
// Escape markdown special characters in title and message
|
||||
const escapedTitle = escapeTelegramMarkdown(payload.title);
|
||||
const escapedMessage = escapeTelegramMarkdown(payload.message);
|
||||
const envTag = payload.environmentName ? ` \\[${escapeTelegramMarkdown(payload.environmentName)}\\]` : '';
|
||||
|
||||
const topicId = topicIdStr ? parseInt(topicIdStr, 10) : undefined;
|
||||
const envTag = payload.environmentName ? ` [${escapeTelegramMarkdown(payload.environmentName)}]` : '';
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
@@ -324,21 +311,11 @@ async function sendTelegram(appriseUrl: string, payload: NotificationPayload): P
|
||||
|
||||
// Gotify
|
||||
async function sendGotify(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
// gotify://hostname/token or gotifys://hostname/token
|
||||
// gotify://hostname/subpath/token (subpath support)
|
||||
const match = appriseUrl.match(/^gotifys?:\/\/([^/]+)\/(.+)/);
|
||||
if (!match) {
|
||||
const url = buildGotifyUrl(appriseUrl);
|
||||
if (!url) {
|
||||
return { success: false, error: 'Invalid Gotify URL format. Expected: gotify://hostname/token' };
|
||||
}
|
||||
|
||||
const [, hostname, pathPart] = match;
|
||||
const protocol = appriseUrl.startsWith('gotifys') ? 'https' : 'http';
|
||||
// Token is always the last path segment; anything before it is a subpath
|
||||
const lastSlash = pathPart.lastIndexOf('/');
|
||||
const subpath = lastSlash >= 0 ? pathPart.substring(0, lastSlash) : '';
|
||||
const token = lastSlash >= 0 ? pathPart.substring(lastSlash + 1) : pathPart;
|
||||
const url = `${protocol}://${hostname}${subpath ? '/' + subpath : ''}/message?token=${token}`;
|
||||
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
try {
|
||||
@@ -496,47 +473,57 @@ async function sendGenericWebhook(appriseUrl: string, payload: NotificationPaylo
|
||||
}
|
||||
}
|
||||
// Microsoft Power Automate Workflows, for e.g. Microsoft Teams
|
||||
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<boolean> {
|
||||
// workflows://hostname/workflow/signature
|
||||
const match = appriseUrl.match(/^workflows?:\/\/([^/]+)\/(.+)\/(.+)/);
|
||||
if (!match) return false;
|
||||
async function sendWorkflows(appriseUrl: string, payload: NotificationPayload): Promise<NotificationResult> {
|
||||
const parsed = parseWorkflowsUrl(appriseUrl);
|
||||
if (!parsed) {
|
||||
return { success: false, error: 'Invalid Workflows URL format. Expected: workflows://hostname/workflow/signature' };
|
||||
}
|
||||
|
||||
const [, hostname, workflow, signature] = match;
|
||||
const url = `https://${hostname}/powerautomate/automations/direct/workflows/${workflow}/triggers/manual/paths/invoke?api-version=1&sp=%2Ftriggers%2Fmanual%2Frun&sv=1.0&sig=${signature}`;
|
||||
const url = buildWorkflowsHttpUrl(parsed.hostname, parsed.workflow, parsed.signature);
|
||||
const titleWithEnv = payload.environmentName ? `${payload.title} [${payload.environmentName}]` : payload.title;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.2',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'heading',
|
||||
wrap: true,
|
||||
text: payload.title
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'default',
|
||||
wrap: true,
|
||||
text: payload.message
|
||||
}
|
||||
]
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'message',
|
||||
attachments: [
|
||||
{
|
||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||
content: {
|
||||
$schema: 'https://adaptivecards.io/schemas/adaptive-card.json',
|
||||
type: 'AdaptiveCard',
|
||||
version: '1.2',
|
||||
body: [
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'heading',
|
||||
wrap: true,
|
||||
text: titleWithEnv
|
||||
},
|
||||
{
|
||||
type: 'TextBlock',
|
||||
style: 'default',
|
||||
wrap: true,
|
||||
text: payload.message
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
return response.ok;
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
return { success: false, error: `Workflows error ${response.status}: ${text || response.statusText}` };
|
||||
}
|
||||
await drainResponse(response);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: `Workflows connection failed: ${error instanceof Error ? error.message : String(error)}` };
|
||||
}
|
||||
}
|
||||
|
||||
// Send notification to all enabled channels
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import { getScannerSettings, scanImage, type ScanResult, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
@@ -369,6 +370,18 @@ export async function runContainerUpdate(
|
||||
return;
|
||||
}
|
||||
|
||||
// Check dockhand.update label (label wins over DB settings)
|
||||
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
|
||||
log(`Skipping - dockhand.update=false label set on container`);
|
||||
await updateScheduleExecution(execution.id, {
|
||||
status: 'skipped',
|
||||
completedAt: new Date().toISOString(),
|
||||
duration: Date.now() - startTime,
|
||||
details: { reason: 'Skipped by dockhand.update=false label' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip digest-pinned images - they are explicitly locked to a specific version
|
||||
if (isDigestBasedImage(imageNameFromConfig)) {
|
||||
log(`Skipping ${containerName} - image pinned to specific digest`);
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { sendEventNotification } from '../../notifications';
|
||||
import { getScannerSettings, scanImage, type VulnerabilitySeverity } from '../../scanner';
|
||||
import { parseImageNameAndTag, shouldBlockUpdate, combineScanSummaries, isSystemContainer } from './update-utils';
|
||||
import { isUpdateDisabledByLabel } from '../../container-labels';
|
||||
import { recreateContainer } from './container-update';
|
||||
|
||||
interface UpdateInfo {
|
||||
@@ -129,6 +130,12 @@ export async function runEnvUpdateCheckJob(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check dockhand.update label (label wins over DB settings)
|
||||
if (isUpdateDisabledByLabel(inspectData.Config?.Labels)) {
|
||||
await log(` [${container.name}] Skipping - dockhand.update=false label`);
|
||||
continue;
|
||||
}
|
||||
|
||||
checkedCount++;
|
||||
await log(` Checking: ${container.name} (${imageName})`);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type ContainerEventAction
|
||||
} from './db';
|
||||
import { sendEnvironmentNotification, sendEventNotification } from './notifications';
|
||||
import { isNotifyDisabledByLabel } from './container-labels';
|
||||
import { rssBeforeOp, rssAfterOp } from './rss-tracker';
|
||||
import { pushMetric } from './metrics-store';
|
||||
|
||||
@@ -285,24 +286,28 @@ async function handleContainerEvent(msg: GoMessage): Promise<void> {
|
||||
|
||||
// Sub-category: notification
|
||||
const notifBefore = rssBeforeOp();
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
sendEnvironmentNotification(msg.envId, action, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
||||
type: notificationType
|
||||
}, image).catch(() => {});
|
||||
// Check dockhand.notify label — Docker includes container labels in event Actor.Attributes
|
||||
if (!isNotifyDisabledByLabel(event.Actor?.Attributes)) {
|
||||
const actionLabel = action.startsWith('health_status')
|
||||
? action.includes('unhealthy') ? 'Unhealthy' : 'Healthy'
|
||||
: action.charAt(0).toUpperCase() + action.slice(1);
|
||||
const containerLabel = containerName || containerId.substring(0, 12);
|
||||
const notificationType =
|
||||
action === 'die' || action === 'kill' || action === 'oom' || action.includes('unhealthy')
|
||||
? 'error'
|
||||
: action === 'stop'
|
||||
? 'warning'
|
||||
: action === 'start' || (action.includes('healthy') && !action.includes('unhealthy'))
|
||||
? 'success'
|
||||
: 'info';
|
||||
|
||||
sendEnvironmentNotification(msg.envId, action, {
|
||||
title: `Container ${actionLabel}`,
|
||||
message: `Container "${containerLabel}" ${action}${image ? ` (${image})` : ''}`,
|
||||
type: notificationType
|
||||
}, image).catch(() => {});
|
||||
}
|
||||
rssAfterOp('events_notif', notifBefore);
|
||||
rssAfterOp('events', before);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user