Compare commits

...

13 Commits

Author SHA1 Message Date
Owen
f7cede4713 Use /etc/sysctl.d/99-podman.conf
Fixes #2253
2026-01-17 12:22:43 -08:00
Owen
610b20c1ff Use the right driver
Fixes #2254
2026-01-17 12:21:47 -08:00
miloschwartz
fb19e10cdc Merge branch 'dev' into bubble-errors-up 2026-01-17 12:00:55 -08:00
miloschwartz
2f1756ccf2 add more error messages for org access policy 2026-01-17 12:00:27 -08:00
Owen
ce632a25cf Consolidate the messages into the same enum 2026-01-17 11:41:10 -08:00
Owen
888f5f8bb6 Dont terminate on archive 2026-01-16 17:06:16 -08:00
Owen
9114dd5992 Send terminate error messages 2026-01-16 14:57:54 -08:00
Owen
a126494c12 Add pending 2026-01-16 14:37:06 -08:00
Milo Schwartz
79ba804c88 Merge pull request #2252 from Fredkiss3/fix/request-analytics-loading-state
Fix: better loading state for analytics
2026-01-16 14:35:55 -08:00
Owen
e2cbe11a5f Send error codes down to olm 2026-01-16 14:19:36 -08:00
Fred KISSIE
f4496bb23a ♻️ show all country list 2026-01-16 17:36:48 +01:00
Fred KISSIE
c93766bb48 💄fix countries list grid items 2026-01-16 17:35:17 +01:00
Fred KISSIE
1065004fa3 🚸 show a better loading state for analytics 2026-01-16 02:07:08 +01:00
19 changed files with 307 additions and 51 deletions

View File

@@ -77,6 +77,8 @@ COPY ./cli/wrapper.sh /usr/local/bin/pangctl
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs
COPY server/db/names.json ./dist/names.json COPY server/db/names.json ./dist/names.json
COPY server/db/ios_models.json ./dist/ios_models.json
COPY server/db/mac_models.json ./dist/mac_models.json
COPY public ./public COPY public ./public
# OCI Image Labels # OCI Image Labels

View File

@@ -210,6 +210,47 @@ func isDockerRunning() bool {
return true return true
} }
func isPodmanRunning() bool {
cmd := exec.Command("podman", "info")
if err := cmd.Run(); err != nil {
return false
}
return true
}
// detectContainerType detects whether the system is currently using Docker or Podman
// by checking which container runtime is running and has containers
func detectContainerType() SupportedContainer {
// Check if we have running containers with podman
if isPodmanRunning() {
cmd := exec.Command("podman", "ps", "-q")
output, err := cmd.Output()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
return Podman
}
}
// Check if we have running containers with docker
if isDockerRunning() {
cmd := exec.Command("docker", "ps", "-q")
output, err := cmd.Output()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
return Docker
}
}
// If no containers are running, check which one is installed and running
if isPodmanRunning() && isPodmanInstalled() {
return Podman
}
if isDockerRunning() && isDockerInstalled() {
return Docker
}
return Undefined
}
// executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied // executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied
func executeDockerComposeCommandWithArgs(args ...string) error { func executeDockerComposeCommandWithArgs(args ...string) error {
var cmd *exec.Cmd var cmd *exec.Cmd

View File

@@ -93,7 +93,7 @@ func installCrowdsec(config Config) error {
if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") { if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") {
fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:") fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:")
fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer") fmt.Printf(" %s exec crowdsec cscli bouncers add traefik-bouncer\n", config.InstallationContainerType)
} }
return nil return nil
@@ -117,7 +117,7 @@ func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) {
} }
// Execute the command to get the API key // Execute the command to get the API key
cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") cmd := exec.Command(string(containerType), "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw")
var out bytes.Buffer var out bytes.Buffer
cmd.Stdout = &out cmd.Stdout = &out

View File

