mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-27 16:25:40 +00:00
Compare commits
75 Commits
dependabot
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17631599a2 | ||
|
|
7563b37cd0 | ||
|
|
7318c86cca | ||
|
|
467cd70b72 | ||
|
|
8ca72a39da | ||
|
|
4ff811c5bd | ||
|
|
ca2370e31d | ||
|
|
06af53c4d6 | ||
|
|
6befdfe01e | ||
|
|
5695137280 | ||
|
|
e2e0936f43 | ||
|
|
32d8bde96d | ||
|
|
f24f867684 | ||
|
|
491636851f | ||
|
|
bf1870608b | ||
|
|
6f6c24b6df | ||
|
|
7c7d1f641e | ||
|
|
82212af643 | ||
|
|
8e16ff07a9 | ||
|
|
56816c7584 | ||
|
|
477712b73c | ||
|
|
ecacb26445 | ||
|
|
cca7cea2f1 | ||
|
|
07154d2a16 | ||
|
|
b509c8aeec | ||
|
|
a2c76cbb24 | ||
|
|
960ada4d66 | ||
|
|
34296e5f40 | ||
|
|
33f1662c91 | ||
|
|
29f26021df | ||
|
|
15f02cf79a | ||
|
|
2a5d836747 | ||
|
|
593a7fdd69 | ||
|
|
99f9b68efe | ||
|
|
9655f119a5 | ||
|
|
48ddc700a0 | ||
|
|
0473d5f639 | ||
|
|
537f9ae66b | ||
|
|
d08f276794 | ||
|
|
6a96f743aa | ||
|
|
b4f0b4e285 | ||
|
|
07c7501669 | ||
|
|
009bac64bf | ||
|
|
5e293e8364 | ||
|
|
1ba7fca798 | ||
|
|
e7a9a19816 | ||
|
|
fa117198a0 | ||
|
|
f03d0cd47f | ||
|
|
925a59c080 | ||
|
|
a7c7319407 | ||
|
|
230f77118a | ||
|
|
bcb5b7b4a7 | ||
|
|
90a2ed2f10 | ||
|
|
fc69364feb | ||
|
|
245755a140 | ||
|
|
dcbd22b4ad | ||
|
|
8481b0a073 | ||
|
|
f651ca84fa | ||
|
|
6b83d3c3f1 | ||
|
|
d463a578c2 | ||
|
|
9d0a8ecb09 | ||
|
|
af5394d464 | ||
|
|
c956e0d401 | ||
|
|
2a281ec002 | ||
|
|
4c000c1d49 | ||
|
|
ea4ff75552 | ||
|
|
c78b866087 | ||
|
|
48b6e98bbc | ||
|
|
3d5260b13e | ||
|
|
d0b0d95b9a | ||
|
|
c2c8b7a631 | ||
|
|
9bc11b8717 | ||
|
|
1d53211fe0 | ||
|
|
473bce856d | ||
|
|
2c8b7b5ca5 |
5
.cursor/rules/Localization.mdc
Normal file
5
.cursor/rules/Localization.mdc
Normal 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.
|
||||
7
.cursor/rules/Nomenclature.mdc
Normal file
7
.cursor/rules/Nomenclature.mdc
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
description:
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
Proxy resources = public resources
|
||||
Private resources = client resources = site resources
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -484,6 +484,7 @@ export const alertRules = pgTable("alertRules", {
|
||||
| "health_check_toggle"
|
||||
| "resource_healthy"
|
||||
| "resource_unhealthy"
|
||||
| "resource_degraded"
|
||||
| "resource_toggle"
|
||||
>()
|
||||
.notNull(),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
});
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
63
server/private/lib/alerts/types.ts
Normal file
63
server/private/lib/alerts/types.ts
Normal 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>;
|
||||
}
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -76,6 +76,7 @@ const SITE_ALERT_EVENT_TYPES = [
|
||||
const RESOURCE_ALERT_EVENT_TYPES = [
|
||||
"resource_healthy",
|
||||
"resource_unhealthy",
|
||||
"resource_degraded",
|
||||
"resource_toggle"
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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!, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
|
||||
@@ -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!, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
13
src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx
Normal file
13
src/app/[orgId]/settings/alerting/[ruleId]/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
13
src/app/[orgId]/settings/alerting/create/layout.tsx
Normal file
13
src/app/[orgId]/settings/alerting/create/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
? [
|
||||
{
|
||||
|
||||
64
src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx
Normal file
64
src/app/[orgId]/settings/sites/[niceId]/resources/page.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
264
src/app/admin/users/AdminUsersTable.tsx
Normal file
264
src/app/admin/users/AdminUsersTable.tsx
Normal 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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
src/app/auth/2fa/setup/layout.tsx
Normal file
13
src/app/auth/2fa/setup/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: ""
|
||||
};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
13
src/app/auth/login/device/success/layout.tsx
Normal file
13
src/app/auth/login/device/success/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user