Skip to content

Commit

Permalink
Merge pull request #27 from gfargo/feat/support-target-config-versions
Browse files Browse the repository at this point in the history
Add Config Version Support and Enhance Firewall Sync
  • Loading branch information
gfargo authored Nov 30, 2024
2 parents 2085c32 + e75c93e commit 2ed0b3d
Show file tree
Hide file tree
Showing 6 changed files with 118 additions and 57 deletions.
47 changes: 36 additions & 11 deletions src/commands/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { LogLevels } from 'consola'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
import { dirname } from 'path'
import { Arguments } from 'yargs'
import { z } from 'zod'
import { logger } from '../lib/logger'
import { FirewallConfig, IPBlockingRule } from '../lib/schemas/firewallSchemas'
import { FirewallConfig, IPBlockingRule, configVersionSchema } from '../lib/schemas/firewallSchemas'
import { VercelClient } from '../lib/services/VercelClient'
import { RuleTransformer } from '../lib/transformers/RuleTransformer'
import { prompt } from '../lib/ui/prompt'
Expand All @@ -19,12 +20,18 @@ interface DownloadOptions {
token?: string
dryRun?: boolean
debug?: boolean
configVersion?: number
}

export const command = 'download'
export const desc = 'Download remote Vercel Firewall rules and update local config'
export const command = 'download [configVersion]'
export const desc =
'Download remote Vercel Firewall rules and update local config, optionally for a specific configuration version'