@@ -229,7 +229,16 @@ func main() {
} }
} }
config.InstallationContainerType = podmanOrDocker(reader) // Try to detect container type from existing installation
detectedType := detectContainerType()
if detectedType == Undefined {
// If detection fails, prompt the user
fmt.Println("Unable to detect container type from existing installation.")
config.InstallationContainerType = podmanOrDocker(reader)
} else {
config.InstallationContainerType = detectedType
fmt.Printf("Detected container type: %s\n", config.InstallationContainerType)
}
config.DoCrowdsecInstall = true config.DoCrowdsecInstall = true
err := installCrowdsec(config) err := installCrowdsec(config)
@@ -286,10 +295,10 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
os.Exit(1) os.Exit(1)
} }
if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil {
fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.")
fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.")
approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true)
if approved { if approved {
if os.Geteuid() != 0 { if os.Geteuid() != 0 {
fmt.Println("You need to run the installer as root for such a configuration.") fmt.Println("You need to run the installer as root for such a configuration.")
@@ -300,7 +309,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer {
// container low-range ports as unprivileged ports. // container low-range ports as unprivileged ports.
// Linux only. // Linux only.
if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil {
fmt.Printf("Error configuring unprivileged ports: %v\n", err) fmt.Printf("Error configuring unprivileged ports: %v\n", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -1308,6 +1308,7 @@
"setupErrorCreateAdmin": "An error occurred while creating the server admin account.", "setupErrorCreateAdmin": "An error occurred while creating the server admin account.",
"certificateStatus": "Certificate Status", "certificateStatus": "Certificate Status",
"loading": "Loading", "loading": "Loading",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart", "restart": "Restart",
"domains": "Domains", "domains": "Domains",
"domainsDescription": "Create and manage domains available in the organization", "domainsDescription": "Create and manage domains available in the organization",

View File

@@ -19,6 +19,7 @@ import logger from "@server/logger";
import { sendTerminateClient } from "@server/routers/client/terminate"; import { sendTerminateClient } from "@server/routers/client/terminate";
import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm";
import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations";
import { OlmErrorCodes } from "@server/routers/olm/error";
export async function calculateUserClientsForOrgs( export async function calculateUserClientsForOrgs(
userId: string, userId: string,
@@ -305,6 +306,7 @@ async function cleanupOrphanedClients(
if (deletedClient.olmId) { if (deletedClient.olmId) {
await sendTerminateClient( await sendTerminateClient(
deletedClient.clientId, deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
deletedClient.olmId deletedClient.olmId
); );
} }

View File

@@ -24,6 +24,8 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { disconnectClient, sendToClient } from "#private/routers/ws"; import { disconnectClient, sendToClient } from "#private/routers/ws";
import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
const reGenerateSecretParamsSchema = z.strictObject({ const reGenerateSecretParamsSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -117,12 +119,12 @@ export async function reGenerateClientSecret(
// Only disconnect if explicitly requested // Only disconnect if explicitly requested
if (disconnect) { if (disconnect) {
const payload = {
type: `olm/terminate`,
data: {}
};
// Don't await this to prevent blocking the response // Don't await this to prevent blocking the response
sendToClient(existingOlms[0].olmId, payload).catch((error) => { sendTerminateClient(
clientId,
OlmErrorCodes.TERMINATED_REKEYED,
existingOlms[0].olmId
).catch((error) => {
logger.error( logger.error(
"Failed to send termination message to olm:", "Failed to send termination message to olm:",
error error

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const archiveClientSchema = z.strictObject({ const archiveClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -79,11 +80,6 @@ export async function archiveClient(
// Rebuild associations to clean up related data // Rebuild associations to clean up related data
await rebuildClientAssociationsFromClient(client, trx); await rebuildClientAssociationsFromClient(client, trx);
// Send terminate signal if there's an associated OLM
if (client.olmId) {
await sendTerminateClient(client.clientId, client.olmId);
}
}); });
return response(res, { return response(res, {

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const blockClientSchema = z.strictObject({ const blockClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -78,7 +79,7 @@ export async function blockClient(
// Send terminate signal if there's an associated OLM and it's connected // Send terminate signal if there's an associated OLM and it's connected
if (client.olmId && client.online) { if (client.olmId && client.online) {
await sendTerminateClient(client.clientId, client.olmId); await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId);
} }
}); });

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "./terminate"; import { sendTerminateClient } from "./terminate";
import { OlmErrorCodes } from "../olm/error";
const deleteClientSchema = z.strictObject({ const deleteClientSchema = z.strictObject({
clientId: z.string().transform(Number).pipe(z.int().positive()) clientId: z.string().transform(Number).pipe(z.int().positive())
@@ -91,7 +92,7 @@ export async function deleteClient(
await rebuildClientAssociationsFromClient(deletedClient, trx); await rebuildClientAssociationsFromClient(deletedClient, trx);
if (olm) { if (olm) {
await sendTerminateClient(deletedClient.clientId, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion
} }
}); });

View File

@@ -1,9 +1,11 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { db, olms } from "@server/db"; import { db, olms } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { OlmErrorCodes } from "../olm/error";
export async function sendTerminateClient( export async function sendTerminateClient(
clientId: number, clientId: number,
error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes],
olmId?: string | null olmId?: string | null
) { ) {
if (!olmId) { if (!olmId) {
@@ -20,6 +22,9 @@ export async function sendTerminateClient(
await sendToClient(olmId, { await sendToClient(olmId, {
type: `olm/terminate`, type: `olm/terminate`,
data: {} data: {
code: error.code,
message: error.message
}
}); });
} }

View File

@@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate"; import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -52,7 +53,7 @@ export async function archiveUserOlm(
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));
await rebuildClientAssociationsFromClient(client, trx); await rebuildClientAssociationsFromClient(client, trx);
await sendTerminateClient(client.clientId, olmId); await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_ARCHIVED, olmId);
} }
// Archive the OLM (set archived to true) // Archive the OLM (set archived to true)

View File

@@ -11,6 +11,7 @@ import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { sendTerminateClient } from "../client/terminate"; import { sendTerminateClient } from "../client/terminate";
import { OlmErrorCodes } from "./error";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -76,6 +77,7 @@ export async function deleteUserOlm(
if (olm) { if (olm) {
await sendTerminateClient( await sendTerminateClient(
deletedClient.clientId, deletedClient.clientId,
OlmErrorCodes.TERMINATED_DELETED,
olm.olmId olm.olmId
); // the olmId needs to be provided because it cant look it up after deletion ); // the olmId needs to be provided because it cant look it up after deletion
} }

104
server/routers/olm/error.ts Normal file
View File

@@ -0,0 +1,104 @@
import { sendToClient } from "#dynamic/routers/ws";
// Error codes for registration failures
export const OlmErrorCodes = {
OLM_NOT_FOUND: {
code: "OLM_NOT_FOUND",
message: "The specified device could not be found."
},
CLIENT_ID_NOT_FOUND: {
code: "CLIENT_ID_NOT_FOUND",
message: "No client ID was provided in the request."
},
CLIENT_NOT_FOUND: {
code: "CLIENT_NOT_FOUND",
message: "The specified client does not exist."
},
CLIENT_BLOCKED: {
code: "CLIENT_BLOCKED",
message:
"This client has been blocked in this organization and cannot connect. Please contact your administrator."
},
CLIENT_PENDING: {
code: "CLIENT_PENDING",
message:
"This client is pending approval and cannot connect yet. Please contact your administrator."
},
ORG_NOT_FOUND: {
code: "ORG_NOT_FOUND",
message:
"The organization could not be found. Please select a valid organization."
},
USER_ID_NOT_FOUND: {
code: "USER_ID_NOT_FOUND",
message: "No user ID was provided in the request."
},
INVALID_USER_SESSION: {
code: "INVALID_USER_SESSION",
message:
"Your user session is invalid or has expired. Please log in again."
},
USER_ID_MISMATCH: {
code: "USER_ID_MISMATCH",
message: "The provided user ID does not match the session."
},
ORG_ACCESS_POLICY_DENIED: {
code: "ORG_ACCESS_POLICY_DENIED",
message:
"Access to this organization has been denied by policy. Please contact your administrator."
},
ORG_ACCESS_POLICY_PASSWORD_EXPIRED: {
code: "ORG_ACCESS_POLICY_PASSWORD_EXPIRED",
message:
"Access to this organization has been denied because your password has expired. Please visit this organization's dashboard to update your password."
},
ORG_ACCESS_POLICY_SESSION_EXPIRED: {
code: "ORG_ACCESS_POLICY_SESSION_EXPIRED",
message:
"Access to this organization has been denied because your session has expired. Please log in again to refresh the session."
},
ORG_ACCESS_POLICY_2FA_REQUIRED: {
code: "ORG_ACCESS_POLICY_2FA_REQUIRED",
message:
"Access to this organization requires two-factor authentication. Please visit this organization's dashboard to enable two-factor authentication."
},
TERMINATED_REKEYED: {
code: "TERMINATED_REKEYED",
message:
"This session was terminated because encryption keys were regenerated."
},
TERMINATED_ORG_DELETED: {
code: "TERMINATED_ORG_DELETED",
message:
"This session was terminated because the organization was deleted."
},
TERMINATED_INACTIVITY: {
code: "TERMINATED_INACTIVITY",
message: "This session was terminated due to inactivity."
},
TERMINATED_DELETED: {
code: "TERMINATED_DELETED",
message: "This session was terminated because it was deleted."
},
TERMINATED_ARCHIVED: {
code: "TERMINATED_ARCHIVED",
message: "This session was terminated because it was archived."
},
TERMINATED_BLOCKED: {
code: "TERMINATED_BLOCKED",
message: "This session was terminated because access was blocked."
}
} as const;
// Helper function to send registration error
export async function sendOlmError(
error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes],
olmId: string
) {
sendToClient(olmId, {
type: "olm/error",
data: {
code: error.code,
message: error.message
}
});
}

View File

@@ -8,8 +8,8 @@ import response from "@server/lib/response";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { getUserDeviceName } from "@server/db/names"; import { getUserDeviceName } from "@server/db/names";
// import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z const paramsSchema = z
.object({ .object({
@@ -101,7 +101,7 @@ export async function getUserOlm(
const model = result.fingerprints?.deviceModel || null; const model = result.fingerprints?.deviceModel || null;
const newName = getUserDeviceName(model, olm.name); const newName = getUserDeviceName(model, olm.name);
const responseData = blocked !== undefined const responseData = blocked !== undefined
? { ...olm, name: newName, blocked } ? { ...olm, name: newName, blocked }
: { ...olm, name: newName }; : { ...olm, name: newName };

View File

@@ -10,6 +10,7 @@ import { sendTerminateClient } from "../client/terminate";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { sendOlmSyncMessage } from "./sync"; import { sendOlmSyncMessage } from "./sync";
import { OlmErrorCodes } from "./error";
// Track if the offline checker interval is running // Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null; let offlineCheckerInterval: NodeJS.Timeout | null = null;
@@ -64,6 +65,7 @@ export const startOlmOfflineChecker = (): void => {
try { try {
await sendTerminateClient( await sendTerminateClient(
offlineClient.clientId, offlineClient.clientId,
OlmErrorCodes.TERMINATED_INACTIVITY,
offlineClient.olmId offlineClient.olmId
); // terminate first ); // terminate first
// wait a moment to ensure the message is sent // wait a moment to ensure the message is sent

View File

@@ -1,32 +1,20 @@
import { import { clientPostureSnapshots, db, fingerprints, orgs } from "@server/db";
Client,
clientPostureSnapshots,
clientSiteResourcesAssociationsCache,
db,
fingerprints,
orgs,
siteResources
} from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
clientSitesAssociationsCache, clientSitesAssociationsCache,
exitNodes,
Olm, Olm,
olms, olms,
sites sites
} from "@server/db"; } from "@server/db";
import { and, count, eq, inArray, isNull } from "drizzle-orm"; import { count, eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import { generateAliasConfig } from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app"; import { validateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error";
export const handleOlmRegisterMessage: MessageHandler = async (context) => { export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!"); logger.info("Handling register olm message!");
@@ -53,6 +41,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("Olm client ID not found"); logger.warn("Olm client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId);
return; return;
} }
@@ -64,11 +53,23 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!client) { if (!client) {
logger.warn("Client ID not found"); logger.warn("Client ID not found");
sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId);
return; return;
} }
if (client.blocked) { if (client.blocked) {
logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); logger.debug(
`Client ${client.clientId} is blocked. Ignoring register.`
);
sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId);
return;
}
if (client.approvalState == "pending") {
logger.debug(
`Client ${client.clientId} approval is pending. Ignoring register.`
);
sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId);
return; return;
} }
@@ -80,12 +81,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
if (!org) { if (!org) {
logger.warn("Org not found"); logger.warn("Org not found");
sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId);
return; return;
} }
if (orgId) { if (orgId) {
if (!olm.userId) { if (!olm.userId) {
logger.warn("Olm has no user ID"); logger.warn("Olm has no user ID");
sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId);
return; return;
} }
@@ -93,10 +96,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await validateSessionToken(userToken); await validateSessionToken(userToken);
if (!userSession || !user) { if (!userSession || !user) {
logger.warn("Invalid user session for olm register"); logger.warn("Invalid user session for olm register");
return; // by returning here we just ignore the ping and the setInterval will force it to disconnect sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId);
return;
} }
if (user.userId !== olm.userId) { if (user.userId !== olm.userId) {
logger.warn("User ID mismatch for olm register"); logger.warn("User ID mismatch for olm register");
sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId);
return; return;
} }
@@ -110,10 +115,46 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
sessionId // this is the user token passed in the message sessionId // this is the user token passed in the message
}); });
if (!policyCheck.allowed) { if (policyCheck?.error) {
logger.error(
`Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}`
);
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return;
}
if (policyCheck?.policies?.passwordAge?.compliant) {
logger.warn(
`Olm user ${olm.userId} has non-compliant password age for org ${orgId}`
);
sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED,
olm.olmId
);
return;
} else if (policyCheck?.policies?.maxSessionLength?.compliant) {
logger.warn(
`Olm user ${olm.userId} has non-compliant session length for org ${orgId}`
);
sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED,
olm.olmId
);
return;
} else if (policyCheck?.policies?.requiredTwoFactor) {
logger.warn(
`Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}`
);
sendOlmError(
OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED,
olm.olmId
);
return;
} else if (!policyCheck.allowed) {
logger.warn( logger.warn(
`Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}`
); );
sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId);
return; return;
} }
} }
@@ -151,7 +192,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
.update(clients) .update(clients)
.set({ .set({
pubKey: publicKey, pubKey: publicKey,
archived: false, archived: false
}) })
.where(eq(clients.clientId, client.clientId)); .where(eq(clients.clientId, client.clientId));

View File

@@ -21,6 +21,8 @@ import { fromError } from "zod-validation-error";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { deletePeer } from "../gerbil/peers"; import { deletePeer } from "../gerbil/peers";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { OlmErrorCodes } from "../olm/error";
import { sendTerminateClient } from "../client/terminate";
const deleteOrgSchema = z.strictObject({ const deleteOrgSchema = z.strictObject({
orgId: z.string() orgId: z.string()
@@ -206,10 +208,11 @@ export async function deleteOrg(
} }
for (const olmId of olmsToTerminate) { for (const olmId of olmsToTerminate) {
sendToClient(olmId, { sendTerminateClient(
type: "olm/terminate", 0, // clientId not needed since we're passing olmId
data: {} OlmErrorCodes.TERMINATED_REKEYED,
}).catch((error) => { olmId
).catch((error) => {
logger.error( logger.error(
"Failed to send termination message to olm:", "Failed to send termination message to olm:",
error error

View File

@@ -48,6 +48,7 @@ import {
TooltipTrigger TooltipTrigger
} from "./ui/tooltip"; } from "./ui/tooltip";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import type { QueryRequestAnalyticsResponse } from "@server/routers/auditLogs";
export type AnalyticsContentProps = { export type AnalyticsContentProps = {
orgId: string; orgId: string;
@@ -276,13 +277,32 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
</CardHeader> </CardHeader>
</Card> </Card>
<Card className="w-full h-full flex flex-col gap-8"> <Card className="w-full h-full flex flex-col gap-8 relative">
{isLoadingAnalytics && (
<div className="absolute z-20 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 border border-border rounded-md bg-muted">
<div className="flex items-center gap-2 p-6">
<LoaderIcon className="size-4 animate-spin" />
{t("loadingAnalytics")}
</div>
</div>
)}
<CardHeader> <CardHeader>
<h3 className="font-semibold">{t("requestsByDay")}</h3> <h3 className="font-semibold">{t("requestsByDay")}</h3>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="relative">
{isLoadingAnalytics && (
<div className="backdrop-blur-[2px] z-10 absolute inset-0"></div>
)}
<RequestChart <RequestChart
data={stats?.requestsPerDay ?? []} className={cn(
isLoadingAnalytics &&
"opacity-50 pointer-events-none"
)}
data={
stats?.requestsPerDay ??
generateSampleDailyRequests()
}
isLoading={isLoadingAnalytics} isLoading={isLoadingAnalytics}
/> />
</CardContent> </CardContent>
@@ -323,6 +343,28 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
); );
} }
function generateSampleDailyRequests(): QueryRequestAnalyticsResponse["requestsPerDay"] {
const today = new Date();
// generate sample data for the last 7 days
const requestsPerDay = Array.from({ length: 7 }, (_, i) => {
const date = new Date(today);
date.setDate(date.getDate() - (6 - i));
// generate a random number of requests between 1 and 100
const totalCount = Math.floor(Math.random() * 100) + 1;
// generate a random number of requests between 1 and totalCount
const blockedCount = Math.floor(Math.random() * (totalCount + 1));
return {
day: date.toISOString().split("T")[0],
allowedCount: totalCount - blockedCount,
blockedCount,
totalCount
};
});
return requestsPerDay;
}
type RequestChartProps = { type RequestChartProps = {
data: { data: {
day: string; day: string;
@@ -331,6 +373,7 @@ type RequestChartProps = {
totalCount: number; totalCount: number;
}[]; }[];
isLoading: boolean; isLoading: boolean;
className?: string;
}; };
function RequestChart(props: RequestChartProps) { function RequestChart(props: RequestChartProps) {
@@ -359,7 +402,7 @@ function RequestChart(props: RequestChartProps) {
return ( return (
<ChartContainer <ChartContainer
config={chartConfig} config={chartConfig}
className="min-h-[200px] w-full h-80" className={cn("min-h-50 w-full h-80", props.className)}
> >
<LineChart accessibilityLayer data={props.data}> <LineChart accessibilityLayer data={props.data}>
<ChartLegend content={<ChartLegendContent />} /> <ChartLegend content={<ChartLegendContent />} />
@@ -467,7 +510,7 @@ function TopCountriesList(props: TopCountriesListProps) {
</div> </div>
)} )}
{/* `aspect-475/335` is the same aspect ratio as the world map component */} {/* `aspect-475/335` is the same aspect ratio as the world map component */}
<ol className="w-full overflow-auto grid gap-1 aspect-475/335"> <ol className="w-full overflow-auto gap-1 aspect-475/335 flex flex-col">
{props.countries.length === 0 && ( {props.countries.length === 0 && (
<div className="flex items-center justify-center size-full text-muted-foreground gap-1"> <div className="flex items-center justify-center size-full text-muted-foreground gap-1">
{props.isLoading ? ( {props.isLoading ? (
@@ -485,7 +528,7 @@ function TopCountriesList(props: TopCountriesListProps) {
return ( return (
<li <li
key={country.code} key={country.code}
className="grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm" className="w-full grid grid-cols-7 rounded-xs hover:bg-muted relative items-center text-sm"
> >
<div <div
className={cn( className={cn(