Merge pull request #2939 from fosrl/dev

1.18.1-s.2
This commit is contained in:
Owen Schwartz
2026-04-29 21:33:03 -07:00
committed by GitHub
7 changed files with 130 additions and 106 deletions

View File

@@ -414,28 +414,18 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Install cosign - name: Install cosign
# cosign is used to sign and verify container images (key and keyless) # cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1 uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Dual-sign and verify (GHCR & Docker Hub) - name: Sign (GHCR, keyless)
# Sign each image by digest using keyless (OIDC) and key-based signing, # Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
# then verify both the public key signature and the keyless OIDC signature. # Signatures are stored in the registry alongside the image.
env: env:
TAG: ${{ env.TAG }} TAG: ${{ env.TAG }}
COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }}
COSIGN_YES: "true" COSIGN_YES: "true"
run: | run: |
set -euo pipefail set -euo pipefail
issuer="https://token.actions.githubusercontent.com"
id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs)
# Track failures
FAILED_TAGS=()
SUCCESSFUL_TAGS=()
# Determine if this is an RC release # Determine if this is an RC release
IS_RC="false" IS_RC="false"
if [[ "$TAG" == *"-rc."* ]]; then if [[ "$TAG" == *"-rc."* ]]; then
@@ -463,95 +453,47 @@ jobs:
) )
fi fi
# Sign each image variant for both registries FAILED_TAGS=()
for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do SUCCESSFUL_TAGS=()
for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do
echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}" echo "Processing ${GHCR_IMAGE}:${IMAGE_TAG}"
TAG_FAILED=false TAG_FAILED=false
# Wrap the entire tag processing in error handling
( (
set -e set -e
DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" DIGEST="$(skopeo inspect --retry-times 3 docker://${GHCR_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')"
REF="${BASE_IMAGE}@${DIGEST}" REF="${GHCR_IMAGE}@${DIGEST}"
echo "Resolved digest: ${REF}" echo "Resolved digest: ${REF}"
echo "==> cosign sign (keyless) --recursive ${REF}" echo "==> cosign sign (keyless) --recursive ${REF}"
cosign sign --recursive "${REF}" cosign sign --recursive "${REF}"
echo "==> cosign sign (key) --recursive ${REF}"
cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}"
# Retry wrapper for verification to handle registry propagation delays
retry_verify() {
local cmd="$1"
local attempts=6
local delay=5
local i=1
until eval "$cmd"; do
if [ $i -ge $attempts ]; then
echo "Verification failed after $attempts attempts"
return 1
fi
echo "Verification not yet available. Retry $i/$attempts after ${delay}s..."
sleep $delay
i=$((i+1))
delay=$((delay*2))
# Cap the delay to avoid very long waits
if [ $delay -gt 60 ]; then delay=60; fi
done
return 0
}
echo "==> cosign verify (public key) ${REF}"
if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then
VERIFIED_INDEX=true
else
VERIFIED_INDEX=false
fi
echo "==> cosign verify (keyless policy) ${REF}"
if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then
VERIFIED_INDEX_KEYLESS=true
else
VERIFIED_INDEX_KEYLESS=false
fi
# Check if verification succeeded
if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then
echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}"
echo "This may be due to registry propagation delays. Continuing anyway."
fi
) || TAG_FAILED=true ) || TAG_FAILED=true
if [ "$TAG_FAILED" = "true" ]; then if [ "$TAG_FAILED" = "true" ]; then
echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}" echo "⚠️ WARNING: Failed to sign ${GHCR_IMAGE}:${IMAGE_TAG}"
FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") FAILED_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
else else
echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" echo "✓ Successfully signed ${GHCR_IMAGE}:${IMAGE_TAG}"
SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") SUCCESSFUL_TAGS+=("${GHCR_IMAGE}:${IMAGE_TAG}")
fi fi
done done
done
# Report summary
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "Sign and Verify Summary" echo "Sign Summary"
echo "==========================================" echo "=========================================="
echo "Successful: ${#SUCCESSFUL_TAGS[@]}" echo "Successful: ${#SUCCESSFUL_TAGS[@]}"
echo "Failed: ${#FAILED_TAGS[@]}" echo "Failed: ${#FAILED_TAGS[@]}"
echo ""
if [ ${#FAILED_TAGS[@]} -gt 0 ]; then if [ ${#FAILED_TAGS[@]} -gt 0 ]; then
echo "Failed tags:" echo "Failed tags:"
for tag in "${FAILED_TAGS[@]}"; do for tag in "${FAILED_TAGS[@]}"; do
echo " - $tag" echo " - $tag"
done done
echo "" echo "⚠️ WARNING: Some tags failed to sign, but continuing anyway"
echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway"
else else
echo "✓ All images signed and verified successfully!" echo "✓ All images signed successfully!"
fi fi
shell: bash shell: bash

View File

@@ -122,8 +122,6 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain", createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain", deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain", restartOrgDomain = "restartOrgDomain",
sendUsageNotification = "sendUsageNotification",
sendTrialNotification = "sendTrialNotification",
createRemoteExitNode = "createRemoteExitNode", createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode", updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode", getRemoteExitNode = "getRemoteExitNode",

View File

@@ -566,6 +566,17 @@ export const alertWebhookActions = pgTable("alertWebhookActions", {
lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable lastSentAt: bigint("lastSentAt", { mode: "number" }) // nullable
}); });
export const trialNotifications = pgTable("trialNotifications", {
notificationId: serial("notificationId").primaryKey(),
subscriptionId: varchar("subscriptionId", { length: 255 })
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: varchar("notificationType", { length: 50 }).notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: bigint("sentAt", { mode: "number" }).notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -604,3 +615,12 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRules = InferSelectModel<typeof alertRules>;
export type AlertEmailActions = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipients = InferSelectModel<
typeof alertEmailRecipients
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -21,6 +21,9 @@ import {
targetHealthCheck, targetHealthCheck,
users users
} from "./schema"; } from "./schema";
import { serial, varchar } from "drizzle-orm/mysql-core";
import { pgTable } from "drizzle-orm/pg-core";
import { bigint } from "zod";
export const certificates = sqliteTable("certificates", { export const certificates = sqliteTable("certificates", {
certId: integer("certId").primaryKey({ autoIncrement: true }), certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -569,6 +572,19 @@ export const alertWebhookActions = sqliteTable("alertWebhookActions", {
lastSentAt: integer("lastSentAt") lastSentAt: integer("lastSentAt")
}); });
export const trialNotifications = sqliteTable("trialNotifications", {
notificationId: integer("notificationId").primaryKey({
autoIncrement: true
}),
subscriptionId: text("subscriptionId")
.notNull()
.references(() => subscriptions.subscriptionId, {
onDelete: "cascade"
}),
notificationType: text("notificationType").notNull(), // trial_ending_5d, trial_ending_24h, trial_ended
sentAt: integer("sentAt").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -601,3 +617,10 @@ export type EventStreamingCursor = InferSelectModel<
typeof eventStreamingCursors typeof eventStreamingCursors
>; >;
export type AlertResources = InferSelectModel<typeof alertResources>; export type AlertResources = InferSelectModel<typeof alertResources>;
export type AlertHealthChecks = InferSelectModel<typeof alertHealthChecks>;
export type AlertSites = InferSelectModel<typeof alertSites>;
export type AlertRule = InferSelectModel<typeof alertRules>;
export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;

View File

@@ -30,8 +30,10 @@ import {
userOrgRoles, userOrgRoles,
siteProvisioningKeyOrg, siteProvisioningKeyOrg,
siteProvisioningKeys, siteProvisioningKeys,
alertRules,
targetHealthCheck
} from "@server/db"; } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
/** /**
* Get the maximum allowed retention days for a given tier * Get the maximum allowed retention days for a given tier
@@ -318,6 +320,14 @@ async function disableFeature(
await disableSiteProvisioningKeys(orgId); await disableSiteProvisioningKeys(orgId);
break; break;
case TierFeature.AlertingRules:
await disableAlertingRules(orgId);
break;
case TierFeature.StandaloneHealthChecks:
await disableStandaloneHealthChecks(orgId);
break;
default: default:
logger.warn( logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping` `Unknown feature ${feature} for org ${orgId}, skipping`
@@ -360,8 +370,7 @@ async function disableFullRbac(orgId: string): Promise<void> {
async function disableSiteProvisioningKeys(orgId: string): Promise<void> { async function disableSiteProvisioningKeys(orgId: string): Promise<void> {
const rows = await db const rows = await db
.select({ .select({
siteProvisioningKeyId: siteProvisioningKeyId: siteProvisioningKeyOrg.siteProvisioningKeyId
siteProvisioningKeyOrg.siteProvisioningKeyId
}) })
.from(siteProvisioningKeyOrg) .from(siteProvisioningKeyOrg)
.where(eq(siteProvisioningKeyOrg.orgId, orgId)); .where(eq(siteProvisioningKeyOrg.orgId, orgId));
@@ -525,6 +534,29 @@ async function disablePasswordExpirationPolicies(orgId: string): Promise<void> {
logger.info(`Disabled password expiration policies for org ${orgId}`); logger.info(`Disabled password expiration policies for org ${orgId}`);
} }
async function disableAlertingRules(orgId: string): Promise<void> {
await db
.update(alertRules)
.set({ enabled: false })
.where(eq(alertRules.orgId, orgId));
logger.info(`Disabled all alert rules for org ${orgId}`);
}
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
await db
.update(targetHealthCheck)
.set({ hcEnabled: false })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
isNull(targetHealthCheck.targetId)
)
);
logger.info(`Disabled standalone health checks for org ${orgId}`);
}
async function disableAutoProvisioning(orgId: string): Promise<void> { async function disableAutoProvisioning(orgId: string): Promise<void> {
// Get all IDP IDs for this org through the idpOrg join table // Get all IDP IDs for this org through the idpOrg join table
const orgIdps = await db const orgIdps = await db

View File

@@ -174,6 +174,19 @@ export async function handleSubscriptionCreated(
// TODO: update user in Sendy // TODO: update user in Sendy
} }
} }
// delete the trial subscrition if we have one
await db
.delete(subscriptions)
.where(
and(
eq(
subscriptions.customerId,
subscription.customer as string
),
eq(subscriptions.trial, true)
)
);
} else if (type === "license") { } else if (type === "license") {
logger.debug( logger.debug(
`License subscription created for org ${customer.orgId}, no lifecycle handling needed.` `License subscription created for org ${customer.orgId}, no lifecycle handling needed.`

View File

@@ -67,23 +67,19 @@ if (build == "saas") {
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
certificates.syncCertToNewts certificates.syncCertToNewts
); );
}
authenticated.post( authenticated.post(
`/org/:orgId/send-usage-notification`, `/org/:orgId/send-usage-notification`,
verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine
verifyApiKeyHasAction(ActionsEnum.sendUsageNotification),
logActionAudit(ActionsEnum.sendUsageNotification),
org.sendUsageNotification org.sendUsageNotification
); );
authenticated.post( authenticated.post(
`/org/:orgId/send-trial-notification`, `/org/:orgId/send-trial-notification`,
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
verifyApiKeyHasAction(ActionsEnum.sendTrialNotification),
logActionAudit(ActionsEnum.sendTrialNotification),
org.sendTrialNotification org.sendTrialNotification
); );
}
authenticated.delete( authenticated.delete(
"/idp/:idpId", "/idp/:idpId",