Compare commits

...

75 Commits

Author SHA1 Message Date
Owen
17631599a2 Remove delay 2026-04-26 21:25:53 -07:00
Owen
7563b37cd0 Add missing health column 2026-04-26 21:25:14 -07:00
Owen
7318c86cca Fix display and query issues 2026-04-26 20:33:58 -07:00
Owen
467cd70b72 Handle delete correctly 2026-04-26 20:26:03 -07:00
Owen
8ca72a39da Handle deleting targets 2026-04-26 17:55:26 -07:00
Owen
4ff811c5bd Use http by default 2026-04-26 17:38:24 -07:00
Owen
ca2370e31d Add logging when manually changing the hc status 2026-04-26 17:29:20 -07:00
miloschwartz
06af53c4d6 increase refresh rate 2026-04-26 16:57:10 -07:00
miloschwartz
6befdfe01e improve cert restart button style 2026-04-26 16:50:52 -07:00
Owen
5695137280 Dont create alerts with 300 second cooldowns 2026-04-26 16:43:28 -07:00
miloschwartz
e2e0936f43 improve cert status style 2026-04-26 11:27:53 -07:00
miloschwartz
32d8bde96d adjust wildcard placeholder 2026-04-26 11:15:23 -07:00
miloschwartz
f24f867684 add hyphens to random blueprint name 2026-04-26 11:11:31 -07:00
miloschwartz
491636851f Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2026-04-26 10:23:36 -07:00
Owen
bf1870608b Exclude wildcard resources 2026-04-25 15:51:39 -07:00
miloschwartz
6f6c24b6df use semibold 2026-04-25 15:42:19 -07:00
Owen
7c7d1f641e Support unknown and degraded status 2026-04-25 15:34:04 -07:00
Owen
82212af643 Add resource degraded 2026-04-25 15:34:04 -07:00
miloschwartz
8e16ff07a9 move switch toggle above tabs on health check dialog 2026-04-25 15:23:22 -07:00
miloschwartz
56816c7584 change column order on sites table 2026-04-25 15:17:39 -07:00
miloschwartz
477712b73c show site resources 2026-04-25 15:08:08 -07:00
Owen
ecacb26445 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-04-24 17:32:28 -07:00
Owen
cca7cea2f1 Handeling the different health status 2026-04-24 17:30:54 -07:00
miloschwartz
07154d2a16 add links to view resources on site 2026-04-24 17:07:11 -07:00
miloschwartz
b509c8aeec dont distribute info section cols 2026-04-24 16:57:53 -07:00
miloschwartz
a2c76cbb24 set standard filter popover width 2026-04-24 16:44:57 -07:00
miloschwartz
960ada4d66 add site column and filter to public resources 2026-04-24 16:24:26 -07:00
Owen
34296e5f40 Fix health check status 2026-04-24 16:19:35 -07:00
miloschwartz
33f1662c91 support site filter in private resources table 2026-04-24 16:12:15 -07:00
Owen
29f26021df Add the right pending record 2026-04-24 16:07:44 -07:00
Owen
15f02cf79a Quiet up messages 2026-04-24 16:06:19 -07:00
Owen
2a5d836747 Fix gear icon 2026-04-24 16:06:04 -07:00
Owen
593a7fdd69 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-04-24 15:13:25 -07:00
miloschwartz
99f9b68efe fix full sudo mode calculation 2026-04-24 14:53:11 -07:00
miloschwartz
9655f119a5 fix text 2026-04-24 13:47:54 -07:00
Owen
48ddc700a0 Catch the domains the right way 2026-04-24 13:40:31 -07:00
Owen
0473d5f639 Get the cert correctly 2026-04-24 12:18:50 -07:00
Owen
537f9ae66b Always update the domain even if wildcard changes 2026-04-24 12:14:06 -07:00
Owen
d08f276794 Use the provided host in the cookie 2026-04-24 11:55:09 -07:00
Owen
6a96f743aa Update exchange session to support wildcards 2026-04-23 21:38:12 -07:00
Owen
b4f0b4e285 Handle matching wildcards 2026-04-23 21:25:13 -07:00
Owen
07c7501669 New columns 2026-04-23 20:30:34 -07:00
Owen
009bac64bf Adding guiderails 2026-04-23 18:02:32 -07:00
Owen
5e293e8364 Handle getting resources 2026-04-23 17:14:05 -07:00
Owen
1ba7fca798 Update traefik config 2026-04-23 15:08:55 -07:00
Owen
e7a9a19816 Basic crud working? 2026-04-23 15:01:43 -07:00
Owen
fa117198a0 Pass one at getting it into the db 2026-04-23 14:05:08 -07:00
Owen
f03d0cd47f Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-04-23 13:37:44 -07:00
Owen Schwartz
925a59c080 Merge pull request #2873 from sidd190/fix/crowdsec-traefik-logrotate
(fix): Added a logrotate function to the crowdsec.go installer file
2026-04-23 12:18:00 -07:00
Owen
a7c7319407 Deprecated sites should be optional 2026-04-23 12:09:22 -07:00
Owen
230f77118a Also check when getting the cert 2026-04-22 21:11:52 -07:00
Owen
bcb5b7b4a7 Show status in messages 2026-04-22 20:44:35 -07:00
Owen
90a2ed2f10 Create pending cert 2026-04-22 20:39:04 -07:00
Owen
fc69364feb Show cert status 2026-04-22 20:36:00 -07:00
Owen
245755a140 Use transactions 2026-04-22 18:13:15 -07:00
Owen
dcbd22b4ad Handle all of the alerting from the functions 2026-04-22 18:13:15 -07:00
miloschwartz
8481b0a073 dont filter admin role in role selector for alerts 2026-04-22 17:52:31 -07:00
miloschwartz
f651ca84fa remove empty table state lines 2026-04-22 17:43:29 -07:00
miloschwartz
6b83d3c3f1 add meta titles to alert pages 2026-04-22 17:27:30 -07:00
Owen
d463a578c2 Handle *. wildcard domains in the db 2026-04-22 17:06:22 -07:00
Owen
9d0a8ecb09 Update placeholder and handle wildcard certs 2026-04-22 16:48:51 -07:00
Owen
af5394d464 Add more information about caches 2026-04-22 16:48:51 -07:00
miloschwartz
c956e0d401 add meta titles to auth pages 2026-04-22 16:09:16 -07:00
miloschwartz
2a281ec002 update telemetry 2026-04-22 15:06:37 -07:00
miloschwartz
4c000c1d49 add site online indicator to selector 2026-04-22 14:33:28 -07:00
miloschwartz
ea4ff75552 cosmetic adjustments 2026-04-22 14:25:06 -07:00
Owen
c78b866087 Add translations 2026-04-22 14:04:21 -07:00
miloschwartz
48b6e98bbc visual improvements 2026-04-22 12:25:01 -07:00
Owen
3d5260b13e Fix strings and local sites 2026-04-22 12:23:59 -07:00
miloschwartz
d0b0d95b9a fix squished alert card when disabled 2026-04-22 12:16:39 -07:00
miloschwartz
c2c8b7a631 disable overflow on header row for tables 2026-04-22 12:08:57 -07:00
Owen
9bc11b8717 Merge branch 'main' into dev 2026-04-22 11:38:14 -07:00
miloschwartz
1d53211fe0 fix logo size 2026-04-21 23:16:06 -07:00
Siddharth Bansal
473bce856d Pass installdir as a parameter 2026-04-20 21:36:42 +05:30
Siddharth Bansal
2c8b7b5ca5 (fix): Added a logrotate function to the crowdsec.go installer file 2026-04-19 12:33:59 +05:30
167 changed files with 4846 additions and 1589 deletions

View File

@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Always localize strings and use the `t` function to convert keys to strings. Add the keys to the en-us.json file. Never edit the other language files, as en-us.json is the single source of truth.

View File

@@ -0,0 +1,7 @@
---
description:
alwaysApply: true
---
Proxy resources = public resources
Private resources = client resources = site resources

View File