export const builder = {
configVersion: {
type: 'number',
description: 'Specific configuration version to download (defaults to latest)',
},
config: {
alias: 'c',
type: 'string',
Expand Down Expand Up @@ -107,16 +114,31 @@ export const handler = async (argv: Arguments<DownloadOptions>) => {

logger.debug(`Project ID: ${projectId}, Team ID: ${teamId}`)

// Validate version if provided
if (argv.configVersion !== undefined) {
try {
configVersionSchema.parse(argv.configVersion)
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('Invalid configuration version number. Version must be a positive integer.')
process.exit(1)
}
throw error
}
}

const client = new VercelClient(projectId, teamId, token)

logger.start('Fetching remote firewall configuration...')
const activeConfig = await client.fetchActiveFirewallConfig()
logger.debug(`Fetched Vercel config: ${JSON.stringify(activeConfig)}`)
logger.start(
`Fetching remote firewall configuration${argv.configVersion ? ` version ${argv.configVersion}` : ''} ...`,
)
const config = await client.fetchFirewallConfig(argv.configVersion)
logger.debug(`Fetched Vercel config: ${JSON.stringify(config)}`)

const configRules = activeConfig.rules.map(RuleTransformer.fromVercelRule)
const configRules = config.rules.map(RuleTransformer.fromVercelRule)
logger.debug(`Transformed custom rules: ${JSON.stringify(configRules)}`)

const ipBlockingRules = activeConfig.ips as IPBlockingRule[]
const ipBlockingRules = config.ips as IPBlockingRule[]
logger.debug(`IP blocking rules: ${JSON.stringify(ipBlockingRules)}`)

if (configRules.length > 0) {
Expand All @@ -139,7 +161,10 @@ export const handler = async (argv: Arguments<DownloadOptions>) => {
return
}

const confirmed = await prompt('Do you want to download these rules?', { type: 'confirm' })
const confirmed = await prompt(
`Do you want to download${argv.configVersion ? ` version ${argv.configVersion}` : ' the latest version'} of these rules? This will overwrite your local configuration.`,
{ type: 'confirm' },
)
if (!confirmed) {
logger.info(chalk.yellow('Download cancelled.'))
return
Expand All @@ -149,8 +174,8 @@ export const handler = async (argv: Arguments<DownloadOptions>) => {
...existingConfig,
projectId,
teamId,
version: activeConfig.version,
updatedAt: activeConfig.updatedAt,
version: config.version,
updatedAt: config.updatedAt,
rules: configRules,
ips: ipBlockingRules,
}
Expand Down
41 changes: 30 additions & 11 deletions src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import chalk from 'chalk'
import { LogLevels } from 'consola'
import { Arguments } from 'yargs'
import { logger } from '../lib/logger'
import { IPBlockingRule } from '../lib/schemas/firewallSchemas'
import { IPBlockingRule, configVersionSchema } from '../lib/schemas/firewallSchemas'
import { z } from 'zod'
import { VercelClient } from '../lib/services/VercelClient'
import { RuleTransformer } from '../lib/transformers/RuleTransformer'
import { displayIPBlockingTable, displayRulesTable } from '../lib/ui/table'
Expand All @@ -14,12 +15,17 @@ interface ListOptions {
token?: string
format?: 'json' | 'table'
debug: boolean
configVersion?: number
}

export const command = 'list'
export const desc = 'List current Vercel Firewall rules'
export const command = 'list [configVersion]'
export const desc = 'List Vercel Firewall rules, optionally for a specific configuration version'

export const builder = {
configVersion: {
type: 'number',
description: 'Specific configuration version to fetch (defaults to latest)',
},
projectId: {
alias: 'p',
type: 'string',
Expand Down Expand Up @@ -60,34 +66,47 @@ export const handler = async (argv: Arguments<ListOptions>) => {
teamId: argv.teamId,
})

// Validate version if provided
if (argv.configVersion !== undefined) {
try {
configVersionSchema.parse(argv.configVersion)
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('Invalid configuration version number. Version must be a positive integer.')
process.exit(1)
}
throw error
}
}

const client = new VercelClient(projectId, teamId, token)

logger.start(`Fetching firewall configuration ...`)
logger.start(`Fetching firewall configuration${argv.configVersion ? ` version ${argv.configVersion}` : ''} ...`)
logger.verbose(`Token: ${token}\t projectId: ${projectId}\t teamId: ${teamId}`)

const activeConfig = await client.fetchActiveFirewallConfig()
const config = await client.fetchFirewallConfig(argv.configVersion)

// Convert custom rules to config format for cleaner output
const configRules = activeConfig.rules.map(RuleTransformer.fromVercelRule)
const ipBlockingRules = activeConfig.ips as IPBlockingRule[]
const configRules = config.rules.map(RuleTransformer.fromVercelRule)
const ipBlockingRules = config.ips as IPBlockingRule[]

const lastUpdated = new Date(activeConfig.updatedAt)
const lastUpdated = new Date(config.updatedAt)
const formattedDate = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'medium',
}).format(lastUpdated)

logger.info(
`Found ${chalk.cyan(configRules.length)} custom rules and ${chalk.cyan(ipBlockingRules.length)} IP blocking rules\n` +
chalk.dim(`Version: ${chalk.yellow(activeConfig.version)} • Last Updated: ${chalk.yellow(formattedDate)}`),
chalk.dim(`Version: ${chalk.yellow(config.version)} • Last Updated: ${chalk.yellow(formattedDate)}`),
)

if (argv.format === 'json') {
logger.info(
JSON.stringify(
{
version: activeConfig.version,
updatedAt: activeConfig.updatedAt,
version: config.version,
updatedAt: config.updatedAt,
lastUpdated: formattedDate,
rules: configRules,
ips: ipBlockingRules,
Expand Down
10 changes: 7 additions & 3 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import chalk from 'chalk'
import { LogLevels } from 'consola'
import { readFileSync, writeFileSync } from 'fs'
import { Arguments } from 'yargs'
import { logger } from '../lib/logger'
import { FirewallConfig } from '../lib/schemas/firewallSchemas'
import { FirewallService } from '../lib/services/FirewallService'
import { ValidationService } from '../lib/services/ValidationService'
import { VercelClient } from '../lib/services/VercelClient'
import { FirewallConfig } from '../lib/schemas/firewallSchemas'
import { prompt } from '../lib/ui/prompt'
import { displayIPBlockingTable, displayRulesTable, RULE_STATUS_MAP } from '../lib/ui/table'
import { ConfigFinder } from '../lib/utils/configFinder'
import { ErrorFormatter } from '../lib/utils/errorFormatter'
import { promptForCredentials } from '../lib/utils/promptForCredentials'

interface SyncOptions {
config?: string
Expand Down Expand Up @@ -49,10 +51,12 @@ export const builder = {
},
}

import { promptForCredentials } from '../lib/utils/promptForCredentials'

export const handler = async (argv: Arguments<SyncOptions>) => {
try {
if (argv.debug) {
logger.level = LogLevels.debug
}

// Find and read config file
let configPath = argv.config
if (!configPath) {
Expand Down
2 changes: 2 additions & 0 deletions src/lib/schemas/firewallSchemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import type {
} from '../types/vercelTypes'

// Basic schemas
export const configVersionSchema = z.number().int().positive().optional()

export const ipAddressSchema = z
.string()
.ip()
Expand Down
21 changes: 10 additions & 11 deletions src/lib/services/FirewallService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class FirewallService {
}

logger.debug('Fetching existing firewall configuration')
const activeConfig = await this.client.fetchActiveFirewallConfig()
const activeConfig = await this.client.fetchFirewallConfig()
logger.debug(`Fetched ${activeConfig.rules.length} custom rules and ${activeConfig.ips.length} IP blocking rules`)

// Handle custom rules
Expand Down Expand Up @@ -288,7 +288,7 @@ export class FirewallService {
await new Promise((resolve) => setTimeout(resolve, 1500))

// Fetch latest config with retries
const activeConfig = await retry(() => this.client.fetchActiveFirewallConfig(), {
const activeConfig = await retry(() => this.client.fetchFirewallConfig(), {
maxAttempts: 3,
delayMs: 1000,
backoff: true,
Expand Down Expand Up @@ -368,15 +368,14 @@ export class FirewallService {
// Update IP rules with remote IDs
if (updatedConfig.ips) {
updatedConfig.ips = updatedConfig.ips.map((localRule): IPBlockingRule => {
if (!localRule.id || localRule.id === '-') {
// Find matching remote rule by comparing content without IDs
const matchingRemoteRule = remoteIPRules.find((remoteRule) =>
isDeepEqual(omitId(remoteRule), omitId(localRule)),
)
if (matchingRemoteRule) {
// Update local rule with remote ID while preserving all other properties
return { ...localRule, id: matchingRemoteRule.id as string }
}
// Find matching remote rule by comparing content without IDs
const matchingRemoteRule = remoteIPRules.find((remoteRule) =>
isDeepEqual(omitId(remoteRule), omitId(localRule)),
)

if (matchingRemoteRule && matchingRemoteRule.id !== localRule.id) {
// Update to use newest remote ID, if different
return { ...localRule, id: matchingRemoteRule.id as string }
}
return localRule
})
Expand Down
54 changes: 33 additions & 21 deletions src/lib/services/VercelClient.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
import { logger } from '../logger'
import { VercelIPBlockingRule, VercelRule } from '../schemas/firewallSchemas'

export interface ApiResponse {
active: {
version: number
firewallEnabled: boolean
crs: unknown
rules: VercelRule[]
ips: VercelIPBlockingRule[]
ownerId: string
updatedAt: string
id: string
projectKey: string
}
export type ApiResponse = LatestConfigResponse | TargetVersionConfig

export type TargetVersionConfig = VercelConfig

export type LatestConfigResponse = {
active: VercelConfig
}
interface VercelConfig {
version: number
firewallEnabled: boolean
crs: unknown
rules: VercelRule[]
ips: VercelIPBlockingRule[]
ownerId: string
updatedAt: string
id: string
projectKey: string
}

export const VERCEL_API_BASE_URL = 'https://api.vercel.com/v1/security/firewall/config'

/**
Expand Down Expand Up @@ -47,8 +53,9 @@ export class VercelClient {
* Constructs the URL for the Vercel API requests.
* @returns The constructed URL.
*/
private getUrl(version?: number) {
const baseUrl = version !== undefined ? `${VERCEL_API_BASE_URL}/${version}` : VERCEL_API_BASE_URL
private getUrl(configVersion?: number) {
const baseUrl = configVersion !== undefined ? `${VERCEL_API_BASE_URL}/${configVersion}` : VERCEL_API_BASE_URL
logger.debug('API URL:', baseUrl)
return `${baseUrl}?projectId=${this.projectId}&teamId=${this.teamId}`
}

Expand All @@ -69,12 +76,13 @@ export class VercelClient {
}

/**
* Fetches the active firewall config for the Vercel project.
* @returns A promise that resolves to the active firewall config.
* Fetches the firewall config for the Vercel project.
* @param configVersion - Optional version number to fetch a specific config version
* @returns A promise that resolves to the firewall config.
* @throws An error if the fetch request fails.
*/
async fetchActiveFirewallConfig(): Promise<ApiResponse['active']> {
const response = await fetch(this.getUrl(), {
async fetchFirewallConfig(configVersion?: number): Promise<VercelConfig> {
const response = await fetch(this.getUrl(configVersion), {
method: 'GET',
headers: this.getHeaders(),
})
Expand All @@ -86,9 +94,13 @@ export class VercelClient {

const data = (await response.json()) as ApiResponse

logger.debug('Live Config Version:', data.active.version)
logger.debug('Config Version:', configVersion ?? 'latest')

if (configVersion) {
return data as TargetVersionConfig
}

return data.active
return (data as LatestConfigResponse).active
}

/**
Expand All @@ -97,7 +109,7 @@ export class VercelClient {
* @throws An error if the fetch request fails.
*/
async fetchActiveFirewallRules(): Promise<VercelRule[]> {
const data = await this.fetchActiveFirewallConfig()
const data = await this.fetchFirewallConfig()
return data.rules
}

Expand Down

0 comments on commit 2ed0b3d

Please sign in to comment.