Merge pull request #701 from fosrl/dev

1.3.2
This commit is contained in:
Milo Schwartz 2025-05-10 11:30:07 -04:00 committed by GitHub
commit a512148348
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 207 additions and 67 deletions

View file

@ -64,15 +64,15 @@ func main() {
} }
var config Config var config Config
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
// check if there is already a config file // check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil { if _, err := os.Stat("config/config.yml"); err != nil {
config = collectUserInput(reader) config = collectUserInput(reader)
loadVersions(&config) loadVersions(&config)
config.DoCrowdsecInstall = false
config.Secret = generateRandomSecretKey()
if err := createConfigFiles(config); err != nil { if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err) fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1) os.Exit(1)

View file

@ -29,9 +29,12 @@ const configSchema = z.object({
.optional() .optional()
.pipe(z.string().url()) .pipe(z.string().url())
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
log_level: z.enum(["debug", "info", "warn", "error"]), log_level: z
save_logs: z.boolean(), .enum(["debug", "info", "warn", "error"])
log_failed_attempts: z.boolean().optional() .optional()
.default("info"),
save_logs: z.boolean().optional().default(false),
log_failed_attempts: z.boolean().optional().default(false)
}), }),
domains: z domains: z
.record( .record(
@ -41,8 +44,8 @@ const configSchema = z.object({
.string() .string()
.nonempty("base_domain must not be empty") .nonempty("base_domain must not be empty")
.transform((url) => url.toLowerCase()), .transform((url) => url.toLowerCase()),
cert_resolver: z.string().optional(), cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional() prefer_wildcard_cert: z.boolean().optional().default(false)
}) })
) )
.refine( .refine(
@ -62,19 +65,42 @@ const configSchema = z.object({
server: z.object({ server: z.object({
integration_port: portSchema integration_port: portSchema
.optional() .optional()
.default(3003)
.transform(stoi) .transform(stoi)
.pipe(portSchema.optional()), .pipe(portSchema.optional()),
external_port: portSchema.optional().transform(stoi).pipe(portSchema), external_port: portSchema
internal_port: portSchema.optional().transform(stoi).pipe(portSchema), .optional()
next_port: portSchema.optional().transform(stoi).pipe(portSchema), .default(3000)
internal_hostname: z.string().transform((url) => url.toLowerCase()), .transform(stoi)
session_cookie_name: z.string(), .pipe(portSchema),
resource_access_token_param: z.string(), internal_port: portSchema
resource_access_token_headers: z.object({ .optional()
id: z.string(), .default(3001)
token: z.string() .transform(stoi)
}), .pipe(portSchema),
resource_session_request_param: z.string(), next_port: portSchema
.optional()
.default(3002)
.transform(stoi)
.pipe(portSchema),
internal_hostname: z
.string()
.optional()
.default("pangolin")
.transform((url) => url.toLowerCase()),
session_cookie_name: z.string().optional().default("p_session_token"),
resource_access_token_param: z.string().optional().default("p_token"),
resource_access_token_headers: z
.object({
id: z.string().optional().default("P-Access-Token-Id"),
token: z.string().optional().default("P-Access-Token")
})
.optional()
.default({}),
resource_session_request_param: z
.string()
.optional()
.default("resource_session_request_param"),
dashboard_session_length_hours: z dashboard_session_length_hours: z
.number() .number()
.positive() .positive()
@ -102,35 +128,61 @@ const configSchema = z.object({
.transform(getEnvOrYaml("SERVER_SECRET")) .transform(getEnvOrYaml("SERVER_SECRET"))
.pipe(z.string().min(8)) .pipe(z.string().min(8))
}), }),
traefik: z.object({ traefik: z
http_entrypoint: z.string(), .object({
https_entrypoint: z.string().optional(), http_entrypoint: z.string().optional().default("web"),
additional_middlewares: z.array(z.string()).optional() https_entrypoint: z.string().optional().default("websecure"),
}), additional_middlewares: z.array(z.string()).optional()
gerbil: z.object({ })
start_port: portSchema.optional().transform(stoi).pipe(portSchema), .optional()
base_endpoint: z .default({}),
.string() gerbil: z
.optional() .object({
.pipe(z.string()) start_port: portSchema
.transform((url) => url.toLowerCase()), .optional()
use_subdomain: z.boolean(), .default(51820)
subnet_group: z.string(), .transform(stoi)
block_size: z.number().positive().gt(0), .pipe(portSchema),
site_block_size: z.number().positive().gt(0) base_endpoint: z
}), .string()
rate_limits: z.object({ .optional()
global: z.object({ .pipe(z.string())
window_minutes: z.number().positive().gt(0), .transform((url) => url.toLowerCase()),
max_requests: z.number().positive().gt(0) use_subdomain: z.boolean().optional().default(false),
}), subnet_group: z.string().optional().default("100.89.137.0/20"),
auth: z block_size: z.number().positive().gt(0).optional().default(24),
.object({ site_block_size: z.number().positive().gt(0).optional().default(30)
window_minutes: z.number().positive().gt(0), })
max_requests: z.number().positive().gt(0) .optional()
}) .default({}),
.optional() rate_limits: z
}), .object({
global: z
.object({
window_minutes: z
.number()
.positive()
.gt(0)
.optional()
.default(1),
max_requests: z
.number()
.positive()
.gt(0)
.optional()
.default(500)
})
.optional()
.default({}),
auth: z
.object({
window_minutes: z.number().positive().gt(0),
max_requests: z.number().positive().gt(0)
})
.optional()
})
.optional()
.default({}),
email: z email: z
.object({ .object({
smtp_host: z.string().optional(), smtp_host: z.string().optional(),

View file

@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.3.0"; export const APP_VERSION = "1.3.2";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);

View file

@ -9,6 +9,10 @@ export function isValidIP(ip: string): boolean {
} }
export function isValidUrlGlobPattern(pattern: string): boolean { export function isValidUrlGlobPattern(pattern: string): boolean {
if (pattern === "/") {
return true;
}
// Remove leading slash if present // Remove leading slash if present
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern; pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;

View file

@ -1,61 +1,136 @@
import { isPathAllowed } from './verifySession';
import { assertEquals } from '@test/assert'; import { assertEquals } from '@test/assert';
function isPathAllowed(pattern: string, path: string): boolean {
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
return result;
}
// For full segment wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
// Try consuming 0 segments (skip the wildcard)
if (matchSegments(patternIndex + 1, pathIndex)) {
return true;
}
// Try consuming current segment and recursively try rest
if (matchSegments(patternIndex, pathIndex + 1)) {
return true;
}
return false;
}
// Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix")
if (currentPatternPart.includes("*")) {
// Convert the pattern segment to a regex pattern
const regexPattern = currentPatternPart
.replace(/\*/g, ".*") // Replace * with .* for regex wildcard
.replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed
const regex = new RegExp(`^${regexPattern}$`);
if (regex.test(currentPathPart)) {
return matchSegments(patternIndex + 1, pathIndex + 1);
}
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
return false;
}
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
return result;
}
function runTests() { function runTests() {
console.log('Running path matching tests...'); console.log('Running path matching tests...');
// Test exact matching // Test exact matching
assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed'); assertEquals(isPathAllowed('foo', 'foo'), true, 'Exact match should be allowed');
assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match'); assertEquals(isPathAllowed('foo', 'bar'), false, 'Different segments should not match');
assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed'); assertEquals(isPathAllowed('foo/bar', 'foo/bar'), true, 'Exact multi-segment match should be allowed');
assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed'); assertEquals(isPathAllowed('foo/bar', 'foo/baz'), false, 'Partial multi-segment match should not be allowed');
// Test with leading and trailing slashes // Test with leading and trailing slashes
assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match'); assertEquals(isPathAllowed('/foo', 'foo'), true, 'Pattern with leading slash should match');
assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match'); assertEquals(isPathAllowed('foo/', 'foo'), true, 'Pattern with trailing slash should match');
assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match'); assertEquals(isPathAllowed('/foo/', 'foo'), true, 'Pattern with both leading and trailing slashes should match');
assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match'); assertEquals(isPathAllowed('foo', '/foo/'), true, 'Path with leading and trailing slashes should match');
// Test simple wildcard matching // Test simple wildcard matching
assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment'); assertEquals(isPathAllowed('*', 'foo'), true, 'Single wildcard should match any single segment');
assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments'); assertEquals(isPathAllowed('*', 'foo/bar'), true, 'Single wildcard should match multiple segments');
assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match'); assertEquals(isPathAllowed('*/bar', 'foo/bar'), true, 'Wildcard prefix should match');
assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match'); assertEquals(isPathAllowed('foo/*', 'foo/bar'), true, 'Wildcard suffix should match');
assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match'); assertEquals(isPathAllowed('foo/*/baz', 'foo/bar/baz'), true, 'Wildcard in middle should match');
// Test multiple wildcards // Test multiple wildcards
assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments'); assertEquals(isPathAllowed('*/*', 'foo/bar'), true, 'Multiple wildcards should match corresponding segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments'); assertEquals(isPathAllowed('*/*/*', 'foo/bar/baz'), true, 'Three wildcards should match three segments');
assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match'); assertEquals(isPathAllowed('foo/*/*', 'foo/bar/baz'), true, 'Specific prefix with wildcards should match');
assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match'); assertEquals(isPathAllowed('*/*/baz', 'foo/bar/baz'), true, 'Wildcards with specific suffix should match');
// Test wildcard consumption behavior // Test wildcard consumption behavior
assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments'); assertEquals(isPathAllowed('*', ''), true, 'Wildcard should optionally consume segments');
assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional'); assertEquals(isPathAllowed('foo/*', 'foo'), true, 'Trailing wildcard should be optional');
assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments'); assertEquals(isPathAllowed('*/*', 'foo'), true, 'Multiple wildcards can match fewer segments');
assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped'); assertEquals(isPathAllowed('*/*/*', 'foo/bar'), true, 'Extra wildcards can be skipped');
// Test complex nested paths // Test complex nested paths
assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match'); assertEquals(isPathAllowed('api/*/users', 'api/v1/users'), true, 'API versioning pattern should match');
assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match'); assertEquals(isPathAllowed('api/*/users/*', 'api/v1/users/123'), true, 'API resource pattern should match');
assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match'); assertEquals(isPathAllowed('api/*/users/*/profile', 'api/v1/users/123/profile'), true, 'Nested API pattern should match');
// Test for the requested padbootstrap* pattern // Test for the requested padbootstrap* pattern
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap'), true, 'padbootstrap* should match padbootstrap');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrapv1'), true, 'padbootstrap* should match padbootstrapv1');
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap/files'), false, 'padbootstrap* should not match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files'); assertEquals(isPathAllowed('padbootstrap*/*', 'padbootstrap/files'), true, 'padbootstrap*/* should match padbootstrap/files');
assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)'); assertEquals(isPathAllowed('padbootstrap*/files', 'padbootstrapv1/files'), true, 'padbootstrap*/files should not match padbootstrapv1/files (wildcard is segment-based, not partial)');
// Test wildcard edge cases // Test wildcard edge cases
assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments'); assertEquals(isPathAllowed('*/*/*/*/*/*', 'a/b'), true, 'Many wildcards can match few segments');
assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments'); assertEquals(isPathAllowed('a/*/b/*/c', 'a/anything/b/something/c'), true, 'Multiple wildcards in pattern should match corresponding segments');
// Test patterns with partial segment matches // Test patterns with partial segment matches
assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based'); assertEquals(isPathAllowed('padbootstrap*', 'padbootstrap-123'), true, 'Wildcards in isPathAllowed should be segment-based, not character-based');
assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard'); assertEquals(isPathAllowed('test*', 'testuser'), true, 'Asterisk as part of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard'); assertEquals(isPathAllowed('my*app', 'myapp'), true, 'Asterisk in middle of segment name is treated as a literal, not a wildcard');
assertEquals(isPathAllowed('/', '/'), true, 'Root path should match root path');
assertEquals(isPathAllowed('/', '/test'), false, 'Root path should not match non-root path');
console.log('All tests passed!'); console.log('All tests passed!');
} }
@ -64,4 +139,4 @@ try {
runTests(); runTests();
} catch (error) { } catch (error) {
console.error('Test failed:', error); console.error('Test failed:', error);
} }

View file

@ -160,7 +160,9 @@ export async function validateOidcCallback(
); );
const idToken = tokens.idToken(); const idToken = tokens.idToken();
logger.debug("ID token", { idToken });
const claims = arctic.decodeIdToken(idToken); const claims = arctic.decodeIdToken(idToken);
logger.debug("ID token claims", { claims });
const userIdentifier = jmespath.search( const userIdentifier = jmespath.search(
claims, claims,

View file

@ -318,8 +318,8 @@ async function updateHttpResource(
domainId: updatePayload.domainId, domainId: updatePayload.domainId,
enabled: updatePayload.enabled, enabled: updatePayload.enabled,
stickySession: updatePayload.stickySession, stickySession: updatePayload.stickySession,
tlsServerName: updatePayload.tlsServerName || null, tlsServerName: updatePayload.tlsServerName,
setHostHeader: updatePayload.setHostHeader || null, setHostHeader: updatePayload.setHostHeader,
fullDomain: updatePayload.fullDomain fullDomain: updatePayload.fullDomain
}) })
.where(eq(resources.resourceId, resource.resourceId)) .where(eq(resources.resourceId, resource.resourceId))

View file

@ -320,8 +320,10 @@ export default function ReverseProxyTargets(props: {
AxiosResponse<CreateTargetResponse> AxiosResponse<CreateTargetResponse>
>(`/resource/${params.resourceId}/target`, data); >(`/resource/${params.resourceId}/target`, data);
target.targetId = res.data.data.targetId; target.targetId = res.data.data.targetId;
target.new = false;
} else if (target.updated) { } else if (target.updated) {
await api.post(`/target/${target.targetId}`, data); await api.post(`/target/${target.targetId}`, data);
target.updated = false;
} }
} }
@ -796,6 +798,12 @@ export default function ReverseProxyTargets(props: {
type="submit" type="submit"
variant="outlinePrimary" variant="outlinePrimary"
className="mt-6" className="mt-6"
disabled={
!(
addTargetForm.getValues("ip") &&
addTargetForm.getValues("port")
)
}
> >
Add Target Add Target
</Button> </Button>

View file

@ -64,7 +64,6 @@ import {
InfoSections, InfoSections,
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { Separator } from "@app/components/ui/separator";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
import { import {
isValidCIDR, isValidCIDR,

View file

@ -21,7 +21,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{user && ( {user && (
<UserProvider user={user}> <UserProvider user={user}>
<div className="p-3"> <div className="p-3 ml-auto">
<ProfileIcon /> <ProfileIcon />
</div> </div>
</UserProvider> </UserProvider>

View file

@ -55,7 +55,7 @@ export function SettingsSectionFooter({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>; return <div className="flex justify-end space-x-2 mt-auto pt-6">{children}</div>;
} }
export function SettingsSectionGrid({ export function SettingsSectionGrid({