@@ -6,12 +6,13 @@ import (
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func installCrowdsec(config Config) error {
func installCrowdsec(config Config, installDir string) error {
if err := stopContainers(config.InstallationContainerType); err != nil {
return fmt.Errorf("failed to stop containers: %v", err)
@@ -40,6 +41,8 @@ func installCrowdsec(config Config) error {
os.Exit(1)
}
setupTraefikLogRotate(installDir)
if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil {
fmt.Printf("Error copying docker service: %v\n", err)
os.Exit(1)
@@ -208,3 +211,69 @@ func CheckAndAddCrowdsecDependency(composePath string) error {
fmt.Println("Added dependency of crowdsec to traefik")
return nil
}
// setupTraefikLogRotate writes a logrotate config for the Traefik access log
// that CrowdSec depends on. This is only needed when CrowdSec is installed
// because the default Pangolin install does not enable Traefik access logs.
//
// copytruncate is used so Traefik does not need to be restarted or sent a
// signal after rotation — it keeps writing to the same file descriptor while
// the rotated copy is made and the original is truncated in place.
func setupTraefikLogRotate(installDir string) {
const logrotateDir = "/etc/logrotate.d"
const logrotateFile = "/etc/logrotate.d/pangolin-traefik"
logPath := filepath.Join(installDir, "config/traefik/logs/access.log")
if os.Geteuid() != 0 {
fmt.Println("\n[logrotate] Skipping automatic logrotate setup: not running as root.")
fmt.Println("[logrotate] To prevent unbounded growth of the Traefik access log used by CrowdSec,")
fmt.Println("[logrotate] create the file /etc/logrotate.d/pangolin-traefik manually with:")
printLogrotateConfig(logPath)
return
}
config := fmt.Sprintf(`# Logrotate config for Traefik access logs used by CrowdSec.
# Generated by the Pangolin installer. Safe to edit.
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
if err := os.MkdirAll(logrotateDir, 0755); err != nil {
fmt.Printf("[logrotate] Warning: could not create %s: %v\n", logrotateDir, err)
return
}
if err := os.WriteFile(logrotateFile, []byte(config), 0644); err != nil {
fmt.Printf("[logrotate] Warning: could not write %s: %v\n", logrotateFile, err)
fmt.Println("[logrotate] Set it up manually:")
printLogrotateConfig(logPath)
return
}
fmt.Printf("[logrotate] Wrote logrotate config to %s\n", logrotateFile)
fmt.Println("[logrotate] Traefik access logs will be rotated daily, keeping 7 compressed copies.")
}
// printLogrotateConfig prints a logrotate config block to stdout so users can
// set it up manually when the installer cannot write to /etc.
func printLogrotateConfig(logPath string) {
fmt.Printf(`
%s {
daily
rotate 7
compress
delaycompress
missingok
notifempty
copytruncate
}
`, logPath)
}

View File

@@ -259,7 +259,7 @@ func main() {
}
config.DoCrowdsecInstall = true
err := installCrowdsec(config)
err := installCrowdsec(config, installDir)
if err != nil {
fmt.Printf("Error installing CrowdSec: %v\n", err)
return

View File

@@ -93,6 +93,8 @@
"siteConfirmCopy": "I have copied the config",
"searchSitesProgress": "Search sites...",
"siteAdd": "Add Site",
"sitesTableViewPublicResources": "View Public Resources",
"sitesTableViewPrivateResources": "View Private Resources",
"siteInstallNewt": "Install Site",
"siteInstallNewtDescription": "Install the site connector for your system",
"WgConfiguration": "WireGuard Configuration",
@@ -110,6 +112,21 @@
"siteUpdatedDescription": "The site has been updated.",
"siteGeneralDescription": "Configure the general settings for this site",
"siteSettingDescription": "Configure the settings on the site",
"siteResourcesTab": "Resources",
"siteResourcesNoneOnSite": "This site has no public or private resources yet.",
"siteResourcesSectionPublic": "Public Resources",
"siteResourcesSectionPrivate": "Private Resources",
"siteResourcesSectionPublicDescription": "Resources exposed externally through domains or ports.",
"siteResourcesSectionPrivateDescription": "Resources available on your private network through the site.",
"siteResourcesViewAllPublic": "View all resources",
"siteResourcesViewAllPrivate": "View all resources",
"siteResourcesDialogDescription": "Overview of public and private resources associated with this site.",
"siteResourcesShowMore": "Show more",
"siteResourcesPermissionDenied": "You do not have permission to list these resources.",
"siteResourcesEmptyPublic": "No public resources target this site yet.",
"siteResourcesEmptyPrivate": "No private resources are associated with this site yet.",
"siteResourcesHowToAccess": "How to access",
"siteResourcesTargetsOnSite": "Targets on this site",
"siteSetting": "{siteName} Settings",
"siteNewtTunnel": "Newt Site (Recommended)",
"siteNewtTunnelDescription": "Easiest way to create an entrypoint into any network. No extra setup.",
@@ -1415,6 +1432,7 @@
"alertingTriggerHcToggle": "Health check status changes",
"alertingTriggerResourceHealthy": "Resource healthy",
"alertingTriggerResourceUnhealthy": "Resource unhealthy",
"alertingTriggerResourceDegraded": "Resource degraded",
"alertingSearchHealthChecks": "Search health checks…",
"alertingHealthChecksEmpty": "No health checks available.",
"alertingTriggerResourceToggle": "Resource status changes",
@@ -1578,7 +1596,7 @@
"initialSetupDescription": "Create the intial server admin account. Only one server admin can exist. You can always change these credentials later.",
"createAdminAccount": "Create Admin Account",
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate Status",
"certificateStatus": "Certificate",
"loading": "Loading",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",
@@ -1886,6 +1904,7 @@
"configureHealthCheck": "Configure Health Check",
"configureHealthCheckDescription": "Set up health monitoring for {target}",
"enableHealthChecks": "Enable Health Checks",
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
"healthScheme": "Method",
"healthSelectScheme": "Select Method",
@@ -1947,6 +1966,8 @@
"httpMethod": "Scheme",
"selectHttpMethod": "Select scheme",
"domainPickerSubdomainLabel": "Subdomain",
"domainPickerWildcard": "Wildcard",
"domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.",
"domainPickerBaseDomainLabel": "Base Domain",
"domainPickerSearchDomains": "Search domains...",
"domainPickerNoDomainsFound": "No domains found",
@@ -1972,12 +1993,12 @@
"resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.",
"resourcesTableClients": "Clients",
"resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.",
"resourcesTableNoTargets": "No targets",
"resourcesTableHealthy": "Healthy",
"resourcesTableDegraded": "Degraded",
"resourcesTableOffline": "Offline",
"resourcesTableUnhealthy": "Unhealthy",
"resourcesTableUnknown": "Unknown",
"resourcesTableNotMonitored": "Not monitored",
"resourcesTableNoTargets": "No targets",
"editInternalResourceDialogEditClientResource": "Edit Private Resource",
"editInternalResourceDialogUpdateResourceProperties": "Update the resource configuration and access controls for {resourceName}",
"editInternalResourceDialogResourceProperties": "Resource Properties",
@@ -2908,6 +2929,7 @@
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
"privateMaintenanceScreenTitle": "Private Placeholder Screen",
"privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.",
"privateMaintenanceScreenSteps": "Once connected, if you are still seeing this message your browser's DNS cache may still point to the old address. To fix this: fully close and reopen this tab, or your browser, then navigate back to this page.",
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
"editDomain": "Edit Domain",
@@ -3142,5 +3164,39 @@
"idpDeleteAllOrgsMenu": "Delete",
"publicIpEndpoint": "Endpoint",
"lastTriggeredAt": "Last Trigger",
"reject": "Reject"
"reject": "Reject",
"uptimeDaysAgo": "{count} days ago",
"uptimeToday": "Today",
"uptimeNoDataAvailable": "No data available",
"uptimeSuffix": "uptime",
"uptimeDowntimeSuffix": "downtime",
"uptimeTooltipUptimeLabel": "Uptime",
"uptimeTooltipDowntimeLabel": "Downtime",
"uptimeOngoing": "ongoing",
"uptimeNoMonitoringData": "No monitoring data",
"uptimeNoData": "No data",
"uptimeMiniBarDown": "Down",
"uptimeSectionTitle": "Uptime",
"uptimeSectionDescription": "Availability over the last {days} days",
"uptimeAddAlert": "Add Alert",
"uptimeViewAlerts": "View Alerts",
"uptimeCreateEmailAlert": "Create Email Alert",
"uptimeAlertDescriptionSite": "Get notified by email when this site goes offline or comes back online.",
"uptimeAlertDescriptionResource": "Get notified by email when this resource goes offline or comes back online.",
"uptimeAlertNamePlaceholder": "Alert name",
"uptimeAdditionalEmails": "Additional Emails",
"uptimeCreateAlert": "Create Alert",
"uptimeAlertNoRecipients": "No recipients",
"uptimeAlertNoRecipientsDescription": "Please add at least one user, role, or email to notify.",
"uptimeAlertCreated": "Alert created",
"uptimeAlertCreatedDescription": "You will be notified when this changes status.",
"uptimeAlertCreateFailed": "Failed to create alert",
"webhookUrlLabel": "URL",
"webhookHeaderKeyPlaceholder": "Key",
"webhookHeaderValuePlaceholder": "Value",
"alertLabel": "Alert",
"domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.",
"domainPickerWildcardCertWarning": "Wildcard certificates must be configured separately in Traefik.",
"domainPickerWildcardCertWarningLink": "Learn more",
"health": "Health"
}

View File

@@ -484,6 +484,7 @@ export const alertRules = pgTable("alertRules", {
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"
>()
.notNull(),

View File

@@ -157,7 +157,9 @@ export const resources = pgTable("resources", {
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
postAuthPath: text("postAuthPath"),
health: varchar("health"), // "healthy", "unhealthy"
wildcard: boolean("wildcard").notNull().default(false)
});
export const targets = pgTable("targets", {

View File

@@ -25,7 +25,7 @@ import {
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility
} from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { and, eq, inArray, or, sql } from "drizzle-orm";
export type ResourceWithAuth = {
resource: Resource | null;
@@ -47,7 +47,17 @@ export type UserSessionWithUser = {
export async function getResourceByDomain(
domain: string
): Promise<ResourceWithAuth | null> {
const [result] = await db
// Build wildcard domain variants to match against.
// For a domain like "me.example.test.com", we want to match:
// - "*.example.test.com" (subdomain wildcard)
// - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent)
const parts = domain.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < parts.length; i++) {
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const potentialResults = await db
.select()
.from(resources)
.leftJoin(
@@ -70,8 +80,29 @@ export async function getResourceByDomain(
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.fullDomain, domain))
.limit(1);
.where(
or(
// Exact match
eq(resources.fullDomain, domain),
// Wildcard match: resource fullDomain is one of the wildcard candidates
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
if (!potentialResults.length) {
return null;
}
// Prefer exact match over wildcard match
const exactMatch = potentialResults.find(
(r) => r.resources?.fullDomain === domain
);
const result = exactMatch ?? potentialResults[0];
if (!result) {
return null;

View File

@@ -425,10 +425,18 @@ export const eventStreamingDestinations = sqliteTable(
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" }).notNull().default(false),
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" }).notNull().default(false),
sendActionLogs: integer("sendActionLogs", { mode: "boolean" }).notNull().default(false),
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" }).notNull().default(false),
sendConnectionLogs: integer("sendConnectionLogs", { mode: "boolean" })
.notNull()
.default(false),
sendRequestLogs: integer("sendRequestLogs", { mode: "boolean" })
.notNull()
.default(false),
sendActionLogs: integer("sendActionLogs", { mode: "boolean" })
.notNull()
.default(false),
sendAccessLogs: integer("sendAccessLogs", { mode: "boolean" })
.notNull()
.default(false),
type: text("type").notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: integer("enabled", { mode: "boolean" })
@@ -476,14 +484,19 @@ export const alertRules = sqliteTable("alertRules", {
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle"
>()
.notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
cooldownSeconds: integer("cooldownSeconds").notNull().default(300),
allSites: integer("allSites", { mode: "boolean" }).notNull().default(false),
allHealthChecks: integer("allHealthChecks", { mode: "boolean" }).notNull().default(false),
allResources: integer("allResources", { mode: "boolean" }).notNull().default(false),
allHealthChecks: integer("allHealthChecks", { mode: "boolean" })
.notNull()
.default(false),
allResources: integer("allResources", { mode: "boolean" })
.notNull()
.default(false),
lastTriggeredAt: integer("lastTriggeredAt"),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
@@ -531,19 +544,27 @@ export const alertEmailRecipients = sqliteTable("alertEmailRecipients", {
recipientId: integer("recipientId").primaryKey({ autoIncrement: true }),
emailActionId: integer("emailActionId")
.notNull()
.references(() => alertEmailActions.emailActionId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, { onDelete: "cascade" }),
roleId: integer("roleId").references(() => roles.roleId, { onDelete: "cascade" }),
.references(() => alertEmailActions.emailActionId, {
onDelete: "cascade"
}),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
roleId: integer("roleId").references(() => roles.roleId, {
onDelete: "cascade"
}),
email: text("email")
});
export const alertWebhookActions = sqliteTable("alertWebhookActions", {
webhookActionId: integer("webhookActionId").primaryKey({ autoIncrement: true }),
webhookActionId: integer("webhookActionId").primaryKey({
autoIncrement: true
}),
alertRuleId: integer("alertRuleId")
.notNull()
.references(() => alertRules.alertRuleId, { onDelete: "cascade" }),
webhookUrl: text("webhookUrl").notNull(),
config: text("config"), // encrypted JSON with auth config (authType, credentials)
config: text("config"), // encrypted JSON with auth config (authType, credentials)
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSentAt: integer("lastSentAt")
});

View File

@@ -178,7 +178,9 @@ export const resources = sqliteTable("resources", {
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
postAuthPath: text("postAuthPath"),
health: text("health"), // "healthy", "unhealthy"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
});
export const targets = sqliteTable("targets", {

View File

@@ -23,6 +23,7 @@ export type AlertEventType =
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
export type AlertNotificationProps = {
@@ -36,8 +37,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: string;
previewText: string;
summary: string;
statusLabel: string;
statusColor: string;
statusLabel: string | null;
statusColor: string | null;
} {
switch (eventType) {
case "site_online":
@@ -63,8 +64,8 @@ function getEventMeta(eventType: AlertEventType): {
heading: "Site Status Changed",
previewText: "A site in your organization has changed status.",
summary: "A site in your organization has changed status.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
statusLabel: null,
statusColor: null
};
case "health_check_healthy":
return {
@@ -93,8 +94,8 @@ function getEventMeta(eventType: AlertEventType): {
"A health check in your organization has changed status.",
summary:
"A health check in your organization has changed status.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
statusLabel: null,
statusColor: null
};
case "resource_healthy":
return {
@@ -114,14 +115,23 @@ function getEventMeta(eventType: AlertEventType): {
statusLabel: "Unhealthy",
statusColor: "#dc2626"
};
case "resource_degraded":
return {
heading: "Resource Unhealthy",
previewText: "A resource in your organization is not healthy.",
summary:
"A resource in your organization is currently unhealthy.",
statusLabel: "Unhealthy",
statusColor: "#dc2626"
};
case "resource_toggle":
return {
heading: "Resource Status Changed",
previewText:
"A resource in your organization has changed status.",
summary: "A resource in your organization has changed status.",
statusLabel: "Status Changed",
statusColor: "#f59e0b"
statusLabel: null,
statusColor: null
};
default:
return {
@@ -135,11 +145,29 @@ function getEventMeta(eventType: AlertEventType): {
}
}
function resolveToggleStatus(status: unknown): {
label: string;
color: string;
} {
switch (String(status).toLowerCase()) {
case "online":
return { label: "Online", color: "#16a34a" };
case "offline":
return { label: "Offline", color: "#dc2626" };
case "healthy":
return { label: "Healthy", color: "#16a34a" };
case "unhealthy":
return { label: "Unhealthy", color: "#dc2626" };
default:
return { label: String(status ?? "Unknown"), color: "#f59e0b" };
}
}
function formatDataItems(
data: Record<string, unknown>
): { label: string; value: React.ReactNode }[] {
return Object.entries(data)
.filter(([key]) => key !== "orgId")
.filter(([key]) => key !== "orgId" && key !== "status")
.map(([key, value]) => ({
label: key
.replace(/([A-Z])/g, " $1")
@@ -154,16 +182,36 @@ export const AlertNotification = (props: AlertNotificationProps) => {
const meta = getEventMeta(eventType);
const dataItems = formatDataItems(data);
const isToggle =
eventType === "site_toggle" ||
eventType === "health_check_toggle" ||
eventType === "resource_toggle";
const resolvedStatus = isToggle
? resolveToggleStatus(data.status)
: meta.statusLabel != null
? { label: meta.statusLabel, color: meta.statusColor! }
: null;
const allItems: { label: string; value: React.ReactNode }[] = [
{ label: "Organization", value: orgId },
{
label: "Status",
value: (
<span style={{ color: meta.statusColor, fontWeight: 600 }}>
{meta.statusLabel}
</span>
)
},
...(resolvedStatus != null
? [
{
label: "Status",
value: (
<span
style={{
color: resolvedStatus.color,
fontWeight: 600
}}
>
{resolvedStatus.label}
</span>
)
}
]
: []),
{ label: "Time", value: new Date().toUTCString() },
...dataItems
];

View File

@@ -4,7 +4,9 @@ export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
extra?: Record<string, unknown>
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
@@ -13,7 +15,20 @@ export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string,
extra?: Record<string, unknown>
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}

View File

@@ -2,19 +2,22 @@ export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {}
export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {}
export async function fireResourceToggleAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
): Promise<void> {}
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {}

View File

@@ -4,7 +4,8 @@ export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
@@ -13,7 +14,8 @@ export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx?: unknown
): Promise<void> {
return;
}
}

View File

@@ -23,7 +23,8 @@ export enum TierFeature {
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules"
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -64,5 +65,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"]
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
};

View File

@@ -293,7 +293,7 @@ export async function applyBlueprint({
orgId,
name:
name ??
`${faker.word.adjective()} ${faker.word.adjective()} ${faker.word.noun()}`,
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
contents: stringifyYaml(configData),
createdAt: Math.floor(Date.now() / 1000),
succeeded: blueprintSucceeded,

View File

@@ -1,5 +1,6 @@
import {
domains,
domainNamespaces,
orgDomains,
Resource,
resourceHeaderAuth,
@@ -33,6 +34,7 @@ import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
import { tierMatrix } from "../billing/tierMatrix";
export type ProxyResourcesResults = {
@@ -168,6 +170,19 @@ export async function updateProxyResources(
.returning();
healthchecksToUpdate.push(newHealthcheck);
// Insert unknown status history when HC is created in disabled state
if (!healthcheckData?.enabled) {
await fireHealthCheckUnknownAlert(
orgId,
newHealthcheck.targetHealthCheckId,
newHealthcheck.name,
newHealthcheck.targetId,
undefined,
true,
trx
);
}
}
// Find existing resource by niceId and orgId
@@ -236,6 +251,7 @@ export async function updateProxyResources(
fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId:
@@ -555,6 +571,21 @@ export async function updateProxyResources(
targetsToUpdate.push(updatedTarget);
}
}
// Insert unknown status history when HC is disabled
const isDisablingHc =
!healthcheckData?.enabled && oldHealthcheck?.hcEnabled;
if (isDisablingHc) {
await fireHealthCheckUnknownAlert(
orgId,
newHealthcheck.targetHealthCheckId,
newHealthcheck.name,
newHealthcheck.targetId,
undefined,
true,
trx
);
}
} else {
await createTarget(existingResource.resourceId, targetData);
}
@@ -683,6 +714,7 @@ export async function updateProxyResources(
fullDomain: http ? resourceData["full-domain"] : null,
subdomain: domain ? domain.subdomain : null,
domainId: domain ? domain.domainId : null,
wildcard: domain ? domain.wildcard : false,
enabled: resourceEnabled,
sso: resourceData.auth?.["sso-enabled"] || false,
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
@@ -1152,7 +1184,9 @@ async function getDomainId(
orgId: string,
fullDomain: string,
trx: Transaction
): Promise<{ subdomain: string | null; domainId: string } | null> {
): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
const isWildcardFullDomain = fullDomain.startsWith("*.");
const possibleDomains = await trx
.select()
.from(domains)
@@ -1165,6 +1199,11 @@ async function getDomainId(
}
const validDomains = possibleDomains.filter((domain) => {
// Wildcard full-domains are not allowed on CNAME domains
if (isWildcardFullDomain && domain.domains.type === "cname") {
return false;
}
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
return (
fullDomain === domain.domains.baseDomain ||
@@ -1182,6 +1221,21 @@ async function getDomainId(
const domainSelection = validDomains[0].domains;
const baseDomain = domainSelection.baseDomain;
// Wildcard full-domains are not allowed on namespace (provided/free) domains
if (isWildcardFullDomain) {
const [namespaceDomain] = await trx
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainSelection.domainId))
.limit(1);
if (namespaceDomain) {
throw new Error(
`Wildcard full-domains are not supported for provided or free domains: ${fullDomain}`
);
}
}
// remove the base domain of the domain
let subdomain = null;
if (fullDomain != baseDomain) {
@@ -1191,6 +1245,7 @@ async function getDomainId(
// Return the first valid domain
return {
subdomain: subdomain,
domainId: domainSelection.domainId
domainId: domainSelection.domainId,
wildcard: isWildcardFullDomain
};
}

View File

@@ -2,6 +2,7 @@ import { z } from "zod";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
export const SiteSchema = z.object({
name: z.string().min(1).max(100),
@@ -319,6 +320,34 @@ export const ResourceSchema = z
message:
"Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)"
}
)
.refine(
(resource) => {
const fullDomain = resource["full-domain"];
if (!fullDomain || !fullDomain.includes("*")) return true;
// A wildcard full-domain must be of the form *.labels.basedomain
// Extract the leftmost label(s) before the first non-wildcard segment.
// e.g. "*.level1.example.com" → subdomain candidate is "*.level1"
// We do this by finding the base domain: everything after the first
// real (non-wildcard) dot-separated segment pair.
//
// Simple rule: split on ".", first token must be "*", rest must be
// valid hostname labels, and there must be at least 2 remaining labels
// (so the full domain has a real base domain).
const parts = fullDomain.split(".");
if (parts[0] !== "*") return false; // * must be the very first label
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{
path: ["full-domain"],
message:
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
}
);
export function isTargetsOnlyResource(resource: any): boolean {
@@ -329,7 +358,7 @@ export const ClientResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
site: z.string(), // DEPRECATED IN FAVOR OF sites
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),

View File

@@ -1,14 +1,16 @@
import { db } from "@server/db";
import { domains, orgDomains } from "@server/db";
import { eq, and } from "drizzle-orm";
import { subdomainSchema } from "@server/lib/schemas";
import { domains, orgDomains, domainNamespaces, resources } from "@server/db";
import { eq, and, like, not } from "drizzle-orm";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import { fromError } from "zod-validation-error";
import config from "./config";
export type DomainValidationResult =
| {
success: true;
fullDomain: string;
subdomain: string | null;
wildcard: boolean;
}
| {
success: false;
@@ -66,6 +68,62 @@ export async function validateAndConstructDomain(
};
}
// Detect wildcard subdomain request
const isWildcard =
subdomain !== undefined &&
subdomain !== null &&
subdomain.includes("*") &&
domainRes.domains.type !== "cname";
// Wildcard subdomains are not allowed on CNAME domains
if (isWildcard && domainRes.domains.type === "cname") {
return {
success: false,
error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname."
};
}
// Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard) {
const [namespaceDomain] = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
}
if (
isWildcard &&
domainRes.domains.type == "wildcard" &&
!(
domainRes.domains.preferWildcardCert ||
config.getRawConfig().traefik.prefer_wildcard_cert
)
) {
return {
success: false,
error: "Wildcard domains are not supported without configuring certificate resolver for wildcard certs and marking it as prefered."
};
}
// Validate wildcard subdomain format
if (isWildcard) {
const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain);
if (!parsedWildcard.success) {
return {
success: false,
error: fromError(parsedWildcard.error).toString()
};
}
}
// Construct full domain based on domain type
let fullDomain = "";
let finalSubdomain = subdomain;
@@ -81,13 +139,16 @@ export async function validateAndConstructDomain(
finalSubdomain = null; // CNAME domains don't use subdomains
} else if (domainRes.domains.type === "wildcard") {
if (subdomain !== undefined && subdomain !== null) {
// Validate subdomain format for wildcard domains
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
if (!isWildcard) {
// Validate regular subdomain format for wildcard domains
const parsedSubdomain =
subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
}
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
@@ -100,13 +161,14 @@ export async function validateAndConstructDomain(
finalSubdomain = null;
}
// Convert to lowercase
// Convert to lowercase (preserve * as-is)
fullDomain = fullDomain.toLowerCase();
return {
success: true,
fullDomain,
subdomain: finalSubdomain ?? null
subdomain: finalSubdomain ?? null,
wildcard: isWildcard
};
} catch (error) {
return {
@@ -115,3 +177,81 @@ export async function validateAndConstructDomain(
};
}
}
/**
* Checks whether a given fullDomain conflicts with any existing wildcard resources,
* or (if the fullDomain is itself a wildcard) whether any existing resources would
* be matched by it.
*
* @param fullDomain - The fully-constructed domain to check (may contain a leading `*`)
* @param excludeResourceId - Optional resource ID to exclude from the check (for updates)
* @returns An object with `conflict: true` and a human-readable `message`, or `conflict: false`
*/
export async function checkWildcardDomainConflict(
fullDomain: string,
excludeResourceId?: number
): Promise<{ conflict: false } | { conflict: true; message: string }> {
const isWildcard = fullDomain.startsWith("*.");
if (isWildcard) {
// e.g. fullDomain = "*.example.com" → suffix = ".example.com"
const suffix = fullDomain.slice(1); // ".example.com"
// Find any existing non-wildcard resource whose fullDomain ends with this suffix
// e.g. "test.example.com" or "foo.example.com"
const conflicting = await db
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain
})
.from(resources)
.where(like(resources.fullDomain, `%${suffix}`));
const matches = conflicting.filter(
(r) =>
!r.fullDomain!.startsWith("*.") &&
r.fullDomain!.endsWith(suffix) &&
(excludeResourceId === undefined ||
r.resourceId !== excludeResourceId)
);
if (matches.length > 0) {
return {
conflict: true,
message: `Wildcard domain ${fullDomain} conflicts with existing resource(s): ${matches.map((r) => r.fullDomain).join(", ")}`
};
}
} else {
// Specific domain — check if any existing wildcard would match it.
// e.g. fullDomain = "test.example.com"
// We look for a wildcard "*.example.com" which means fullDomain ends with ".example.com"
const dotIndex = fullDomain.indexOf(".");
if (dotIndex !== -1) {
const suffix = fullDomain.slice(dotIndex); // ".example.com"
const wildcardPattern = `*.${fullDomain.slice(dotIndex + 1)}`; // "*.example.com"
const conflicting = await db
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain
})
.from(resources)
.where(eq(resources.fullDomain, wildcardPattern));
const matches = conflicting.filter(
(r) =>
excludeResourceId === undefined ||
r.resourceId !== excludeResourceId
);
if (matches.length > 0) {
return {
conflict: true,
message: `Domain ${fullDomain} conflicts with existing wildcard resource(s): ${matches.map((r) => r.fullDomain).join(", ")}`
};
}
}
}
return { conflict: false };
}

View File

@@ -1,5 +1,41 @@
import { z } from "zod";
/**
* Validates a wildcard subdomain passed as the leftmost component of a full domain.
*
* The value represents everything to the left of the base domain, so when combined
* with e.g. "example.com" it must produce a valid SSL-style wildcard hostname.
*
* Valid:
* "*" → *.example.com
* "*.level1" → *.level1.example.com
*
* Invalid:
* "*example" → *example.com (no dot after *)
* "level2.*.level1" → wildcard not in leftmost position
* "*.level1.*" → multiple wildcards
*/
export const wildcardSubdomainSchema = z
.string()
.refine(
(val) => {
// Must start with "*."; the remainder (if any) must be valid hostname labels.
// A bare "*" is also valid (becomes *.baseDomain directly).
if (val === "*") return true;
if (!val.startsWith("*.")) return false;
const rest = val.slice(2); // everything after "*."
// rest must not be empty, must not contain another "*",
// and every label must be a valid hostname label.
if (!rest || rest.includes("*")) return false;
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
return rest.split(".").every((label) => labelRegex.test(label));
},
{
message:
'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.'
}
);
export const subdomainSchema = z
.string()
.regex(

View File

@@ -18,7 +18,7 @@ export interface StatusHistoryDayBucket {
uptimePercent: number; // 0-100
totalDowntimeSeconds: number;
downtimeWindows: { start: number; end: number | null; status: string }[];
status: "good" | "degraded" | "bad" | "no_data";
status: "good" | "degraded" | "bad" | "no_data" | "unknown";
}
export interface StatusHistoryResponse {
@@ -54,6 +54,7 @@ export function computeBuckets(
const windows: { start: number; end: number | null; status: string }[] = [];
let dayDowntime = 0;
let dayDegradedTime = 0;
let windowStart = dayStartSec;
let windowStatus = currentStatus;
@@ -63,8 +64,8 @@ export function computeBuckets(
const windowEnd = evt.timestamp;
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
windowStatus === "unhealthy";
const isDegraded = windowStatus === "degraded";
if (isDown) {
dayDowntime += windowEnd - windowStart;
windows.push({
@@ -72,6 +73,13 @@ export function computeBuckets(
end: windowEnd,
status: windowStatus,
});
} else if (isDegraded) {
dayDegradedTime += windowEnd - windowStart;
windows.push({
start: windowStart,
end: windowEnd,
status: windowStatus,
});
}
}
windowStart = evt.timestamp;
@@ -83,8 +91,8 @@ export function computeBuckets(
const finalEnd = Math.min(dayEndSec, nowSec);
const isDown =
windowStatus === "offline" ||
windowStatus === "unhealthy" ||
windowStatus === "unknown";
windowStatus === "unhealthy";
const isDegraded = windowStatus === "degraded";
if (isDown && finalEnd > windowStart) {
dayDowntime += finalEnd - windowStart;
windows.push({
@@ -92,6 +100,13 @@ export function computeBuckets(
end: finalEnd,
status: windowStatus,
});
} else if (isDegraded && finalEnd > windowStart) {
dayDegradedTime += finalEnd - windowStart;
windows.push({
start: windowStart,
end: finalEnd,
status: windowStatus,
});
}
}
@@ -105,7 +120,7 @@ export function computeBuckets(
effectiveDayLength > 0
? Math.max(
0,
((effectiveDayLength - dayDowntime) /
((effectiveDayLength - dayDowntime - dayDegradedTime) /
effectiveDayLength) *
100
)
@@ -113,11 +128,27 @@ export function computeBuckets(
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
const hasAnyData = currentStatus !== null || dayEvents.length > 0;
// The whole observable window is "unknown" if every status we have seen is unknown
const allStatuses = [
...(currentStatus !== null ? [currentStatus] : []),
...dayEvents.map((e) => e.status)
];
const onlyUnknownData =
hasAnyData && allStatuses.every((s) => s === "unknown");
let status: StatusHistoryDayBucket["status"] = "no_data";
if (currentStatus !== null || dayEvents.length > 0) {
if (uptimePct >= 99) status = "good";
else if (uptimePct >= 50) status = "degraded";
else status = "bad";
if (hasAnyData) {
if (onlyUnknownData) {
status = "unknown";
} else if (dayDowntime > 0 && uptimePct < 50) {
status = "bad";
} else if (dayDowntime > 0 || dayDegradedTime > 0) {
status = "degraded";
} else {
status = "good";
}
}
buckets.push({

View File

@@ -2,7 +2,7 @@ import { PostHog } from "posthog-node";
import config from "./config";
import { getHostMeta } from "./hostMeta";
import logger from "@server/logger";
import { apiKeys, db, roles, siteResources } from "@server/db";
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import { APP_VERSION } from "./consts";
@@ -15,6 +15,7 @@ class TelemetryClient {
private client: PostHog | null = null;
private enabled: boolean;
private intervalId: NodeJS.Timeout | null = null;
private collectionIntervalDays = 14;
constructor() {
const enabled = config.getRawConfig().app.telemetry.anonymous_usage;
@@ -33,7 +34,7 @@ class TelemetryClient {
this.client = new PostHog(
"phc_QYuATSSZt6onzssWcYJbXLzQwnunIpdGGDTYhzK3VjX",
{
host: "https://pangolin.net/relay-O7yI"
host: "https://telemetry.fossorial.io/relay-O7yI"
}
);
@@ -72,7 +73,7 @@ class TelemetryClient {
logger.debug("Successfully sent analytics data");
});
},
336 * 60 * 60 * 1000
this.collectionIntervalDays * 24 * 60 * 60 * 1000 // Convert days to milliseconds
);
this.collectAndSendAnalytics().catch((err) => {
@@ -157,6 +158,14 @@ class TelemetryClient {
})
.from(sites);
const [numAlertRules] = await db
.select({ count: count() })
.from(alertRules);
const [blueprintsCount] = await db
.select({ count: count() })
.from(blueprints);
const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources);
@@ -165,11 +174,14 @@ class TelemetryClient {
let numPrivResourceAliases = 0;
let numPrivResourceHosts = 0;
let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
} else if (res.mode === "cidr") {
numPrivResourceCidr += 1;
} else if (res.mode === "http") {
numPrivResourceHttp += 1;
}
if (res.alias) {
@@ -187,6 +199,9 @@ class TelemetryClient {
numPrivateResources: numPrivResources,
numPrivateResourceAliases: numPrivResourceAliases,
numPrivateResourceHosts: numPrivResourceHosts,
numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp,
numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,
numIdentityProviders: idpCount.count,
@@ -197,6 +212,7 @@ class TelemetryClient {
appVersion: APP_VERSION,
numApiKeys: numApiKeys.count,
numCustomRoles: customRoles.count,
numBlueprints: blueprintsCount.count,
supporterStatus: {
valid: supporterKey?.valid || false,
tier: supporterKey?.tier || "None",
@@ -285,10 +301,12 @@ class TelemetryClient {
num_private_resource_aliases:
stats.numPrivateResourceAliases,
num_private_resource_hosts: stats.numPrivateResourceHosts,
num_private_resource_cidr: stats.numPrivateResourceCidr,
num_user_devices: stats.numUserDevices,
num_machine_clients: stats.numMachineClients,
num_identity_providers: stats.numIdentityProviders,
num_sites_online: stats.numSitesOnline,
num_blueprint_runs: stats.numBlueprints,
num_resources_sso_enabled: stats.resources.filter(
(r) => r.sso
).length,

View File

@@ -280,6 +280,7 @@ async function syncAcmeCerts(
for (const cert of resolverData.Certificates) {
const domain = cert.domain?.main;
const wildcard = domain.startsWith("*.");
if (!domain) {
logger.debug(`acmeCertSync: skipping cert with missing domain`);
@@ -309,7 +310,11 @@ async function syncAcmeCerts(
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.where(
and(
eq(certificates.domain, domain)
)
)
.limit(1);
let oldCertPem: string | null = null;
@@ -364,7 +369,6 @@ async function syncAcmeCerts(
}
}
const wildcard = domain.startsWith("*.");
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
@@ -387,6 +391,9 @@ async function syncAcmeCerts(
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
@@ -411,6 +418,9 @@ async function syncAcmeCerts(
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,

View File

@@ -13,6 +13,21 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import {
db,
statusHistory,
targetHealthCheck,
targets,
resources,
Transaction
} from "@server/db";
import { eq } from "drizzle-orm";
import {
fireResourceDegradedAlert,
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceUnknownAlert
} from "./resourceEvents";
// ---------------------------------------------------------------------------
// Public API
@@ -33,9 +48,26 @@ export async function fireHealthCheckHealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
extra?: Record<string, unknown>
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db,
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await handleResource(orgId, healthCheckTargetId, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_healthy",
orgId,
@@ -51,6 +83,7 @@ export async function fireHealthCheckHealthyAlert(
healthCheckId,
data: {
healthCheckId,
status: "healthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
@@ -78,9 +111,26 @@ export async function fireHealthCheckUnhealthyAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
extra?: Record<string, unknown>
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await handleResource(orgId, healthCheckTargetId, trx);
if (!send) {
return;
}
await processAlerts({
eventType: "health_check_unhealthy",
orgId,
@@ -96,6 +146,7 @@ export async function fireHealthCheckUnhealthyAlert(
healthCheckId,
data: {
healthCheckId,
status: "unhealthy",
...(healthCheckName != null ? { healthCheckName } : {}),
...extra
}
@@ -107,3 +158,130 @@ export async function fireHealthCheckUnhealthyAlert(
);
}
}
export async function fireHealthCheckUnknownAlert(
orgId: string,
healthCheckId: number,
healthCheckName?: string | null,
healthCheckTargetId?: number | null,
extra?: Record<string, unknown>,
send: boolean = true,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "health_check",
entityId: healthCheckId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await handleResource(orgId, healthCheckTargetId, trx);
if (!send) {
return;
}
} catch (err) {
logger.error(
`fireHealthCheckUnknownAlert: unexpected error for healthCheckId ${healthCheckId}`,
err
);
}
}
async function handleResource(orgId: string, healthCheckTargetId?: number | null, trx: Transaction | typeof db = db) {
if (!healthCheckTargetId) {
return;
}
// we have resources lets get them
const [target] = await trx
.select()
.from(targets)
.where(eq(targets.targetId, healthCheckTargetId))
.limit(1);
if (!target) {
return;
}
const [resource] = await trx
.select()
.from(resources)
.where(eq(resources.resourceId, target.resourceId))
.limit(1);
if (!resource) {
return;
}
const otherTargets = await trx
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId))
.where(eq(targets.resourceId, resource.resourceId));
let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
if (allUnknown) {
logger.debug(
`Marking resource ${resource.resourceId} as unknown because all health checks are disabled`
);
health = "unknown";
} else if (allHealthy) {
health = "healthy";
} else if (allUnhealthy) {
logger.debug(
`Marking resource ${resource.resourceId} as unhealthy because all targets are unhealthy`
);
health = "unhealthy";
} else {
logger.debug(
`Marking resource ${resource.resourceId} as degraded because some targets are unhealthy`
);
health = "degraded";
}
if (health != resource.health) {
// it changed
await trx
.update(resources)
.set({ health })
.where(eq(resources.resourceId, resource.resourceId));
if (health === "unknown") {
await fireResourceUnknownAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
} else if (health === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
} else if (health === "healthy") {
await fireResourceHealthyAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
} else if (health === "degraded") {
await fireResourceDegradedAlert(
orgId,
resource.resourceId,
resource.name,
undefined,
trx
);
}
}
}

View File

@@ -13,6 +13,7 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, statusHistory, Transaction } from "@server/db";
// ---------------------------------------------------------------------------
// Public API
@@ -33,9 +34,18 @@ export async function fireResourceHealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "healthy",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "resource_healthy",
orgId,
@@ -51,6 +61,7 @@ export async function fireResourceHealthyAlert(
resourceId,
data: {
resourceId,
status: "healthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
@@ -78,9 +89,18 @@ export async function fireResourceUnhealthyAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "resource_unhealthy",
orgId,
@@ -96,6 +116,7 @@ export async function fireResourceUnhealthyAlert(
resourceId,
data: {
resourceId,
status: "unhealthy",
...(resourceName != null ? { resourceName } : {}),
...extra
}
@@ -109,9 +130,9 @@ export async function fireResourceUnhealthyAlert(
}
/**
* Fire a `resource_toggle` alert for the given resource.
* Fire a `resource_degraded` alert for the given resource.
*
* Call this when a resource's enabled/disabled status is toggled so that any
* Call this after a resource has been detected as degraded so that any
* matching `alertRules` can dispatch their email and webhook actions.
*
* @param orgId - Organisation that owns the resource.
@@ -119,15 +140,24 @@ export async function fireResourceUnhealthyAlert(
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceToggleAlert(
export async function fireResourceDegradedAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "degraded",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "resource_toggle",
eventType: "resource_degraded",
orgId,
resourceId,
data: {
@@ -135,9 +165,66 @@ export async function fireResourceToggleAlert(
...extra
}
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "degraded",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceToggleAlert: unexpected error for resourceId ${resourceId}`,
`fireResourceDegradedAlert: unexpected error for resourceId ${resourceId}`,
err
);
}
}
/**
* Fire a `resource_unknown` alert for the given resource.
*
* Call this when all health checks on a resource are disabled so that the
* resource status transitions to unknown.
*
* @param orgId - Organisation that owns the resource.
* @param resourceId - Numeric primary key of the resource.
* @param resourceName - Human-readable name shown in notifications (optional).
* @param extra - Any additional key/value pairs to include in the payload.
*/
export async function fireResourceUnknownAlert(
orgId: string,
resourceId: number,
resourceName?: string | null,
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId: orgId,
status: "unknown",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "resource_toggle",
orgId,
resourceId,
data: {
resourceId,
status: "unknown",
...(resourceName != null ? { resourceName } : {}),
...extra
}
});
} catch (err) {
logger.error(
`fireResourceUnknownAlert: unexpected error for resourceId ${resourceId}`,
err
);
}

View File

@@ -13,6 +13,9 @@
import logger from "@server/logger";
import { processAlerts } from "../processAlerts";
import { db, sites, statusHistory, targetHealthCheck, Transaction } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { fireHealthCheckUnhealthyAlert } from "./healthCheckEvents";
// ---------------------------------------------------------------------------
// Public API
@@ -33,9 +36,18 @@ export async function fireSiteOnlineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000)
});
await processAlerts({
eventType: "site_online",
orgId,
@@ -51,6 +63,7 @@ export async function fireSiteOnlineAlert(
siteId,
data: {
siteId,
status: "online",
...(siteName != null ? { siteName } : {}),
...extra
}
@@ -78,9 +91,45 @@ export async function fireSiteOfflineAlert(
orgId: string,
siteId: number,
siteName?: string,
extra?: Record<string, unknown>
extra?: Record<string, unknown>,
trx: Transaction | typeof db = db
): Promise<void> {
try {
await trx.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
const unhealthyHealthChecks = await trx
.update(targetHealthCheck)
.set({ hcHealth: "unhealthy" })
.where(
and(
eq(targetHealthCheck.orgId, orgId),
eq(targetHealthCheck.siteId, siteId)
)
)
.returning();
for (const healthCheck of unhealthyHealthChecks) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheckId} unhealthy due to site ${siteId} being marked offline`
);
await fireHealthCheckUnhealthyAlert(
healthCheck.orgId,
healthCheck.targetHealthCheckId,
healthCheck.name,
undefined,
undefined,
true,
trx
);
}
await processAlerts({
eventType: "site_offline",
orgId,
@@ -96,6 +145,7 @@ export async function fireSiteOfflineAlert(
siteId,
data: {
siteId,
status: "offline",
...(siteName != null ? { siteName } : {}),
...extra
}

View File

@@ -88,6 +88,8 @@ function buildSubject(context: AlertContext): string {
return "[Alert] Resource Healthy";
case "resource_unhealthy":
return "[Alert] Resource Unhealthy";
case "resource_degraded":
return "[Alert] Resource Degraded";
case "resource_toggle":
return "[Alert] Resource Status Changed";
default: {

View File

@@ -12,9 +12,14 @@
*/
import logger from "@server/logger";
import { AlertContext, WebhookAlertConfig } from "@server/routers/alertRule/types";
import {
AlertContext,
WebhookAlertConfig
} from "@server/routers/alertRule/types";
const REQUEST_TIMEOUT_MS = 15_000;
const MAX_RETRIES = 3;
const RETRY_BASE_DELAY_MS = 500;
/**
* Sends a single webhook POST for an alert event.
@@ -40,6 +45,7 @@ export async function sendAlertWebhook(
const payload = {
event: context.eventType,
timestamp: new Date().toISOString(),
status: deriveStatus(context.eventType, context.data),
data: {
orgId: context.orgId,
...context.data
@@ -49,52 +55,125 @@ export async function sendAlertWebhook(
const body = JSON.stringify(payload);
const headers = buildHeaders(webhookConfig);
const controller = new AbortController();
const timeoutHandle = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
let lastError: Error | undefined;
let response: Response;
try {
response = await fetch(url, {
method: webhookConfig.method ?? "POST",
headers,
body,
signal: controller.signal
});
} catch (err: unknown) {
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
throw new Error(
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
);
}
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Alert webhook: request to "${url}" failed ${msg}`);
} finally {
clearTimeout(timeoutHandle);
}
if (!response.ok) {
let snippet = "";
try {
const text = await response.text();
snippet = text.slice(0, 300);
} catch {
// best-effort
}
throw new Error(
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
(snippet ? ` ${snippet}` : "")
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController();
const timeoutHandle = setTimeout(
() => controller.abort(),
REQUEST_TIMEOUT_MS
);
let response: Response;
try {
response = await fetch(url, {
method: webhookConfig.method ?? "POST",
headers,
body,
signal: controller.signal
});
} catch (err: unknown) {
clearTimeout(timeoutHandle);
const isAbort = err instanceof Error && err.name === "AbortError";
if (isAbort) {
lastError = new Error(
`Alert webhook: request to "${url}" timed out after ${REQUEST_TIMEOUT_MS} ms`
);
} else {
const msg = err instanceof Error ? err.message : String(err);
lastError = new Error(
`Alert webhook: request to "${url}" failed ${msg}`
);
}
if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
logger.warn(
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed retrying in ${delay} ms. ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
continue;
} finally {
clearTimeout(timeoutHandle);
}
if (!response.ok) {
let snippet = "";
try {
const text = await response.text();
snippet = text.slice(0, 300);
} catch {
// best-effort
}
lastError = new Error(
`Alert webhook: server at "${url}" returned HTTP ${response.status} ${response.statusText}` +
(snippet ? ` ${snippet}` : "")
);
if (attempt < MAX_RETRIES) {
const delay = RETRY_BASE_DELAY_MS * 2 ** (attempt - 1);
logger.warn(
`Alert webhook: attempt ${attempt}/${MAX_RETRIES} failed retrying in ${delay} ms. ${lastError.message}`
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
continue;
}
logger.debug(
`Alert webhook sent successfully to "${url}" for event "${context.eventType}" (attempt ${attempt}/${MAX_RETRIES})`
);
return;
}
logger.debug(`Alert webhook sent successfully to "${url}" for event "${context.eventType}"`);
throw (
lastError ??
new Error(
`Alert webhook: all ${MAX_RETRIES} attempts failed for "${url}"`
)
);
}
// ---------------------------------------------------------------------------
// Status derivation
// ---------------------------------------------------------------------------
function deriveStatus(
eventType: AlertContext["eventType"],
data: Record<string, unknown>
): string {
switch (eventType) {
case "site_online":
return "online";
case "site_offline":
return "offline";
case "site_toggle":
return String(data.status ?? "unknown");
case "health_check_healthy":
case "resource_healthy":
return "healthy";
case "health_check_unhealthy":
case "resource_unhealthy":
return "unhealthy";
case "resource_degraded":
return "degraded";
case "health_check_toggle":
case "resource_toggle":
return String(data.status ?? "unknown");
default: {
const _exhaustive: never = eventType;
void _exhaustive;
return "unknown";
}
}
}
// ---------------------------------------------------------------------------
// Header construction (mirrors HttpLogDestination.buildHeaders)
// ---------------------------------------------------------------------------
function buildHeaders(webhookConfig: WebhookAlertConfig): Record<string, string> {
function buildHeaders(
webhookConfig: WebhookAlertConfig
): Record<string, string> {
const headers: Record<string, string> = {
"Content-Type": "application/json"
};

View File

@@ -0,0 +1,63 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
// ---------------------------------------------------------------------------
// Alert event types
// ---------------------------------------------------------------------------
export type AlertEventType =
| "site_online"
| "site_offline"
| "health_check_healthy"
| "health_check_not_healthy";
// ---------------------------------------------------------------------------
// Webhook authentication config (stored as encrypted JSON in the DB)
// ---------------------------------------------------------------------------
export type WebhookAuthType = "none" | "bearer" | "basic" | "custom";
/**
* Stored as an encrypted JSON blob in `alertWebhookActions.config`.
*/
export interface WebhookAlertConfig {
/** Authentication strategy for the webhook endpoint */
authType: WebhookAuthType;
/** Bearer token used when authType === "bearer" */
bearerToken?: string;
/** Basic credentials "username:password" used when authType === "basic" */
basicCredentials?: string;
/** Custom header name used when authType === "custom" */
customHeaderName?: string;
/** Custom header value used when authType === "custom" */
customHeaderValue?: string;
/** Extra headers to send with every webhook request */
headers?: Array<{ key: string; value: string }>;
/** HTTP method (default POST) */
method?: string;
}
// ---------------------------------------------------------------------------
// Internal alert event passed through the processing pipeline
// ---------------------------------------------------------------------------
export interface AlertContext {
eventType: AlertEventType;
orgId: string;
/** Set for site_online / site_offline events */
siteId?: number;
/** Set for health_check_* events */
healthCheckId?: number;
/** Human-readable context data included in emails and webhook payloads */
data: Record<string, unknown>;
}

View File

@@ -18,8 +18,7 @@ import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import cache from "#private/lib/cache";
import { build } from "@server/build";
// Define the return type for clarity and type safety
export type CertificateResult = {
@@ -78,6 +77,9 @@ export async function getValidCertificatesForDomains(
const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
const potentialCerts = await db
@@ -91,10 +93,13 @@ export async function getValidCertificatesForDomains(
or(
// Condition for exact matches on the requested domains
inArray(certificates.domain, domainsToQueryArray),
// Condition for wildcard matches on the parent domains
// Condition for wildcard matches on the parent domains (stored as "example.com" or "*.example.com")
parentDomainsArray.length > 0
? and(
inArray(certificates.domain, parentDomainsArray),
inArray(certificates.domain, [
...parentDomainsArray,
...wildcardPrefixedArray
]),
eq(certificates.wildcard, true)
)
: // If there are no possible parent domains, this condition is false
@@ -103,13 +108,18 @@ export async function getValidCertificatesForDomains(
)
);
// Helper to normalize a wildcard cert's domain to its bare parent domain (strips leading "*.")
const normalizeWildcardDomain = (domain: string): string =>
domain.startsWith("*.") ? domain.slice(2) : domain;
// 5. Process the database results, prioritizing exact matches over wildcards
const exactMatches = new Map<string, (typeof potentialCerts)[0]>();
const wildcardMatches = new Map<string, (typeof potentialCerts)[0]>();
for (const cert of potentialCerts) {
if (cert.wildcard) {
wildcardMatches.set(cert.domain, cert);
// Normalize to bare parent domain so lookups are consistent regardless of storage format
wildcardMatches.set(normalizeWildcardDomain(cert.domain), cert);
} else {
exactMatches.set(cert.domain, cert);
}
@@ -122,14 +132,15 @@ export async function getValidCertificatesForDomains(
if (exactMatches.has(domain)) {
foundCert = exactMatches.get(domain);
}
// Priority 2: Check for a wildcard certificate that matches the exact domain
// Priority 2: Check for a wildcard certificate whose normalized domain equals the queried domain
else {
if (wildcardMatches.has(domain)) {
foundCert = wildcardMatches.get(domain);
const normalizedDomain = normalizeWildcardDomain(domain);
if (wildcardMatches.has(normalizedDomain)) {
foundCert = wildcardMatches.get(normalizedDomain);
}
// Priority 3: Check for a wildcard match on the parent domain
else {
const parts = domain.split(".");
const parts = normalizedDomain.split(".");
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
if (wildcardMatches.has(parentDomain)) {

View File

@@ -33,7 +33,15 @@ import {
} from "drizzle-orm";
import logger from "@server/logger";
import config from "@server/lib/config";
import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db";
import {
orgs,
resources,
sites,
siteNetworks,
siteResources,
Target,
targets
} from "@server/db";
import {
sanitize,
encodePath,
@@ -100,6 +108,7 @@ export async function getTraefikConfig(
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
@@ -238,6 +247,7 @@ export async function getTraefikConfig(
priority: priority, // may be null, we fallback later
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
wildcard: row.wildcard,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
@@ -275,7 +285,10 @@ export async function getTraefikConfig(
mode: siteResources.mode
})
.from(siteResources)
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
@@ -376,7 +389,16 @@ export async function getTraefikConfig(
...additionalMiddlewares
];
let rule = `Host(\`${fullDomain}\`)`;
let rule: string;
if (resource.wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain
.slice(2) // remove leading "*."
.replace(/\./g, "\\.");
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
} else {
rule = `Host(\`${fullDomain}\`)`;
}
// priority logic
let priority: number;
@@ -419,7 +441,8 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.prefer_wildcard_cert;
const domainCertResolver = resource.domainCertResolver;
const preferWildcardCert = resource.preferWildcardCert;
const preferWildcardCert =
resource.preferWildcardCert || resource.wildcard;
let resolverName: string | undefined;
let preferWildcard: boolean | undefined;
@@ -566,7 +589,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
@@ -953,22 +976,17 @@ export async function getTraefikConfig(
};
// Middleware that rewrites any path to /maintenance-screen
config_output.http.middlewares[
siteResourceRewriteMiddlewareName
] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/private-maintenance-screen"
}
};
config_output.http.middlewares[siteResourceRewriteMiddlewareName] =
{
replacePathRegex: {
regex: "^/(.*)",
replacement: "/private-maintenance-screen"
}
};
// HTTP -> HTTPS redirect so the ACME challenge can be served
config_output.http.routers[
`${siteResourceRouterName}-redirect`
] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
config_output.http.routers[`${siteResourceRouterName}-redirect`] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`)`,
@@ -977,9 +995,7 @@ export async function getTraefikConfig(
// Determine TLS / cert-resolver configuration
let tls: any = {};
if (
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
const wildCard =
domainParts.length <= 2
@@ -1012,9 +1028,7 @@ export async function getTraefikConfig(
// HTTPS router - presence of this entry triggers cert generation
config_output.http.routers[siteResourceRouterName] = {
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${fullDomain}\`)`,
@@ -1024,9 +1038,7 @@ export async function getTraefikConfig(
// Assets bypass router - lets Next.js static files load without rewrite
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [
config.getRawConfig().traefik.https_entrypoint
],
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
priority: 101,

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { targetHealthCheck, statusHistory } from "@server/db";
import { targetHealthCheck } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -91,14 +91,6 @@ export async function triggerHealthCheckAlert(
);
}
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: healthCheckId,
orgId,
status: eventType === "health_check_healthy" ? "healthy" : "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
if (eventType === "health_check_healthy") {
await fireHealthCheckHealthyAlert(
orgId,

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, statusHistory } from "@server/db";
import { resources } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -24,7 +24,7 @@ import { eq, and } from "drizzle-orm";
import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert,
fireResourceToggleAlert
fireResourceDegradedAlert
} from "#private/lib/alerts/events/resourceEvents";
const paramsSchema = z.strictObject({
@@ -33,7 +33,12 @@ const paramsSchema = z.strictObject({
});
const bodySchema = z.strictObject({
eventType: z.enum(["resource_healthy", "resource_unhealthy", "resource_toggle"])
eventType: z.enum([
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
])
});
export type TriggerResourceAlertResponse = {
@@ -89,16 +94,6 @@ export async function triggerResourceAlert(
);
}
if (eventType === "resource_healthy" || eventType === "resource_unhealthy") {
await db.insert(statusHistory).values({
entityType: "resource",
entityId: resourceId,
orgId,
status: eventType === "resource_healthy" ? "healthy" : "unhealthy",
timestamp: Math.floor(Date.now() / 1000)
});
}
if (eventType === "resource_healthy") {
await fireResourceHealthyAlert(
orgId,
@@ -111,8 +106,8 @@ export async function triggerResourceAlert(
resourceId,
resource.name ?? undefined
);
} else {
await fireResourceToggleAlert(
} else if (eventType === "resource_degraded") {
await fireResourceDegradedAlert(
orgId,
resourceId,
resource.name ?? undefined
@@ -132,4 +127,4 @@ export async function triggerResourceAlert(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -14,7 +14,7 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { sites, statusHistory } from "@server/db";
import { sites } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -83,14 +83,6 @@ export async function triggerSiteAlert(
);
}
await db.insert(statusHistory).values({
entityType: "site",
entityId: siteId,
orgId,
status: eventType === "site_online" ? "online" : "offline",
timestamp: Math.floor(Date.now() / 1000)
});
if (eventType === "site_online") {
await fireSiteOnlineAlert(orgId, siteId, site.name ?? undefined);
} else {
@@ -110,4 +102,4 @@ export async function triggerSiteAlert(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
}

View File

@@ -33,7 +33,11 @@ import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { CreateAlertRuleResponse } from "@server/routers/alertRule/types";
export const SITE_EVENT_TYPES = ["site_online", "site_offline", "site_toggle"] as const;
export const SITE_EVENT_TYPES = [
"site_online",
"site_offline",
"site_toggle"
] as const;
export const HC_EVENT_TYPES = [
"health_check_healthy",
"health_check_unhealthy",
@@ -42,6 +46,7 @@ export const HC_EVENT_TYPES = [
export const RESOURCE_EVENT_TYPES = [
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
] as const;
@@ -92,19 +97,24 @@ const bodySchema = z
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
val.eventType
);
const isResourceEvent = (
RESOURCE_EVENT_TYPES as readonly string[]
).includes(val.eventType);
if (isSiteEvent && !val.allSites && val.siteIds.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one siteId is required for site event types when allSites is false",
message:
"At least one siteId is required for site event types when allSites is false",
path: ["siteIds"]
});
}
if (isHcEvent && !val.allHealthChecks && val.healthCheckIds.length === 0) {
if (
isHcEvent &&
!val.allHealthChecks &&
val.healthCheckIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
@@ -129,10 +139,15 @@ const bodySchema = z
});
}
if (isResourceEvent && !val.allResources && val.resourceIds.length === 0) {
if (
isResourceEvent &&
!val.allResources &&
val.resourceIds.length === 0
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "At least one resourceId is required for resource event types when allResources is false",
message:
"At least one resourceId is required for resource event types when allResources is false",
path: ["resourceIds"]
});
}
@@ -148,7 +163,8 @@ const bodySchema = z
if (isResourceEvent && val.healthCheckIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "healthCheckIds must not be set for resource event types",
message:
"healthCheckIds must not be set for resource event types",
path: ["healthCheckIds"]
});
}
@@ -164,7 +180,8 @@ const bodySchema = z
if (isHcEvent && val.resourceIds.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "resourceIds must not be set for health check event types",
message:
"resourceIds must not be set for health check event types",
path: ["resourceIds"]
});
}
@@ -284,9 +301,7 @@ export async function createAlertRule(
// Create the email action pivot row and recipients if any recipients
// were supplied (userIds, roleIds, or raw emails).
const hasRecipients =
userIds.length > 0 ||
roleIds.length > 0 ||
emails.length > 0;
userIds.length > 0 || roleIds.length > 0 || emails.length > 0;
if (hasRecipients) {
const [emailActionRow] = await db

View File

@@ -76,6 +76,7 @@ const SITE_ALERT_EVENT_TYPES = [
const RESOURCE_ALERT_EVENT_TYPES = [
"resource_healthy",
"resource_unhealthy",
"resource_degraded",
"resource_toggle"
] as const;

View File

@@ -15,7 +15,6 @@ import { Certificate, certificates, db, domains } from "@server/db";
import logger from "@server/logger";
import { Transaction } from "@server/db";
import { eq, or, and, like } from "drizzle-orm";
import privateConfig from "#private/lib/config";
/**
* Checks if a certificate exists for the given domain.
@@ -27,10 +26,6 @@ export async function createCertificate(
domain: string,
trx: Transaction | typeof db
) {
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
return;
}
const [domainRecord] = await trx
.select()
.from(domains)
@@ -42,18 +37,25 @@ export async function createCertificate(
}
let existing: Certificate[] = [];
if (domainRecord.type == "ns") {
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await trx
.select()
.from(certificates)
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.wildcard, true), // only NS domains can have wildcard certs
or(
eq(certificates.domain, domain),
eq(certificates.domain, domainLevelDown)
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
)
)
);
@@ -75,11 +77,28 @@ export async function createCertificate(
return;
}
let domainToWrite = domain;
if (
domainRecord.type == "wildcard" &&
domainRecord.preferWildcardCert &&
!domain.startsWith("*.")
) {
// in this case traefik is going to generate a domain one level down so we need to store it that way
const parts = domain.split(".");
if (parts.length > 2) {
domainToWrite = parts.slice(1).join(".");
domainToWrite = `*.${domainToWrite}`;
}
}
// No cert found, create a new one in pending state
await trx.insert(certificates).values({
domain,
domain: domainToWrite,
domainId,
wildcard: domainRecord.type == "ns", // we can only create wildcard certs for NS domains
wildcard:
domainRecord.type == "ns" ||
(domainRecord.type == "wildcard" &&
domainRecord.preferWildcardCert), // we can only create wildcard certs for NS domains
status: "pending",
updatedAt: Math.floor(Date.now() / 1000),
createdAt: Math.floor(Date.now() / 1000)

View File

@@ -41,8 +41,9 @@ async function query(domainId: string, domain: string) {
}
let existing: any[] = [];
if (domainRecord.type == "ns") {
if (domainRecord.type == "ns" || domainRecord.type == "wildcard") {
const domainLevelDown = domain.split(".").slice(1).join(".");
const wildcardPrefixed = `*.${domainLevelDown}`;
existing = await db
.select({
@@ -61,10 +62,15 @@ async function query(domainId: string, domain: string) {
.where(
and(
eq(certificates.domainId, domainId),
eq(certificates.wildcard, true), // only NS domains can have wildcard certs
or(
eq(certificates.domain, domain),
eq(certificates.domain, domainLevelDown)
and(
eq(certificates.wildcard, true),
or(
eq(certificates.domain, domainLevelDown),
eq(certificates.domain, wildcardPrefixed)
)
)
)
)
);

View File

@@ -22,6 +22,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
import { fireHealthCheckUnhealthyAlert } from "#private/lib/alerts";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty()
@@ -141,10 +142,20 @@ export async function createHealthCheck(
hcStatus: hcStatus ?? null,
hcTlsServerName: hcTlsServerName ?? null,
hcHealthyThreshold,
hcUnhealthyThreshold
hcUnhealthyThreshold,
hcHealth: "unhealthy"
})
.returning();
await fireHealthCheckUnhealthyAlert(
record.orgId,
record.targetHealthCheckId,
record.name || "",
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
// Push health check to newt if the site is a newt site
if (siteId) {
const [site] = await db

View File

@@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, isNull } from "drizzle-orm";
import { addStandaloneHealthCheck } from "@server/routers/newt/targets";
import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert, fireHealthCheckHealthyAlert } from "#private/lib/alerts";
const paramsSchema = z
.object({
@@ -166,6 +167,17 @@ export async function updateHealthCheck(
const updateData: Record<string, unknown> = {};
const [existingHealthCheck] = await db
.select()
.from(targetHealthCheck)
.where(
and(
eq(targetHealthCheck.targetHealthCheckId, healthCheckId),
eq(targetHealthCheck.orgId, orgId)
)
)
.limit(1);
if (name !== undefined) updateData.name = name;
if (siteId !== undefined) updateData.siteId = siteId;
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
@@ -190,6 +202,26 @@ export async function updateHealthCheck(
if (hcUnhealthyThreshold !== undefined)
updateData.hcUnhealthyThreshold = hcUnhealthyThreshold;
const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true &&
existingHealthCheck.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if (
parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null
) {
hcHealthValue = "unknown";
} else if (hcEnabledTurnedOn) {
hcHealthValue = "unhealthy";
} else {
hcHealthValue = undefined;
}
if (hcHealthValue) {
updateData.hcHealth = hcHealthValue;
}
const [updated] = await db
.update(targetHealthCheck)
.set(updateData)
@@ -202,6 +234,37 @@ export async function updateHealthCheck(
)
.returning();
if (updated.hcHealth === "unhealthy" && existingHealthCheck.hcHealth !== "unhealthy") {
await fireHealthCheckUnhealthyAlert(
updated.orgId,
updated.targetHealthCheckId,
updated.name || "",
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (updated.hcHealth === "unknown" && existingHealthCheck.hcHealth !== "unknown") {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
updated.orgId,
updated.targetHealthCheckId,
updated.name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (updated.hcHealth === "healthy" && existingHealthCheck.hcHealth !== "healthy") {
await fireHealthCheckHealthyAlert(
updated.orgId,
updated.targetHealthCheckId,
updated.name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
}
// Push updated health check to newt if the site is a newt site
const [newt] = await db
.select()

View File

@@ -50,7 +50,7 @@ import {
userOrgRoles,
roles
} from "@server/db";
import { eq, and, inArray, isNotNull, ne } from "drizzle-orm";
import { eq, and, inArray, isNotNull, ne, or, sql } from "drizzle-orm";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
@@ -492,7 +492,15 @@ hybridRouter.get(
);
}
const [result] = await db
// Build wildcard domain candidates for the requested domain.
// e.g. "me.example.test.com" -> ["*.example.test.com", "*.test.com"]
const domainParts = domain.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < domainParts.length; i++) {
wildcardCandidates.push(`*.${domainParts.slice(i).join(".")}`);
}
const potentialResults = await db
.select()
.from(resources)
.leftJoin(
@@ -515,10 +523,28 @@ hybridRouter.get(
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(eq(resources.fullDomain, domain))
.limit(1);
.where(
or(
// Exact match
eq(resources.fullDomain, domain),
// Wildcard match
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
// Prefer exact match over wildcard match
const exactMatch = potentialResults.find(
(r) => r.resources?.fullDomain === domain
);
const result = exactMatch ?? potentialResults[0];
if (
result &&
await checkExitNodeOrg(
remoteExitNode.exitNodeId,
result.resources.orgId

View File

@@ -368,8 +368,8 @@ export async function signSshKey(
const parsedSudoCommands: string[] = [];
const parsedGroupsSet = new Set<string>();
let homedir: boolean | null = null;
const sudoModeOrder = { none: 0, commands: 1, all: 2 };
let sudoMode: "none" | "commands" | "all" = "none";
const sudoModeOrder = { none: 0, commands: 1, full: 2 };
let sudoMode: "none" | "commands" | "full" = "none";
for (const roleRow of roleRows) {
try {
const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]");
@@ -386,7 +386,7 @@ export async function signSshKey(
if (roleRow?.sshCreateHomeDir === true) homedir = true;
const m = roleRow?.sshSudoMode ?? "none";
if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) {
sudoMode = m as "none" | "commands" | "all";
sudoMode = m as "none" | "commands" | "full";
}
}
const parsedGroups = Array.from(parsedGroupsSet);

View File

@@ -37,6 +37,7 @@ export type GetAlertRuleResponse = {
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
enabled: boolean;
cooldownSeconds: number;
@@ -94,6 +95,7 @@ export type AlertEventType =
| "health_check_toggle"
| "resource_healthy"
| "resource_unhealthy"
| "resource_degraded"
| "resource_toggle";
// ---------------------------------------------------------------------------

View File

@@ -6,7 +6,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { resourceAccessToken, resources, sessions } from "@server/db";
import { db } from "@server/db";
import { eq } from "drizzle-orm";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import {
createResourceSession,
serializeResourceSessionCookie,
@@ -65,11 +65,31 @@ export async function exchangeSession(
const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
const [resource] = await db
const parts = cleanHost.split(".");
const wildcardCandidates: string[] = [];
for (let i = 1; i < parts.length; i++) {
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const potentialResources = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, cleanHost))
.limit(1);
.where(
or(
eq(resources.fullDomain, cleanHost),
wildcardCandidates.length > 0
? and(
eq(resources.wildcard, true),
inArray(resources.fullDomain, wildcardCandidates)
)
: sql`false`
)
);
const exactMatch = potentialResources.find(
(r) => r.fullDomain === cleanHost
);
const resource = exactMatch ?? potentialResources[0];
if (!resource) {
return next(
@@ -178,7 +198,7 @@ export async function exchangeSession(
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
cleanHost,
token,
!resource.ssl,
expiresAt ? new Date(expiresAt) : undefined

View File

@@ -1,8 +1,12 @@
import { MessageHandler } from "@server/routers/ws";
import { db, Newt, sites } from "@server/db";
import {
db,
Newt,
sites
} from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert } from "@server/lib/alerts";
import { fireSiteOfflineAlert } from "#dynamic/lib/alerts";
/**
* Handles disconnecting messages from sites to show disconnected in the ui
@@ -25,15 +29,17 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
try {
// Update the client's last ping timestamp
const [site] = await db
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, newt.siteId))
.returning();
await db.transaction(async (trx) => {
const [site] = await trx
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, newt.siteId!))
.returning();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name, undefined, trx);
});
} catch (error) {
logger.error("Error handling disconnecting message", { error });
}

View File

@@ -8,26 +8,26 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context;
const newt = client as Newt;
logger.info("Handling Docker socket check response");
logger.debug("Handling Docker socket check response");
if (!newt) {
logger.warn("Newt not found");
return;
}
logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
const { available, socketPath } = message.data;
logger.info(
logger.debug(
`Docker socket availability for Newt ${newt.newtId}: available=${available}, socketPath=${socketPath}`
);
if (available) {
logger.info(`Newt ${newt.newtId} has Docker socket access`);
logger.debug(`Newt ${newt.newtId} has Docker socket access`);
await cache.set(`${newt.newtId}:socketPath`, socketPath, 0);
await cache.set(`${newt.newtId}:isAvailable`, available, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker socket access`);
logger.debug(`Newt ${newt.newtId} does not have Docker socket access`);
}
return;
@@ -39,28 +39,28 @@ export const handleDockerContainersMessage: MessageHandler = async (
const { message, client, sendToClient } = context;
const newt = client as Newt;
logger.info("Handling Docker containers response");
logger.debug("Handling Docker containers response");
if (!newt) {
logger.warn("Newt not found");
return;
}
logger.info(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
logger.debug(`Newt ID: ${newt.newtId}, Site ID: ${newt.siteId}`);
const { containers } = message.data;
logger.info(
logger.debug(
`Docker containers for Newt ${newt.newtId}: ${containers ? containers.length : 0}`
);
if (containers && containers.length > 0) {
await cache.set(`${newt.newtId}:dockerContainers`, containers, 0);
} else {
logger.warn(`Newt ${newt.newtId} does not have Docker containers`);
logger.debug(`Newt ${newt.newtId} does not have Docker containers`);
}
if (!newt.siteId) {
logger.warn("Newt has no site!");
logger.debug("Newt has no site!");
return;
}

View File

@@ -1,8 +1,10 @@
import { db, newts, sites, targetHealthCheck, targets, statusHistory } from "@server/db";
import {
hasActiveConnections,
} from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not } from "drizzle-orm";
db,
newts,
sites
} from "@server/db";
import { hasActiveConnections } from "#dynamic/routers/ws";
import { eq, lt, isNull, and, or, ne, not, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOfflineAlert, fireSiteOnlineAlert } from "#dynamic/lib/alerts";
@@ -72,48 +74,20 @@ export const startNewtOfflineChecker = (): void => {
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection`
);
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, staleSite.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: staleSite.siteId,
orgId: staleSite.orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
const healthChecksOnSite = await db
.select()
.from(targetHealthCheck)
.innerJoin(
targets,
eq(targets.targetId, targetHealthCheck.targetId)
)
.innerJoin(sites, eq(sites.siteId, targets.siteId))
.where(eq(sites.siteId, staleSite.siteId));
for (const healthCheck of healthChecksOnSite) {
logger.info(
`Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline`
await fireSiteOfflineAlert(
staleSite.orgId,
staleSite.siteId,
staleSite.name,
undefined,
trx
);
await db
.update(targetHealthCheck)
.set({ hcHealth: "unknown" })
.where(
eq(
targetHealthCheck.targetHealthCheckId,
healthCheck.targetHealthCheck
.targetHealthCheckId
)
);
// TODO: should we be firing an alert here when the health check goes to unknown?
}
await fireSiteOfflineAlert(staleSite.orgId, staleSite.siteId, staleSite.name);
});
}
// this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites
@@ -150,20 +124,20 @@ export const startNewtOfflineChecker = (): void => {
`Marking wireguard site ${site.siteId} offline: no bandwidth update in over ${OFFLINE_THRESHOLD_BANDWIDTH_MS / 60000} minutes`
);
await db
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: false })
.where(eq(sites.siteId, site.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOfflineAlert(site.orgId, site.siteId, site.name);
await fireSiteOfflineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
} else if (
lastBandwidthUpdate >= wireguardOfflineThreshold &&
!site.online
@@ -172,20 +146,20 @@ export const startNewtOfflineChecker = (): void => {
`Marking wireguard site ${site.siteId} online: recent bandwidth update`
);
await db
.update(sites)
.set({ online: true })
.where(eq(sites.siteId, site.siteId));
await db.transaction(async (trx) => {
await trx
.update(sites)
.set({ online: true })
.where(eq(sites.siteId, site.siteId));
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
await fireSiteOnlineAlert(
site.orgId,
site.siteId,
site.name,
undefined,
trx
);
});
}
}
} catch (error) {

View File

@@ -1,5 +1,5 @@
import { db } from "@server/db";
import { sites, clients, olms, statusHistory } from "@server/db";
import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger";
import { fireSiteOnlineAlert } from "#dynamic/lib/alerts";
@@ -147,14 +147,9 @@ async function flushSitePingsToDb(): Promise<void> {
}, "flushSitePingsToDb");
for (const site of newlyOnlineSites) {
await db.insert(statusHistory).values({
entityType: "site",
entityId: site.siteId,
orgId: site.orgId,
status: "online",
timestamp: Math.floor(Date.now() / 1000),
}).execute();
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name);
await db.transaction(async (trx) => {
await fireSiteOnlineAlert(site.orgId, site.siteId, site.name, undefined, trx);
});
}
} catch (error) {
logger.error(

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, statusHistory } from "@server/db";
import {
siteProvisioningKeys,
siteProvisioningKeyOrg,
@@ -223,6 +223,14 @@ export async function registerNewt(
})
.returning();
await trx.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
newSiteId = newSite.siteId;
// Grant admin role access to the new site

View File

@@ -28,36 +28,18 @@ export async function addTargets(
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
);
// Create a map for quick lookup
const healthCheckMap = new Map<number, TargetHealthCheck>();
healthCheckData.forEach((hc) => {
if (hc.targetId !== null) {
healthCheckMap.set(hc.targetId, hc);
}
});
const healthCheckTargets = targets.map((target) => {
const hc = healthCheckMap.get(target.targetId);
// If no health check data found, skip this target
if (!hc) {
logger.warn(
`No health check configuration found for target ${target.targetId}`
);
return null;
}
const healthCheckTargets = healthCheckData.map((hc) => {
// Ensure all necessary fields are present
const isTCP = hc.hcMode?.toLowerCase() === "tcp";
if (!hc.hcHostname || !hc.hcPort || !hc.hcInterval) {
logger.debug(
`Skipping target ${target.targetId} due to missing health check fields`
`Skipping hc ${hc.targetHealthCheckId} due to missing health check fields`
);
return null;
}
if (!isTCP && (!hc.hcPath || !hc.hcMethod)) {
logger.debug(
`Skipping target ${target.targetId} due to missing HTTP health check fields`
`Skipping hc ${hc.targetHealthCheckId} due to missing HTTP health check fields`
);
return null;
}
@@ -105,7 +87,7 @@ export async function addTargets(
// Filter out any null values from health check targets
const validHealthCheckTargets = healthCheckTargets.filter(
(target) => target !== null
(hc) => hc !== null
);
await sendToClient(
@@ -213,6 +195,7 @@ export async function removeStandaloneHealthCheck(
export async function removeTargets(
newtId: string,
targets: Target[],
healthCheckData: TargetHealthCheck[],
protocol: string,
version?: string | null
) {
@@ -234,8 +217,8 @@ export async function removeTargets(
{ incrementConfigVersion: true }
);
const healthCheckTargets = targets.map((target) => {
return target.targetId;
const healthCheckTargets = healthCheckData.map((hc) => {
return hc.targetHealthCheckId;
});
await sendToClient(

View File

@@ -17,14 +17,15 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { subdomainSchema } from "@server/lib/schemas";
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import { OpenAPITags, registry } from "@server/openApi";
import { build } from "@server/build";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { getUniqueResourceName } from "@server/db/names";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
const createResourceParamsSchema = z.strictObject({
@@ -44,7 +45,10 @@ const createHttpResourceSchema = z
.refine(
(data) => {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
return (
subdomainSchema.safeParse(data.subdomain).success ||
wildcardSubdomainSchema.safeParse(data.subdomain).success
);
}
return true;
},
@@ -198,6 +202,22 @@ async function createHttpResource(
const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession;
// Wildcard subdomains are a paid feature
if (subdomain && subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
)
);
}
}
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
// grandfather in existing users
const lastAllowedDate = new Date("2026-04-13");
@@ -232,7 +252,7 @@ async function createHttpResource(
return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error));
}
const { fullDomain, subdomain: finalSubdomain } = domainResult;
const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult;
logger.debug(`Full domain: ${fullDomain}`);
@@ -251,6 +271,13 @@ async function createHttpResource(
);
}
const wildcardConflict = await checkWildcardDomainConflict(fullDomain);
if (wildcardConflict.conflict) {
return next(
createHttpError(HttpCode.CONFLICT, wildcardConflict.message)
);
}
// Prevent creating resource with same domain as dashboard
const dashboardUrl = config.getRawConfig().app.dashboard_url;
if (dashboardUrl) {
@@ -299,7 +326,9 @@ async function createHttpResource(
protocol: "tcp",
ssl: true,
stickySession: stickySession,
postAuthPath: postAuthPath
postAuthPath: postAuthPath,
wildcard,
health: "unknown"
})
.returning();

View File

@@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { db, targetHealthCheck } from "@server/db";
import { newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
@@ -66,44 +66,47 @@ export async function deleteResource(
);
}
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, deletedResource.siteId!))
// .limit(1);
//
// if (!site) {
// return next(
// createHttpError(
// HttpCode.NOT_FOUND,
// `Site with ID ${deletedResource.siteId} not found`
// )
// );
// }
//
// if (site.pubKey) {
// if (site.type == "wireguard") {
// await addPeer(site.exitNodeId!, {
// publicKey: site.pubKey,
// allowedIps: await getAllowedIps(site.siteId)
// });
// } else if (site.type == "newt") {
// // get the newt on the site by querying the newt table for siteId
// const [newt] = await db
// .select()
// .from(newts)
// .where(eq(newts.siteId, site.siteId))
// .limit(1);
//
// removeTargets(
// newt.newtId,
// targetsToBeRemoved,
// deletedResource.protocol,
// deletedResource.proxyPort
// );
// }
// }
//
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
)
);
}
if (site.pubKey) {
if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
const [healthCheck] = await db
.select()
.from(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, target.targetId));
await removeTargets(
newt.newtId,
[target],
[healthCheck],
deletedResource.protocol,
newt.version
);
}
}
}
return response(res, {
data: null,
success: true,

View File

@@ -32,6 +32,8 @@ export type GetResourceAuthInfoResponse = {
sso: boolean;
blockAccess: boolean;
url: string;
wildcard: boolean;
fullDomain: string | null;
whitelist: boolean;
skipToIdpId: number | null;
orgId: string;
@@ -130,7 +132,9 @@ export async function getResourceAuthInfo(
const headerAuthExtendedCompatibility =
result?.resourceHeaderAuthExtendedCompatibility;
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const url = resource.fullDomain
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
: null;
return response<GetResourceAuthInfoResponse>(res, {
data: {
@@ -145,7 +149,9 @@ export async function getResourceAuthInfo(
headerAuthExtendedCompatibility !== null,
sso: resource.sso,
blockAccess: resource.blockAccess,
url,
url: url ?? "",
wildcard: resource.wildcard ?? false,
fullDomain: resource.fullDomain,
whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId,

View File

@@ -86,7 +86,12 @@ export async function getUserResources(
.where(inArray(roleSiteResources.roleId, userRoleIds))
: Promise.resolve([]);
const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([
const [
directResources,
roleResourceResults,
directSiteResourceResults,
roleSiteResourceResults
] = await Promise.all([
directResourcesQuery,
roleResourcesQuery,
directSiteResourcesQuery,
@@ -118,24 +123,24 @@ export async function getUserResources(
}> = [];
if (accessibleResourceIds.length > 0) {
resourcesData = await db
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled
})
.from(resources)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
)
);
.select({
resourceId: resources.resourceId,
name: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
enabled: resources.enabled,
sso: resources.sso,
protocol: resources.protocol,
emailWhitelistEnabled: resources.emailWhitelistEnabled
})
.from(resources)
.where(
and(
inArray(resources.resourceId, accessibleResourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true)
)
);
}
// Get site resource details for accessible site resources
@@ -166,7 +171,10 @@ export async function getUserResources(
.from(siteResources)
.where(
and(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds),
inArray(
siteResources.siteResourceId,
accessibleSiteResourceIds
),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true)
)
@@ -246,7 +254,7 @@ export async function getUserResources(
enabled: siteResource.enabled,
alias: siteResource.alias,
aliasAddress: siteResource.aliasAddress,
type: 'site' as const
type: "site" as const
};
});

View File

@@ -105,15 +105,20 @@ const listResourcesSchema = z.object({
"Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)."
}),
healthStatus: z
.enum(["no_targets", "healthy", "degraded", "offline", "unknown"])
.enum(["healthy", "degraded", "unhealthy", "unknown"])
.optional()
.catch(undefined)
.openapi({
type: "string",
enum: ["no_targets", "healthy", "degraded", "offline", "unknown"],
enum: ["healthy", "degraded", "offline", "unknown"],
description:
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets."
})
"Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status."
}),
siteId: z.coerce.number<string>().int().positive().optional().openapi({
type: "integer",
description:
"When set, only resources that have at least one target on this site are returned"
})
});
// grouped by resource with targets[])
@@ -133,6 +138,8 @@ export type ResourceWithTargets = {
domainId: string | null;
niceId: string;
headerAuthId: number | null;
wildcard: boolean;
health: string | null;
targets: Array<{
targetId: number;
ip: string;
@@ -141,29 +148,14 @@ export type ResourceWithTargets = {
healthStatus: "healthy" | "unhealthy" | "unknown" | null;
siteName: string | null;
}>;
sites: Array<{
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
}>;
};
// Aggregate filters
const total_targets = count(targets.targetId);
const healthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1
ELSE 0
END
) `;
const unknown_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1
ELSE 0
END
) `;
const unhealthy_targets = sql<number>`SUM(
CASE
WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1
ELSE 0
END
) `;
function queryResourcesBase() {
return db
.select({
@@ -181,9 +173,11 @@ function queryResourcesBase() {
enabled: resources.enabled,
domainId: resources.domainId,
niceId: resources.niceId,
wildcard: resources.wildcard,
headerAuthId: resourceHeaderAuth.headerAuthId,
headerAuthExtendedCompatibilityId:
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId
resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId,
health: resources.health
})
.from(resources)
.leftJoin(
@@ -260,7 +254,8 @@ export async function listResources(
query,
healthStatus,
sort_by,
order
order,
siteId
} = parsedQuery.data;
const parsedParams = listResourcesParamsSchema.safeParse(req.params);
@@ -380,44 +375,23 @@ export async function listResources(
}
}
let aggregateFilters: SQL<any> | undefined = sql`1 = 1`;
if (typeof healthStatus !== "undefined") {
switch (healthStatus) {
case "healthy":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = ${total_targets}`
);
break;
case "degraded":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unhealthy_targets} > 0`
);
break;
case "no_targets":
aggregateFilters = sql`${total_targets} = 0`;
break;
case "offline":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${healthy_targets} = 0`,
sql`${unhealthy_targets} = ${total_targets}`
);
break;
case "unknown":
aggregateFilters = and(
sql`${total_targets} > 0`,
sql`${unknown_targets} = ${total_targets}`
);
break;
}
conditions.push(eq(resources.health, healthStatus));
}
if (siteId != null) {
const resourcesWithSite = db
.select({ resourceId: targets.resourceId })
.from(targets)
.innerJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(eq(sites.orgId, orgId), eq(sites.siteId, siteId))
);
conditions.push(
inArray(resources.resourceId, resourcesWithSite)
);
}
const baseQuery = queryResourcesBase()
.where(and(...conditions))
.having(aggregateFilters);
const baseQuery = queryResourcesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery
const countQuery = db.$count(baseQuery.as("filtered_resources"));
@@ -444,12 +418,15 @@ export async function listResources(
.select({
targetId: targets.targetId,
resourceId: targets.resourceId,
siteId: targets.siteId,
ip: targets.ip,
port: targets.port,
enabled: targets.enabled,
healthStatus: targetHealthCheck.hcHealth,
hcEnabled: targetHealthCheck.hcEnabled,
siteName: sites.name
siteName: sites.name,
siteNiceId: sites.niceId,
siteOnline: sites.online
})
.from(targets)
.where(inArray(targets.resourceId, resourceIdList))
@@ -478,10 +455,13 @@ export async function listResources(
http: row.http,
protocol: row.protocol,
proxyPort: row.proxyPort,
wildcard: row.wildcard,
enabled: row.enabled,
domainId: row.domainId,
headerAuthId: row.headerAuthId,
targets: []
health: row.health ?? null,
targets: [],
sites: []
};
map.set(row.resourceId, entry);
}
@@ -491,6 +471,33 @@ export async function listResources(
);
}
for (const entry of map.values()) {
const raw = allResourceTargets.filter(
(t) => t.resourceId === entry.resourceId
);
const siteById = new Map<
number,
{
siteId: number;
siteName: string;
siteNiceId: string;
online: boolean;
}
>();
for (const t of raw) {
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
continue;
}
siteById.set(t.siteId, {
siteId: t.siteId,
siteName: t.siteName ?? "",
siteNiceId: t.siteNiceId ?? "",
online: Boolean(t.siteOnline)
});
}
entry.sites = Array.from(siteById.values());
}
const resourcesList: ResourceWithTargets[] = Array.from(map.values());
return response<ListResourcesResponse>(res, {

View File

@@ -16,12 +16,15 @@ import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import config from "@server/lib/config";
import { tlsNameSchema } from "@server/lib/schemas";
import { subdomainSchema } from "@server/lib/schemas";
import {
tlsNameSchema,
subdomainSchema,
wildcardSubdomainSchema
} from "@server/lib/schemas";
import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils";
import { build } from "@server/build";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -43,7 +46,7 @@ const updateHttpResourceBodySchema = z
"niceId can only contain letters, numbers, and dashes"
)
.optional(),
subdomain: subdomainSchema.nullable().optional(),
subdomain: z.string().nullable().optional(),
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
@@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z
.refine(
(data) => {
if (data.subdomain) {
return subdomainSchema.safeParse(data.subdomain).success;
return (
subdomainSchema.safeParse(data.subdomain).success ||
wildcardSubdomainSchema.safeParse(data.subdomain).success
);
}
return true;
},
@@ -318,6 +324,22 @@ async function updateHttpResource(
}
}
// Wildcard subdomains are a paid feature
if (updateData.subdomain && updateData.subdomain.includes("*")) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.wildcardSubdomain
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
)
);
}
}
if (updateData.domainId) {
const domainId = updateData.domainId;
@@ -362,7 +384,11 @@ async function updateHttpResource(
);
}
const { fullDomain, subdomain: finalSubdomain } = domainResult;
const {
fullDomain,
subdomain: finalSubdomain,
wildcard
} = domainResult;
logger.debug(`Full domain: ${fullDomain}`);
@@ -384,6 +410,16 @@ async function updateHttpResource(
);
}
const wildcardConflict = await checkWildcardDomainConflict(
fullDomain,
resource.resourceId
);
if (wildcardConflict.conflict) {
return next(
createHttpError(HttpCode.CONFLICT, wildcardConflict.message)
);
}
// Prevent updating resource with same domain as dashboard
const dashboardUrl = config.getRawConfig().app.dashboard_url;
if (dashboardUrl) {
@@ -419,7 +455,7 @@ async function updateHttpResource(
if (fullDomain && fullDomain !== resource.fullDomain) {
await db
.update(resources)
.set({ fullDomain })
.set({ fullDomain, wildcard })
.where(eq(resources.resourceId, resource.resourceId));
}

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { clients, db, exitNodes } from "@server/db";
import { clients, db, exitNodes, statusHistory } from "@server/db";
import { roles, userSites, sites, roleSites, Site, orgs } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -321,12 +321,7 @@ export async function createSite(
const existingSite = await db
.select()
.from(sites)
.where(
and(
eq(sites.niceId, niceId),
eq(sites.orgId, orgId)
)
)
.where(and(eq(sites.niceId, niceId), eq(sites.orgId, orgId)))
.limit(1);
if (existingSite.length > 0) {
@@ -344,7 +339,8 @@ export async function createSite(
if (type == "newt") {
[newSite] = await trx
.insert(sites)
.values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
.values({
// NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT
orgId,
name,
niceId: updatedNiceId!,
@@ -354,6 +350,14 @@ export async function createSite(
status: "approved"
})
.returning();
await trx.insert(statusHistory).values({
entityType: "site",
entityId: newSite.siteId,
orgId: orgId,
status: "offline",
timestamp: Math.floor(Date.now() / 1000)
});
} else if (type == "wireguard") {
// we are creating a site with an exit node (tunneled)
if (!subnet) {

View File

@@ -5,6 +5,9 @@ import {
orgs,
remoteExitNodes,
roleSites,
siteNetworks,
siteResources,
targets,
sites,
userSites
} from "@server/db";
@@ -199,6 +202,18 @@ function querySitesBase() {
exitNodeName: exitNodes.name,
exitNodeEndpoint: exitNodes.endpoint,
remoteExitNodeId: remoteExitNodes.remoteExitNodeId,
resourceCount: sql<number>`(
SELECT COUNT(DISTINCT ${targets.resourceId})
FROM ${targets}
WHERE ${targets.siteId} = ${sites.siteId}
) + (
SELECT COUNT(DISTINCT ${siteResources.siteResourceId})
FROM ${siteResources}
INNER JOIN ${siteNetworks}
ON ${siteResources.networkId} = ${siteNetworks.networkId}
WHERE ${siteNetworks.siteId} = ${sites.siteId}
AND ${siteResources.orgId} = ${sites.orgId}
)`,
status: sites.status
})
.from(sites)
@@ -319,7 +334,6 @@ export async function listSites(
if (typeof status !== "undefined") {
conditions.push(eq(sites.status, status));
}
const baseQuery = querySitesBase().where(and(...conditions));
// we need to add `as` so that drizzle filters the result as a subquery

View File

@@ -31,6 +31,8 @@ import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { build } from "@server/build";
const createSiteResourceParamsSchema = z.strictObject({
orgId: z.string()
@@ -494,6 +496,10 @@ export async function createSiteResource(
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
);
if (ssl && mode === "http" && domainId && fullDomain && build != "oss") {
await createCertificate(domainId, fullDomain, db);
}
return response(res, {
data: newSiteResource,
success: true,

View File

@@ -4,7 +4,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode";
import type { PaginatedResponse } from "@server/types/Pagination";
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
@@ -68,6 +68,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
enum: ["asc", "desc"],
default: "asc",
description: "Sort order"
}),
siteId: z.coerce
.number<string>()
.int()
.positive()
.optional()
.openapi({
type: "integer",
description:
"When set, only site resources associated with this site (via network) are returned"
})
});
@@ -199,10 +209,31 @@ export async function listAllSiteResourcesByOrg(
}
const { orgId } = parsedParams.data;
const { page, pageSize, query, mode, sort_by, order } =
const { page, pageSize, query, mode, sort_by, order, siteId } =
parsedQuery.data;
const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) {
const resourcesForSite = db
.select({ id: siteResources.siteResourceId })
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
eq(siteResources.orgId, orgId),
eq(sites.orgId, orgId),
eq(sites.siteId, siteId)
)
);
conditions.push(
inArray(siteResources.siteResourceId, resourcesForSite)
);
}
if (query) {
conditions.push(
or(

View File

@@ -112,10 +112,7 @@ export async function listSiteResources(
const siteResourcesList = await db
.select()
.from(siteNetworks)
.innerJoin(
networks,
eq(siteNetworks.networkId, networks.networkId)
)
.innerJoin(networks, eq(siteNetworks.networkId, networks.networkId))
.innerJoin(
siteResources,
eq(siteResources.networkId, networks.networkId)
@@ -136,7 +133,6 @@ export async function listSiteResources(
.limit(limit)
.offset(offset);
return response(res, {
data: { siteResources: siteResourcesList },
success: true,

View File

@@ -1,6 +1,11 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, TargetHealthCheck, targetHealthCheck } from "@server/db";
import {
db,
statusHistory,
TargetHealthCheck,
targetHealthCheck
} from "@server/db";
import { newts, resources, sites, Target, targets } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
@@ -14,6 +19,7 @@ import { eq } from "drizzle-orm";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { fireHealthCheckHealthyAlert, fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
const createTargetParamsSchema = z.strictObject({
resourceId: z.string().transform(Number).pipe(z.int().positive())
@@ -245,13 +251,43 @@ export async function createTarget(
hcFollowRedirects: targetData.hcFollowRedirects ?? null,
hcMethod: targetData.hcMethod ?? null,
hcStatus: targetData.hcStatus ?? null,
hcHealth: "unknown",
hcHealth: targetData.hcEnabled ? "unhealthy" : "unknown",
hcTlsServerName: targetData.hcTlsServerName ?? null,
hcHealthyThreshold: targetData.hcHealthyThreshold ?? null,
hcUnhealthyThreshold: targetData.hcUnhealthyThreshold ?? null
})
.returning();
if (healthCheck[0].hcHealth === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (healthCheck[0].hcHealth === "unknown") {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (healthCheck[0].hcHealth === "healthy") {
await fireHealthCheckHealthyAlert(
healthCheck[0].orgId,
healthCheck[0].targetHealthCheckId,
healthCheck[0].name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
}
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {

View File

@@ -12,6 +12,7 @@ import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "./helpers";
import { OpenAPITags, registry } from "@server/openApi";
import { targetHealthCheck } from "@server/db/pg";
const deleteTargetSchema = z.strictObject({
targetId: z.string().transform(Number).pipe(z.int().positive())
@@ -46,6 +47,11 @@ export async function deleteTarget(
const { targetId } = parsedParams.data;
const [deletedHealthCheck] = await db
.delete(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
const [deletedTarget] = await db
.delete(targets)
.where(eq(targets.targetId, targetId))
@@ -74,38 +80,39 @@ export async function deleteTarget(
);
}
// const [site] = await db
// .select()
// .from(sites)
// .where(eq(sites.siteId, resource.siteId!))
// .limit(1);
//
// if (!site) {
// return next(
// createHttpError(
// HttpCode.NOT_FOUND,
// `Site with ID ${resource.siteId} not found`
// )
// );
// }
//
// if (site.pubKey) {
// if (site.type == "wireguard") {
// await addPeer(site.exitNodeId!, {
// publicKey: site.pubKey,
// allowedIps: await getAllowedIps(site.siteId)
// });
// } else if (site.type == "newt") {
// // get the newt on the site by querying the newt table for siteId
// const [newt] = await db
// .select()
// .from(newts)
// .where(eq(newts.siteId, site.siteId))
// .limit(1);
//
// removeTargets(newt.newtId, [deletedTarget], resource.protocol, resource.proxyPort);
// }
// }
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, targets.siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${targets.siteId} not found`
)
);
}
if (site.pubKey) {
if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await removeTargets(
newt.newtId,
[deletedTarget],
[deletedHealthCheck],
resource.protocol,
newt.version
);
}
}
return response(res, {
data: null,

View File

@@ -1,10 +1,6 @@
import {
db,
targets,
resources,
sites,
targetHealthCheck,
statusHistory
targetHealthCheck
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db";
@@ -14,10 +10,7 @@ import {
fireHealthCheckHealthyAlert,
fireHealthCheckUnhealthyAlert
} from "#dynamic/lib/alerts";
import {
fireResourceHealthyAlert,
fireResourceUnhealthyAlert
} from "#dynamic/lib/alerts";
interface TargetHealthStatus {
status: string;
@@ -94,30 +87,17 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
const [targetCheck] = await db
.select({
targetId: targets.targetId,
siteId: targets.siteId,
targetId: targetHealthCheck.targetId,
orgId: targetHealthCheck.orgId,
targetHealthCheckId: targetHealthCheck.targetHealthCheckId,
resourceOrgId: resources.orgId,
resourceId: resources.resourceId,
resourceName: resources.name,
name: targetHealthCheck.name,
hcHealth: targetHealthCheck.hcHealth
})
.from(targetHealthCheck)
.innerJoin(sites, eq(targetHealthCheck.siteId, sites.siteId))
.innerJoin(
targets,
eq(targetHealthCheck.targetId, targets.targetId)
)
.innerJoin(
resources,
eq(targets.resourceId, resources.resourceId)
)
.where(
and(
eq(targetHealthCheck.targetHealthCheckId, targetIdNum),
eq(sites.siteId, newt.siteId)
eq(targetHealthCheck.siteId, newt.siteId)
)
)
.limit(1);
@@ -138,104 +118,42 @@ export const handleHealthcheckStatusMessage: MessageHandler = async (
continue;
}
// Update the target's health status in the database
await db
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetId, targetCheck.targetId));
// Update the target's health status in the database and fire alert in a transaction
await db.transaction(async (trx) => {
await trx
.update(targetHealthCheck)
.set({
hcHealth: healthStatus.status as
| "unknown"
| "healthy"
| "unhealthy"
})
.where(eq(targetHealthCheck.targetHealthCheckId, targetCheck.targetHealthCheckId));
const orgId = targetCheck.orgId || targetCheck.resourceOrgId; // for backwards compatibility, check both orgId fields because the target health checks dont have the orgId
if (!orgId) {
logger.warn(
`No org ID found for target ${targetId}, skipping status history logging`
);
continue;
}
// Log the state change to status history
await db.insert(statusHistory).values({
entityType: "healthCheck",
entityId: targetCheck.targetHealthCheckId,
orgId: orgId,
status: healthStatus.status,
timestamp: Math.floor(Date.now() / 1000)
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
targetCheck.orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined,
targetCheck.targetId,
undefined,
true,
trx
);
} else if (healthStatus.status === "healthy") {
await fireHealthCheckHealthyAlert(
targetCheck.orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined,
targetCheck.targetId,
undefined,
true,
trx
);
}
});
if (targetCheck.resourceId) {
// Log the state change to status history for the resource as well
// so we can show the resource status along with the site
// if the status is healthy we should check if ALL of the targets on the resource are currently healthy and if not then dont mark the resource as healthy yet, we want to wait until all targets are healthy to mark the resource as healthy
let status = healthStatus.status;
if (healthStatus.status === "healthy") {
const otherTargets = await db
.select({ hcHealth: targetHealthCheck.hcHealth })
.from(targets)
.innerJoin(
targetHealthCheck,
eq(targets.targetId, targetHealthCheck.targetId)
)
.where(
and(
eq(targets.resourceId, targetCheck.resourceId),
ne(targets.targetId, targetCheck.targetId) // only check the other targets, not the one we just updated
)
);
const allHealthy = otherTargets.every(
(t) => t.hcHealth === "healthy"
);
if (!allHealthy) {
logger.debug(
`Not marking resource ${targetCheck.resourceId} as healthy because not all targets are healthy`
);
status = "unhealthy";
}
}
await db.insert(statusHistory).values({
entityType: "resource",
entityId: targetCheck.resourceId,
orgId: orgId,
status: status,
timestamp: Math.floor(Date.now() / 1000)
});
if (status === "unhealthy") {
await fireResourceUnhealthyAlert(
orgId,
targetCheck.resourceId,
targetCheck.resourceName
);
} else if (status === "healthy") {
await fireResourceHealthyAlert(
orgId,
targetCheck.resourceId,
targetCheck.resourceName
);
}
}
// because we are checking above if there was a change we can fire the alert here because it changed
if (healthStatus.status === "unhealthy") {
await fireHealthCheckUnhealthyAlert(
orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined
);
} else if (healthStatus.status === "healthy") {
await fireHealthCheckHealthyAlert(
orgId,
targetCheck.targetHealthCheckId,
targetCheck.name ?? undefined
);
}
logger.debug(
`Updated health status for target ${targetId} to ${healthStatus.status}`
);

View File

@@ -10,10 +10,12 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
import { fireHealthCheckHealthyAlert, fireHealthCheckUnknownAlert } from "#dynamic/lib/alerts";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
import { OpenAPITags, registry } from "@server/openApi";
import { vs } from "@react-email/components";
import { fireHealthCheckUnhealthyAlert } from "@server/lib/alerts";
const updateTargetParamsSchema = z.strictObject({
targetId: z.string().transform(Number).pipe(z.int().positive())
@@ -153,32 +155,6 @@ export async function updateTarget(
);
}
const targetData = {
...target,
...parsedBody.data
};
const existingTargets = await db
.select()
.from(targets)
.where(eq(targets.resourceId, target.resourceId));
const foundTarget = existingTargets.find(
(target) =>
target.targetId !== targetId && // Exclude the current target being updated
target.ip === targetData.ip &&
target.port === targetData.port &&
target.method === targetData.method &&
target.siteId === targetData.siteId
);
if (foundTarget) {
// log a warning
logger.warn(
`Target with IP ${targetData.ip}, port ${targetData.port}, method ${targetData.method} already exists for resource ID ${target.resourceId}`
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!, db);
if (!internalPort) {
@@ -210,20 +186,51 @@ export async function updateTarget(
.where(eq(targets.targetId, targetId))
.returning();
const [existingHc] = await db
.select()
.from(targetHealthCheck)
.where(eq(targetHealthCheck.targetId, targetId))
.limit(1);
if (!existingHc) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Health check for target with ID ${targetId} not found`
)
);
}
let hcHeaders = null;
if (parsedBody.data.hcHeaders) {
hcHeaders = JSON.stringify(parsedBody.data.hcHeaders);
}
// When health check is disabled, reset hcHealth to "unknown"
// to prevent previously unhealthy targets from being excluded
// Also when the site is not a newt, set hcHealth to "unknown"
const hcHealthValue =
// to prevent previously unhealthy targets from being excluded.
// Also when the site is not a newt, set hcHealth to "unknown".
// If hcEnabled is being turned on (was false, now true), set to "unhealthy"
// so the target must pass a health check before being considered healthy.
const hcEnabledTurnedOn =
parsedBody.data.hcEnabled === true && existingHc.hcEnabled === false;
let hcHealthValue: "unknown" | "healthy" | "unhealthy" | undefined;
if (
parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null ||
site.type !== "newt"
? "unknown"
: undefined;
) {
hcHealthValue = "unknown";
} else if (hcEnabledTurnedOn) {
hcHealthValue = "unhealthy";
} else {
hcHealthValue = undefined;
}
const isDisablingHc =
(parsedBody.data.hcEnabled === false ||
parsedBody.data.hcEnabled === null) &&
existingHc.hcEnabled === true;
const [updatedHc] = await db
.update(targetHealthCheck)
@@ -245,11 +252,41 @@ export async function updateTarget(
hcTlsServerName: parsedBody.data.hcTlsServerName,
hcHealthyThreshold: parsedBody.data.hcHealthyThreshold,
hcUnhealthyThreshold: parsedBody.data.hcUnhealthyThreshold,
...(hcHealthValue !== undefined && { hcHealth: hcHealthValue })
hcHealth: hcHealthValue
})
.where(eq(targetHealthCheck.targetId, targetId))
.returning();
if (updatedHc.hcHealth === "unhealthy" && existingHc.hcHealth !== "unhealthy") {
await fireHealthCheckUnhealthyAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name || "",
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (updatedHc.hcHealth === "unknown" && existingHc.hcHealth !== "unknown") {
// if the health is unknown, we want to fire an alert to notify users to enable health checks
await fireHealthCheckUnknownAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
} else if (updatedHc.hcHealth === "healthy" && existingHc.hcHealth !== "healthy") {
await fireHealthCheckHealthyAlert(
updatedHc.orgId,
updatedHc.targetHealthCheckId,
updatedHc.name,
undefined,
undefined,
false // dont send the alert because we just want to create the alert, not notify users yet
);
}
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {

View File

@@ -346,6 +346,14 @@ export default async function migration() {
ALTER TABLE "siteResources" DROP COLUMN "protocol";
`);
await db.execute(sql`
ALTER TABLE "resources" ADD "health" varchar;
`);
await db.execute(sql`
ALTER TABLE "resources" ADD "wildcard" boolean DEFAULT false NOT NULL;
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {

View File

@@ -330,6 +330,17 @@ export default async function migration() {
ALTER TABLE 'sites' ADD 'networkId' integer REFERENCES networks(networkId);
`
).run();
db.prepare(
`
ALTER TABLE 'resources' ADD 'health' text;
`
).run();
db.prepare(
`
ALTER TABLE 'resources' ADD 'wildcard' integer DEFAULT false NOT NULL;
`
).run();
})();
db.pragma("foreign_keys = ON");

View File

@@ -836,7 +836,14 @@ export default function BillingPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
{/* Plan Cards Grid */}
<div className={cn("grid grid-cols-1 gap-4", visiblePlanOptions.length === 5 ? "md:grid-cols-5" : "md:grid-cols-4")}>
<div
className={cn(
"grid grid-cols-1 gap-4",
visiblePlanOptions.length === 5
? "md:grid-cols-5"
: "md:grid-cols-4"
)}
>
{visiblePlanOptions.map((plan) => {
const isCurrentPlan = plan.id === currentPlanId;
const planAction = getPlanAction(plan);
@@ -967,7 +974,7 @@ export default function BillingPage() {
{t("billingCurrentUsage") || "Current Usage"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
<span className="text-3xl font-semibold">
{getUserCount()}
</span>
<span className="text-lg">
@@ -1298,7 +1305,7 @@ export default function BillingPage() {
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">

View File

@@ -151,6 +151,7 @@ export default async function AlertingHealthChecksPage(
fullDomain: string | null;
niceId: string;
ssl: boolean;
wildcard: boolean;
} | null = null;
if (resourceIdParam) {
try {
@@ -165,7 +166,8 @@ export default async function AlertingHealthChecksPage(
resourceId: r.resourceId,
fullDomain: r.fullDomain,
niceId: r.niceId,
ssl: r.ssl
ssl: r.ssl,
wildcard: r.wildcard
};
}
} catch {

View File

@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Edit Alert"
};
export default function EditAlertRuleLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Create Alert"
};
export default function CreateAlertRuleLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -5,7 +5,7 @@ export default async function NotFound() {
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h1 className="text-6xl font-semibold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>

View File

@@ -69,6 +69,7 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) {
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
resourceCount: Number(site.resourceCount ?? 0),
orgId: params.orgId,
type: site.type as any,
online: site.online,

View File

@@ -7,7 +7,9 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import type { ListResourcesResponse } from "@server/routers/resource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
@@ -22,6 +24,13 @@ export interface ClientResourcesPageProps {
searchParams: Promise<Record<string, string>>;
}
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ClientResourcesPage(
props: ClientResourcesPageProps
) {
@@ -47,6 +56,32 @@ export default async function ClientResourcesPage(
pagination = responseData.pagination;
} catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null;
try {
const res = await getCachedOrg(params.orgId);
@@ -114,6 +149,7 @@ export default async function ClientResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
/>
</OrgProvider>
</>

View File

@@ -161,6 +161,7 @@ function MaintenanceSectionForm({
</SettingsSectionHeader>
<SettingsSectionBody>
<PaidFeaturesAlert tiers={tierMatrix.maintencePage} />
<SettingsSectionForm>
<Form {...maintenanceForm}>
<form
@@ -168,9 +169,6 @@ function MaintenanceSectionForm({
className="space-y-4"
id="maintenance-settings-form"
>
<PaidFeaturesAlert
tiers={tierMatrix.maintencePage}
/>
<FormField
control={maintenanceForm.control}
name="maintenanceModeEnabled"
@@ -672,6 +670,7 @@ export default function GeneralForm() {
<div className="space-y-4">
<div id="resource-domain-picker">
<DomainPicker
allowWildcard={true}
key={resource.resourceId}
orgId={orgId as string}
cols={2}

View File

@@ -694,19 +694,6 @@ export default function Page() {
header: () => <span className="p-3">{t("healthCheck")}</span>,
cell: ({ row }) => {
const status = row.original.hcHealth || "unknown";
const isEnabled = row.original.hcEnabled;
const getStatusColor = (status: string) => {
switch (status) {
case "healthy":
return "green";
case "unhealthy":
return "red";
case "unknown":
default:
return "secondary";
}
};
const getStatusText = (status: string) => {
switch (status) {
@@ -720,19 +707,7 @@ export default function Page() {
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "healthy":
return <CircleCheck className="w-3 h-3" />;
case "unhealthy":
return <CircleX className="w-3 h-3" />;
case "unknown":
default:
return null;
}
};
return (
return (
<div className="flex items-center justify-center w-full">
{row.original.siteType === "newt" ? (
<Button
@@ -742,12 +717,16 @@ export default function Page() {
openHealthCheckDialog(row.original)
}
>
<Settings className="h-4 w-4" />
<div className="flex items-center gap-1">
{getStatusIcon(status)}
<div
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : "text-neutral-500"}`}
>
<div
className={`w-2 h-2 rounded-full ${status === "healthy" ? "bg-green-500" : status === "unhealthy" ? "bg-destructive" : "bg-neutral-500"}`}
></div>
{getStatusText(status)}
</div>
</Button>
) : (
<span>-</span>
)}
@@ -1132,6 +1111,7 @@ export default function Page() {
<SettingsSectionBody>
<SettingsSectionForm>
<DomainPicker
allowWildcard={true}
orgId={orgId as string}
warnOnProvidedDomain={
remoteExitNodes.length >= 1

View File

@@ -7,7 +7,8 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import OrgProvider from "@app/providers/OrgProvider";
import type { GetOrgResponse } from "@server/routers/org";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
@@ -24,6 +25,13 @@ export interface ProxyResourcesPageProps {
searchParams: Promise<Record<string, string>>;
}
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ProxyResourcesPage(
props: ProxyResourcesPageProps
) {
@@ -47,13 +55,31 @@ export default async function ProxyResourcesPage(
pagination = responseData.pagination;
} catch (e) {}
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(`/org/${params.orgId}/site-resources`, await authCookieHeader());
siteResources = res.data.data.siteResources;
} catch (e) {}
const siteIdParam = parsePositiveInt(searchParams.get("siteId") ?? undefined);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null;
try {
@@ -102,7 +128,9 @@ export default async function ProxyResourcesPage(
enabled: target.enabled,
healthStatus: target.healthStatus,
siteName: target.siteName
}))
})),
sites: resource.sites ?? [],
health: (resource.health as ResourceRow["health"]) ?? undefined
};
});
return (
@@ -123,6 +151,7 @@ export default async function ProxyResourcesPage(
pageIndex: pagination.page - 1,
pageSize: pagination.pageSize
}}
initialFilterSite={initialFilterSite}
/>
</OrgProvider>
</>

View File

@@ -113,7 +113,7 @@ export default function GeneralPage() {
return (
<SettingsContainer>
{site?.siteId && site?.orgId && (
{site?.siteId && site?.orgId && site.type != "local" && (
<UptimeAlertSection
orgId={site.orgId}
siteId={site.siteId}

View File

@@ -42,6 +42,10 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
title: t("general"),
href: `/${params.orgId}/settings/sites/${params.niceId}/general`
},
{
title: t("siteResourcesTab"),
href: `/${params.orgId}/settings/sites/${params.niceId}/resources`
},
...(site.type !== "local"
? [
{

View File

@@ -0,0 +1,64 @@
import SiteResourcesOverview from "@app/components/SiteResourcesOverview";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { ListResourcesResponse } from "@server/routers/resource";
import type { GetSiteResponse } from "@server/routers/site";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type { AxiosResponse } from "axios";
type SiteResourcesPageProps = {
params: Promise<{ orgId: string; niceId: string }>;
};
export default async function SiteResourcesPage(props: SiteResourcesPageProps) {
const { orgId, niceId } = await props.params;
const siteRes = await internal.get<AxiosResponse<GetSiteResponse>>(
`/org/${orgId}/site/${niceId}`,
await authCookieHeader()
);
const site = siteRes.data.data;
const baseSearch = new URLSearchParams({
page: "1",
pageSize: "5",
siteId: String(site.siteId)
});
let initialPublicData: ListResourcesResponse | null = null;
let initialPrivateData: ListAllSiteResourcesByOrgResponse | null = null;
let initialPublicForbidden = false;
let initialPrivateForbidden = false;
try {
const res = await internal.get<AxiosResponse<ListResourcesResponse>>(
`/org/${orgId}/resources?${baseSearch.toString()}`,
await authCookieHeader()
);
initialPublicData = res.data.data;
} catch (e: any) {
initialPublicForbidden = e?.response?.status === 403;
}
try {
const res = await internal.get<
AxiosResponse<ListAllSiteResourcesByOrgResponse>
>(
`/org/${orgId}/site-resources?${baseSearch.toString()}`,
await authCookieHeader()
);
initialPrivateData = res.data.data;
} catch (e: any) {
initialPrivateForbidden = e?.response?.status === 403;
}
return (
<SiteResourcesOverview
siteId={site.siteId}
initialPublicData={initialPublicData}
initialPrivateData={initialPrivateData}
initialPublicForbidden={initialPublicForbidden}
initialPrivateForbidden={initialPrivateForbidden}
/>
);
}

View File

@@ -64,6 +64,7 @@ export default async function SitesPage(props: SitesPageProps) {
address: site.address?.split("/")[0],
mbIn: formatSize(site.megabytesIn || 0, site.type),
mbOut: formatSize(site.megabytesOut || 0, site.type),
resourceCount: Number(site.resourceCount ?? 0),
orgId: params.orgId,
type: site.type as any,
online: site.online,

View File

@@ -0,0 +1,264 @@
"use client";
import { UsersDataTable } from "@app/components/AdminUsersDataTable";
import { Button } from "@app/components/ui/button";
import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import { toast } from "@app/hooks/useToast";
import { formatAxiosError } from "@app/lib/api";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
import {
DropdownMenu,
DropdownMenuItem,
DropdownMenuContent,
DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu";
import { ExtendedColumnDef } from "@app/components/ui/data-table";
export type GlobalUserRow = {
id: string;
name: string | null;
username: string;
email: string | null;
type: string;
idpId: number | null;
idpName: string;
dateCreated: string;
twoFactorEnabled: boolean | null;
twoFactorSetupRequested: boolean | null;
};
type Props = {
users: GlobalUserRow[];
};
export default function UsersTable({ users }: Props) {
const router = useRouter();
const t = useTranslations();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selected, setSelected] = useState<GlobalUserRow | null>(null);
const [rows, setRows] = useState<GlobalUserRow[]>(users);
const api = createApiClient(useEnvContext());
const deleteUser = (id: string) => {
api.delete(`/user/${id}`)
.catch((e) => {
console.error(t("userErrorDelete"), e);
toast({
variant: "destructive",
title: t("userErrorDelete"),
description: formatAxiosError(e, t("userErrorDelete"))
});
})
.then(() => {
router.refresh();
setIsDeleteModalOpen(false);
const newRows = rows.filter((row) => row.id !== id);
setRows(newRows);
});
};
const columns: ExtendedColumnDef<GlobalUserRow>[] = [
{
accessorKey: "id",
friendlyName: "ID",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
ID
</Button>
);
}
},
{
accessorKey: "username",
friendlyName: t("username"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("username")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "email",
friendlyName: t("email"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("email")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "name",
friendlyName: t("name"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("name")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "idpName",
friendlyName: t("identityProvider"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("identityProvider")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
accessorKey: "twoFactorEnabled",
friendlyName: t("twoFactor"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("twoFactor")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const userRow = row.original;
return (
<div className="flex flex-row items-center gap-2">
<span>
{userRow.twoFactorEnabled ||
userRow.twoFactorSetupRequested ? (
<span className="text-green-500">
{t("enabled")}
</span>
) : (
<span>{t("disabled")}</span>
)}
</span>
</div>
);
}
},
{
id: "actions",
header: () => <span className="p-3">{t("actions")}</span>,
cell: ({ row }) => {
const r = row.original;
return (
<>
<div className="flex items-center gap-2">
<Button
variant={"outline"}
onClick={() => {
router.push(`/admin/users/${r.id}`);
}}
>
{t("edit")}
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="h-8 w-8 p-0"
>
<span className="sr-only">
Open menu
</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
setSelected(r);
setIsDeleteModalOpen(true);
}}
>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</>
);
}
}
];
return (
<>
{selected && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelected(null);
}}
dialog={
<div className="space-y-2">
<p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p>
</div>
}
buttonText={t("userDeleteConfirm")}
onConfirm={async () => deleteUser(selected!.id)}
string={
selected.email || selected.name || selected.username
}
title={t("userDeleteServer")}
/>
)}
<UsersDataTable columns={columns} data={rows} />
</>
);
}

View File

@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Set Up 2FA"
};
export default function TwoFactorSetupLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -5,6 +5,11 @@ import { cache } from "react";
import DeleteAccountClient from "./DeleteAccountClient";
import { getTranslations } from "next-intl/server";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Delete Account"
};
export const dynamic = "force-dynamic";

View File

@@ -8,6 +8,11 @@ import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv";
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Complete Login"
};
export const dynamic = "force-dynamic";

View File

@@ -3,6 +3,11 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { InitialSetupCompleteResponse } from "@server/routers/auth";
import { AxiosResponse } from "axios";
import { redirect } from "next/navigation";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Initial Setup"
};
export default async function Layout(props: { children: React.ReactNode }) {
const setupRes = await internal.get<

View File

@@ -92,7 +92,7 @@ export default function InitialSetupPage() {
/>
</div>
<div className="text-center space-y-1">
<h1 className="text-2xl font-bold mt-1">
<h1 className="text-2xl font-semibold mt-1">
{t("initialSetupTitle")}
</h1>
<CardDescription>

View File

@@ -10,7 +10,10 @@ import { getTranslations } from "next-intl/server";
import { cache } from "react";
export const metadata: Metadata = {
title: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
title: {
template: `%s - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
default: `Auth - ${process.env.BRANDING_APP_NAME || "Pangolin"}`
},
description: ""
};

View File

@@ -4,6 +4,11 @@ import DeviceLoginForm from "@/components/DeviceLoginForm";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { cache } from "react";
import { cleanRedirect } from "@app/lib/cleanRedirect";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Authorize Device"
};
export const dynamic = "force-dynamic";

View File

@@ -0,0 +1,13 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Device Authorized"
};
export default function DeviceAuthSuccessLayout({
children
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View File

@@ -23,8 +23,10 @@ export default function DeviceAuthSuccessPage() {
useEffect(() => {
// Detect if we're on iOS or Android
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera;
const isIOS =
/iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
const isAndroid = /android/i.test(userAgent);
if (isAndroid) {
@@ -32,7 +34,8 @@ export default function DeviceAuthSuccessPage() {
// This explicitly tells Chrome to send an intent to the app, which will bring
// SignInCodeActivity back to the foreground (it has launchMode="singleTop")
setTimeout(() => {
window.location.href = "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end";
window.location.href =
"intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end";
}, 500);
} else if (isIOS) {
// Wait 500ms then attempt to open the app
@@ -41,7 +44,8 @@ export default function DeviceAuthSuccessPage() {
window.location.href = "pangolin://";
setTimeout(() => {
window.location.href = "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS";
window.location.href =
"https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS";
}, 2000);
}, 500);
}
@@ -64,7 +68,7 @@ export default function DeviceAuthSuccessPage() {
<div className="flex flex-col items-center space-y-4">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<div className="space-y-2">
<h3 className="text-xl font-bold text-center">
<h3 className="text-xl font-semibold text-center">
{t("deviceConnected")}
</h3>
<p className="text-center text-sm text-muted-foreground">

View File

@@ -17,6 +17,11 @@ import { priv } from "@app/lib/api";
import { AxiosResponse } from "axios";
import { LoginFormIDP } from "@app/components/LoginForm";
import { ListIdpsResponse } from "@server/routers/idp";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Log In"
};
export const dynamic = "force-dynamic";
@@ -130,7 +135,7 @@ export default async function Page(props: {
<div className="border rounded-md p-3 mb-4 bg-card">
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
<h2 className="text-2xl font-semibold mb-2 text-center">
{t("inviteAlready")}
</h2>
<p className="text-center">

View File

@@ -12,6 +12,11 @@ import {
import { redirect } from "next/navigation";
import OrgLoginPage from "@app/components/OrgLoginPage";
import { pullEnv } from "@app/lib/pullEnv";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Organization Login"
};
export const dynamic = "force-dynamic";

View File

@@ -18,6 +18,11 @@ import ValidateSessionTransferToken from "@app/components/ValidateSessionTransfe
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { OrgSelectionForm } from "@app/components/OrgSelectionForm";
import OrgLoginPage from "@app/components/OrgLoginPage";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Choose Organization"
};
export const dynamic = "force-dynamic";

View File

@@ -7,6 +7,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import { getTranslations } from "next-intl/server";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Reset Password"
};
export const dynamic = "force-dynamic";

View File

@@ -27,6 +27,11 @@ import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Resource Access"
};
export const dynamic = "force-dynamic";
@@ -101,10 +106,22 @@ export default async function ResourceAuthPage(props: {
const redirectPort = new URL(searchParams.redirect).port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
if (!wildcardDomain.startsWith("*.")) return false;
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
return host.endsWith(suffix) && host.length > suffix.length;
};
if (serverResourceHost === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (serverResourceHostWithPort === redirectHost) {
redirectUrl = searchParams.redirect;
} else if (
authInfo.wildcard &&
authInfo.fullDomain &&
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
) {
redirectUrl = searchParams.redirect;
}
} catch (e) {}
}

View File

@@ -7,6 +7,11 @@ import Link from "next/link";
import { redirect } from "next/navigation";
import { cache } from "react";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Create Account"
};
export const dynamic = "force-dynamic";
@@ -60,7 +65,7 @@ export default async function Page(props: {
<div className="border rounded-md p-3 mb-4 bg-card">
<div className="flex flex-col items-center">
<Mail className="w-12 h-12 mb-4 text-primary" />
<h2 className="text-2xl font-bold mb-2 text-center">
<h2 className="text-2xl font-semibold mb-2 text-center">
{t("inviteAlready")}
</h2>
<p className="text-center">

View File

@@ -4,6 +4,11 @@ import { cleanRedirect } from "@app/lib/cleanRedirect";
import { pullEnv } from "@app/lib/pullEnv";
import { redirect } from "next/navigation";
import { cache } from "react";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Verify Email"
};
export const dynamic = "force-dynamic";

View File

@@ -41,11 +41,11 @@
}
.dark {
--background: #0d0d0f;
--background: #141415;
--foreground: oklch(0.985 0 0);
--card: #0d0d0f;
--card: #141415;
--card-foreground: oklch(0.985 0 0);
--popover: #0d0d0f;
--popover: #141415;
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.6717 0.1946 41.93);
--primary-foreground: oklch(0.98 0.016 73.684);
@@ -65,11 +65,11 @@
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: #040404;
--sidebar: #0C0C0D;
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.646 0.222 41.116);
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
--sidebar-accent: #131317;
--sidebar-accent: #171717;
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.646 0.222 41.116);

View File

@@ -5,7 +5,7 @@ export default async function NotFound() {
return (
<div className="w-full max-w-md mx-auto p-3 md:mt-32 text-center">
<h1 className="text-6xl font-bold mb-4">404</h1>
<h1 className="text-6xl font-semibold mb-4">404</h1>
<h2 className="text-2xl font-semibold text-neutral-500 mb-4">
{t("pageNotFound")}
</h2>

Some files were not shown because too many files have changed in this diff Show More