mirror of
https://github.com/fosrl/pangolin.git
synced 2026-02-10 20:02:26 +00:00
Seperate managed node code to fosrl/pangolin-node
This commit is contained in:
@@ -1,70 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { tokenManager } from "./tokenManager";
|
||||
import logger from "@server/logger";
|
||||
import config from "./config";
|
||||
|
||||
/**
|
||||
* Get valid certificates for the specified domains
|
||||
*/
|
||||
export async function getValidCertificatesForDomainsHybrid(domains: Set<string>): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
domain: string;
|
||||
wildcard: boolean | null;
|
||||
certFile: string | null;
|
||||
keyFile: string | null;
|
||||
expiresAt: number | null;
|
||||
updatedAt?: number | null;
|
||||
}>
|
||||
> {
|
||||
if (domains.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const domainArray = Array.from(domains);
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/certificates/domains`,
|
||||
{
|
||||
params: {
|
||||
domains: domainArray
|
||||
},
|
||||
headers: (await tokenManager.getAuthHeader()).headers
|
||||
}
|
||||
);
|
||||
|
||||
if (response.status !== 200) {
|
||||
logger.error(
|
||||
`Failed to fetch certificates for domains: ${response.status} ${response.statusText}`,
|
||||
{ responseData: response.data, domains: domainArray }
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
// logger.debug(
|
||||
// `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains`
|
||||
// );
|
||||
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
// pull data out of the axios error to log
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error("Error getting certificates:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method
|
||||
});
|
||||
} else {
|
||||
logger.error("Error getting certificates:", error);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getValidCertificatesForDomains(domains: Set<string>): Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
|
||||
@@ -101,10 +101,7 @@ export class Config {
|
||||
if (!this.rawConfig) {
|
||||
throw new Error("Config not loaded. Call load() first.");
|
||||
}
|
||||
if (this.rawConfig.managed) {
|
||||
// LETS NOT WORRY ABOUT THE SERVER SECRET WHEN MANAGED
|
||||
return;
|
||||
}
|
||||
|
||||
license.setServerSecret(this.rawConfig.server.secret!);
|
||||
|
||||
await this.checkKeyStatus();
|
||||
@@ -157,10 +154,6 @@ export class Config {
|
||||
return false;
|
||||
}
|
||||
|
||||
public isManagedMode() {
|
||||
return typeof this.rawConfig?.managed === "object";
|
||||
}
|
||||
|
||||
public async checkSupporterKey() {
|
||||
const [key] = await db.select().from(supporterKey).limit(1);
|
||||
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import logger from "@server/logger";
|
||||
import { maxmindLookup } from "@server/db/maxmind";
|
||||
import axios from "axios";
|
||||
import config from "./config";
|
||||
import { tokenManager } from "./tokenManager";
|
||||
|
||||
export async function getCountryCodeForIp(
|
||||
ip: string
|
||||
@@ -33,32 +30,4 @@ export async function getCountryCodeForIp(
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
export async function remoteGetCountryCodeForIp(
|
||||
ip: string
|
||||
): Promise<string | undefined> {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/geoip/${ip}`,
|
||||
await tokenManager.getAuthHeader()
|
||||
);
|
||||
|
||||
return response.data.data.countryCode;
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error("Error fetching config in verify session:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method
|
||||
});
|
||||
} else {
|
||||
logger.error("Error fetching config in verify session:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -39,15 +39,6 @@ export const configSchema = z
|
||||
anonymous_usage: true
|
||||
}
|
||||
}),
|
||||
managed: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
id: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
endpoint: z.string().optional().default("https://pangolin.fossorial.io"),
|
||||
redirect_endpoint: z.string().optional()
|
||||
})
|
||||
.optional(),
|
||||
domains: z
|
||||
.record(
|
||||
z.string(),
|
||||
@@ -320,10 +311,7 @@ export const configSchema = z
|
||||
if (data.flags?.disable_config_managed_domains) {
|
||||
return true;
|
||||
}
|
||||
// If hybrid is defined, domains are not required
|
||||
if (data.managed) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
return false;
|
||||
}
|
||||
@@ -335,10 +323,6 @@ export const configSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If hybrid is defined, server secret is not required
|
||||
if (data.managed) {
|
||||
return true;
|
||||
}
|
||||
// If hybrid is not defined, server secret must be defined. If its not defined already then pull it from env
|
||||
if (data.server?.secret === undefined) {
|
||||
data.server.secret = process.env.SERVER_SECRET;
|
||||
@@ -351,10 +335,6 @@ export const configSchema = z
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
// If hybrid is defined, dashboard_url is not required
|
||||
if (data.managed) {
|
||||
return true;
|
||||
}
|
||||
// If hybrid is not defined, dashboard_url must be defined
|
||||
return data.app.dashboard_url !== undefined && data.app.dashboard_url.length > 0;
|
||||
},
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { Router } from "express";
|
||||
import axios from "axios";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { tokenManager } from "./tokenManager";
|
||||
|
||||
/**
|
||||
* Proxy function that forwards requests to the remote cloud server
|
||||
*/
|
||||
|
||||
export const proxyToRemote = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction,
|
||||
endpoint: string
|
||||
): Promise<any> => {
|
||||
try {
|
||||
const remoteUrl = `${config.getRawConfig().managed?.endpoint?.replace(/\/$/, '')}/api/v1/${endpoint}`;
|
||||
|
||||
logger.debug(`Proxying request to remote server: ${remoteUrl}`);
|
||||
|
||||
// Forward the request to the remote server
|
||||
const response = await axios({
|
||||
method: req.method as any,
|
||||
url: remoteUrl,
|
||||
data: req.body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(await tokenManager.getAuthHeader()).headers
|
||||
},
|
||||
params: req.query,
|
||||
timeout: 30000, // 30 second timeout
|
||||
validateStatus: () => true // Don't throw on non-2xx status codes
|
||||
});
|
||||
|
||||
logger.debug(`Proxy response: ${JSON.stringify(response.data)}`);
|
||||
|
||||
// Forward the response status and data
|
||||
return res.status(response.status).json(response.data);
|
||||
|
||||
} catch (error) {
|
||||
logger.error("Error proxying request to remote server:", error);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.SERVICE_UNAVAILABLE,
|
||||
"Remote server is unavailable"
|
||||
)
|
||||
);
|
||||
}
|
||||
if (error.code === 'ECONNABORTED') {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.REQUEST_TIMEOUT,
|
||||
"Request to remote server timed out"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Error communicating with remote server"
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -1,274 +0,0 @@
|
||||
import axios from "axios";
|
||||
import config from "@server/lib/config";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export interface TokenResponse {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data: {
|
||||
token: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Manager - Handles automatic token refresh for hybrid server authentication
|
||||
*
|
||||
* Usage throughout the application:
|
||||
* ```typescript
|
||||
* import { tokenManager } from "@server/lib/tokenManager";
|
||||
*
|
||||
* // Get the current valid token
|
||||
* const token = await tokenManager.getToken();
|
||||
*
|
||||
* // Force refresh if needed
|
||||
* await tokenManager.refreshToken();
|
||||
* ```
|
||||
*
|
||||
* The token manager automatically refreshes tokens every 24 hours by default
|
||||
* and is started once in the privateHybridServer.ts file.
|
||||
*/
|
||||
|
||||
export class TokenManager {
|
||||
private token: string | null = null;
|
||||
private refreshInterval: NodeJS.Timeout | null = null;
|
||||
private isRefreshing: boolean = false;
|
||||
private refreshIntervalMs: number;
|
||||
private retryInterval: NodeJS.Timeout | null = null;
|
||||
private retryIntervalMs: number;
|
||||
private tokenAvailablePromise: Promise<void> | null = null;
|
||||
private tokenAvailableResolve: (() => void) | null = null;
|
||||
|
||||
constructor(refreshIntervalMs: number = 24 * 60 * 60 * 1000, retryIntervalMs: number = 5000) {
|
||||
// Default to 24 hours for refresh, 5 seconds for retry
|
||||
this.refreshIntervalMs = refreshIntervalMs;
|
||||
this.retryIntervalMs = retryIntervalMs;
|
||||
this.setupTokenAvailablePromise();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up promise that resolves when token becomes available
|
||||
*/
|
||||
private setupTokenAvailablePromise(): void {
|
||||
this.tokenAvailablePromise = new Promise((resolve) => {
|
||||
this.tokenAvailableResolve = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the token available promise
|
||||
*/
|
||||
private resolveTokenAvailable(): void {
|
||||
if (this.tokenAvailableResolve) {
|
||||
this.tokenAvailableResolve();
|
||||
this.tokenAvailableResolve = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the token manager - gets initial token and sets up refresh interval
|
||||
* If initial token fetch fails, keeps retrying every few seconds until successful
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
logger.info("Starting token manager...");
|
||||
|
||||
try {
|
||||
await this.refreshToken();
|
||||
this.setupRefreshInterval();
|
||||
this.resolveTokenAvailable();
|
||||
logger.info("Token manager started successfully");
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to get initial token, will retry in ${this.retryIntervalMs / 1000} seconds:`, error);
|
||||
this.setupRetryInterval();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up retry interval for initial token acquisition
|
||||
*/
|
||||
private setupRetryInterval(): void {
|
||||
if (this.retryInterval) {
|
||||
clearInterval(this.retryInterval);
|
||||
}
|
||||
|
||||
this.retryInterval = setInterval(async () => {
|
||||
try {
|
||||
logger.debug("Retrying initial token acquisition");
|
||||
await this.refreshToken();
|
||||
this.setupRefreshInterval();
|
||||
this.clearRetryInterval();
|
||||
this.resolveTokenAvailable();
|
||||
logger.info("Token manager started successfully after retry");
|
||||
} catch (error) {
|
||||
logger.debug("Token acquisition retry failed, will try again");
|
||||
}
|
||||
}, this.retryIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear retry interval
|
||||
*/
|
||||
private clearRetryInterval(): void {
|
||||
if (this.retryInterval) {
|
||||
clearInterval(this.retryInterval);
|
||||
this.retryInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the token manager and clear all intervals
|
||||
*/
|
||||
stop(): void {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = null;
|
||||
}
|
||||
this.clearRetryInterval();
|
||||
logger.info("Token manager stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current valid token
|
||||
*/
|
||||
|
||||
// TODO: WE SHOULD NOT BE GETTING A TOKEN EVERY TIME WE REQUEST IT
|
||||
async getToken(): Promise<string> {
|
||||
// If we don't have a token yet, wait for it to become available
|
||||
if (!this.token && this.tokenAvailablePromise) {
|
||||
await this.tokenAvailablePromise;
|
||||
}
|
||||
|
||||
if (!this.token) {
|
||||
if (this.isRefreshing) {
|
||||
// Wait for current refresh to complete
|
||||
await this.waitForRefresh();
|
||||
} else {
|
||||
throw new Error("No valid token available");
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.token) {
|
||||
throw new Error("No valid token available");
|
||||
}
|
||||
|
||||
return this.token;
|
||||
}
|
||||
|
||||
async getAuthHeader() {
|
||||
return {
|
||||
headers: {
|
||||
Authorization: `Bearer ${await this.getToken()}`,
|
||||
"X-CSRF-Token": "x-csrf-protection",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh the token
|
||||
*/
|
||||
async refreshToken(): Promise<void> {
|
||||
if (this.isRefreshing) {
|
||||
await this.waitForRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const hybridConfig = config.getRawConfig().managed;
|
||||
|
||||
if (
|
||||
!hybridConfig?.id ||
|
||||
!hybridConfig?.secret ||
|
||||
!hybridConfig?.endpoint
|
||||
) {
|
||||
throw new Error("Hybrid configuration is not defined");
|
||||
}
|
||||
|
||||
const tokenEndpoint = `${hybridConfig.endpoint}/api/v1/auth/remoteExitNode/get-token`;
|
||||
|
||||
const tokenData = {
|
||||
remoteExitNodeId: hybridConfig.id,
|
||||
secret: hybridConfig.secret
|
||||
};
|
||||
|
||||
logger.debug("Requesting new token from server");
|
||||
|
||||
const response = await axios.post<TokenResponse>(
|
||||
tokenEndpoint,
|
||||
tokenData,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": "x-csrf-protection"
|
||||
},
|
||||
timeout: 10000 // 10 second timeout
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(
|
||||
`Failed to get token: ${response.data.message}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.data.data.token) {
|
||||
throw new Error("Received empty token from server");
|
||||
}
|
||||
|
||||
this.token = response.data.data.token;
|
||||
logger.debug("Token refreshed successfully");
|
||||
} catch (error) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
logger.error("Error updating proxy mapping:", {
|
||||
message: error.message,
|
||||
code: error.code,
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method
|
||||
});
|
||||
} else {
|
||||
logger.error("Error updating proxy mapping:", error);
|
||||
}
|
||||
|
||||
throw new Error("Failed to refresh token");
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up automatic token refresh interval
|
||||
*/
|
||||
private setupRefreshInterval(): void {
|
||||
if (this.refreshInterval) {
|
||||
clearInterval(this.refreshInterval);
|
||||
}
|
||||
|
||||
this.refreshInterval = setInterval(async () => {
|
||||
try {
|
||||
logger.debug("Auto-refreshing token");
|
||||
await this.refreshToken();
|
||||
} catch (error) {
|
||||
logger.error("Failed to auto-refresh token:", error);
|
||||
}
|
||||
}, this.refreshIntervalMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for current refresh operation to complete
|
||||
*/
|
||||
private async waitForRefresh(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkInterval = setInterval(() => {
|
||||
if (!this.isRefreshing) {
|
||||
clearInterval(checkInterval);
|
||||
resolve();
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Export a singleton instance for use throughout the application
|
||||
export const tokenManager = new TokenManager();
|
||||
@@ -6,12 +6,10 @@ import * as yaml from "js-yaml";
|
||||
import axios from "axios";
|
||||
import { db, exitNodes } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { tokenManager } from "../tokenManager";
|
||||
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
|
||||
import { getTraefikConfig } from "#dynamic/lib/traefik";
|
||||
import {
|
||||
getValidCertificatesForDomains,
|
||||
getValidCertificatesForDomainsHybrid
|
||||
} from "#dynamic/lib/certificates";
|
||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { build } from "@server/build";
|
||||
@@ -348,17 +346,8 @@ export class TraefikConfigManager {
|
||||
|
||||
if (domainsToFetch.size > 0) {
|
||||
// Get valid certificates for domains not covered by wildcards
|
||||
if (config.isManagedMode()) {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomainsHybrid(
|
||||
domainsToFetch
|
||||
);
|
||||
} else {
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(
|
||||
domainsToFetch
|
||||
);
|
||||
}
|
||||
validCertificates =
|
||||
await getValidCertificatesForDomains(domainsToFetch);
|
||||
this.lastCertificateFetch = new Date();
|
||||
this.lastKnownDomains = new Set(domains);
|
||||
|
||||
@@ -448,32 +437,15 @@ export class TraefikConfigManager {
|
||||
} | null> {
|
||||
let traefikConfig;
|
||||
try {
|
||||
if (config.isManagedMode()) {
|
||||
const resp = await axios.get(
|
||||
`${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/traefik-config`,
|
||||
await tokenManager.getAuthHeader()
|
||||
);
|
||||
|
||||
if (resp.status !== 200) {
|
||||
logger.error(
|
||||
`Failed to fetch traefik config: ${resp.status} ${resp.statusText}`,
|
||||
{ responseData: resp.data }
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
traefikConfig = resp.data.data;
|
||||
} else {
|
||||
const currentExitNode = await getCurrentExitNodeId();
|
||||
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
|
||||
traefikConfig = await getTraefikConfig(
|
||||
// this is called by the local exit node to get its own config
|
||||
currentExitNode,
|
||||
config.getRawConfig().traefik.site_types,
|
||||
build == "oss", // filter out the namespace domains in open source
|
||||
build != "oss" // generate the login pages on the cloud and hybrid
|
||||
);
|
||||
}
|
||||
const currentExitNode = await getCurrentExitNodeId();
|
||||
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
|
||||
traefikConfig = await getTraefikConfig(
|
||||
// this is called by the local exit node to get its own config
|
||||
currentExitNode,
|
||||
config.getRawConfig().traefik.site_types,
|
||||
build == "oss", // filter out the namespace domains in open source
|
||||
build != "oss" // generate the login pages on the cloud and hybrid
|
||||
);
|
||||
|
||||
const domains = new Set<string>();
|
||||
|
||||
@@ -842,7 +814,9 @@ export class TraefikConfigManager {
|
||||
const lastUpdateStr = fs
|
||||
.readFileSync(lastUpdatePath, "utf8")
|
||||
.trim();
|
||||
lastUpdateTime = Math.floor(new Date(lastUpdateStr).getTime() / 1000);
|
||||
lastUpdateTime = Math.floor(
|
||||
new Date(lastUpdateStr).getTime() / 1000
|
||||
);
|
||||
} catch {
|
||||
lastUpdateTime = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user