diff --git a/src/core/const.ts b/src/core/const.ts index 02eb81bc..0304cc9c 100644 --- a/src/core/const.ts +++ b/src/core/const.ts @@ -68,6 +68,7 @@ export const CLOUDYPAD_SUNSHINE_IMAGE_REGISTRY = "ghcr.io/pierrebeucher/cloudypa export interface SimplePortDefinition { port: number protocol: string + description?: string } /** @@ -75,33 +76,33 @@ export interface SimplePortDefinition { * See https://games-on-whales.github.io/wolf/stable/user/quickstart.html */ export const CLOUDYPAD_WOLF_PORTS: SimplePortDefinition[] = [ - { port: 22, protocol: 'tcp' }, // SSH - { port: 47984, protocol: 'tcp' }, // HTTPS - { port: 47989, protocol: 'tcp' }, // HTTP - { port: 47999, protocol: 'udp' }, // Control - { port: 48010, protocol: 'tcp' }, // RTSP - { port: 48100, protocol: 'udp' }, // Video (up to 10 users, you can open more ports if needed) - { port: 48101, protocol: 'udp' }, - { port: 48102, protocol: 'udp' }, - { port: 48103, protocol: 'udp' }, - { port: 48104, protocol: 'udp' }, - { port: 48105, protocol: 'udp' }, - { port: 48106, protocol: 'udp' }, - { port: 48107, protocol: 'udp' }, - { port: 48108, protocol: 'udp' }, - { port: 48109, protocol: 'udp' }, - { port: 48110, protocol: 'udp' }, - { port: 48200, protocol: 'udp' }, // Audio (up to 10 users, you can open more ports if needed) - { port: 48201, protocol: 'udp' }, - { port: 48202, protocol: 'udp' }, - { port: 48203, protocol: 'udp' }, - { port: 48204, protocol: 'udp' }, - { port: 48205, protocol: 'udp' }, - { port: 48206, protocol: 'udp' }, - { port: 48207, protocol: 'udp' }, - { port: 48208, protocol: 'udp' }, - { port: 48209, protocol: 'udp' }, - { port: 48210, protocol: 'udp' }, + { port: 22, protocol: 'tcp', description: 'SSH' }, + { port: 47984, protocol: 'tcp', description: 'Wolf HTTPS control' }, + { port: 47989, protocol: 'tcp', description: 'Wolf HTTP control' }, + { port: 47999, protocol: 'udp', description: 'Wolf Moonlight control channel' }, + { port: 48010, protocol: 'tcp', description: 'Wolf RTSP stream setup' }, + { port: 48100, protocol: 'udp', description: 'Wolf video stream (user 1)' }, + { port: 48101, protocol: 'udp', description: 'Wolf video stream (user 2)' }, + { port: 48102, protocol: 'udp', description: 'Wolf video stream (user 3)' }, + { port: 48103, protocol: 'udp', description: 'Wolf video stream (user 4)' }, + { port: 48104, protocol: 'udp', description: 'Wolf video stream (user 5)' }, + { port: 48105, protocol: 'udp', description: 'Wolf video stream (user 6)' }, + { port: 48106, protocol: 'udp', description: 'Wolf video stream (user 7)' }, + { port: 48107, protocol: 'udp', description: 'Wolf video stream (user 8)' }, + { port: 48108, protocol: 'udp', description: 'Wolf video stream (user 9)' }, + { port: 48109, protocol: 'udp', description: 'Wolf video stream (user 10)' }, + { port: 48110, protocol: 'udp', description: 'Wolf video stream (user 11)' }, + { port: 48200, protocol: 'udp', description: 'Wolf audio stream (user 1)' }, + { port: 48201, protocol: 'udp', description: 'Wolf audio stream (user 2)' }, + { port: 48202, protocol: 'udp', description: 'Wolf audio stream (user 3)' }, + { port: 48203, protocol: 'udp', description: 'Wolf audio stream (user 4)' }, + { port: 48204, protocol: 'udp', description: 'Wolf audio stream (user 5)' }, + { port: 48205, protocol: 'udp', description: 'Wolf audio stream (user 6)' }, + { port: 48206, protocol: 'udp', description: 'Wolf audio stream (user 7)' }, + { port: 48207, protocol: 'udp', description: 'Wolf audio stream (user 8)' }, + { port: 48208, protocol: 'udp', description: 'Wolf audio stream (user 9)' }, + { port: 48209, protocol: 'udp', description: 'Wolf audio stream (user 10)' }, + { port: 48210, protocol: 'udp', description: 'Wolf audio stream (user 11)' }, ] /** @@ -111,16 +112,15 @@ export const CLOUDYPAD_WOLF_PORTS: SimplePortDefinition[] = [ * See archive: https://web.archive.org/web/20241228223157/https://docs.lizardbyte.dev/projects/sunshine/en/latest/about/advanced_usage.html#port */ export const CLOUDYPAD_SUNSHINE_PORTS: SimplePortDefinition[] = [ - { port: 22, protocol: 'tcp' }, // SSH - { port: 47984, protocol: 'tcp' }, // HTTPS - { port: 47989, protocol: 'tcp' }, // HTTP - { port: 47990, protocol: 'tcp' }, // Web - { port: 48010, protocol: 'tcp' }, // RTSP - - { port: 47998, protocol: 'udp' }, // Video - { port: 47999, protocol: 'udp' }, // Control - { port: 48000, protocol: 'udp' }, // Audio - { port: 48002, protocol: 'udp' }, // Mic (unused) + { port: 22, protocol: 'tcp', description: 'SSH' }, + { port: 47984, protocol: 'tcp', description: 'Sunshine HTTPS control' }, + { port: 47989, protocol: 'tcp', description: 'Sunshine HTTP control' }, + { port: 47990, protocol: 'tcp', description: 'Sunshine web UI' }, + { port: 48010, protocol: 'tcp', description: 'Sunshine RTSP stream setup' }, + { port: 47998, protocol: 'udp', description: 'Sunshine video stream' }, + { port: 47999, protocol: 'udp', description: 'Sunshine Moonlight control channel' }, + { port: 48000, protocol: 'udp', description: 'Sunshine audio stream' }, + { port: 48002, protocol: 'udp', description: 'Sunshine microphone input (client to server)' }, ] /** diff --git a/src/providers/aws/cli.ts b/src/providers/aws/cli.ts index be2cbb5d..e429a89a 100644 --- a/src/providers/aws/cli.ts +++ b/src/providers/aws/cli.ts @@ -1,4 +1,5 @@ import { AwsInstanceInput, AwsInstanceStateV1, AwsProvisionInputV1, AwsStateParser } from "./state" +import { fetchCurrentIpCidrs } from '../../tools/ip' import { CommonConfigurationInputV1, CommonInstanceInput } from "../../core/state/state" import { input, select, confirm } from '@inquirer/prompts'; import { AwsClient, EC2_QUOTA_CODE_ALL_G_AND_VT_SPOT_INSTANCES, EC2_QUOTA_CODE_RUNNING_ON_DEMAND_G_AND_VT_INSTANCES, DEFAULT_REGION } from "./sdk-client"; @@ -35,6 +36,7 @@ export const AwsCreateCliArgsSchema = CreateCliArgsSchema.extend({ baseImageKeepOnDeletion: z.boolean().optional(), dataDiskSnapshot: z.boolean().optional(), deleteInstanceServerOnStop: z.boolean().optional(), + restrictToMyIp: z.boolean().default(true), }) /** @@ -75,11 +77,17 @@ export const SUPPORTED_INSTANCE_TYPES = [ export class AwsInputPrompter extends AbstractInputPrompter { + // Stashed from buildProvisionerInputFromCliArgs for use in resolveAllowedCidrs. + // restrictToMyIp is a CLI-only concept; state stores resolved allowedCidrs instead. + // Defaults to false (no restriction); set to true by Commander's --no-restrict-to-my-ip default. + private _cliRestrictToMyIp = false + constructor(args: AbstractInputPrompterArgs){ super(args) } buildProvisionerInputFromCliArgs(cliArgs: AwsCreateCliArgs): PartialDeep { + this._cliRestrictToMyIp = cliArgs.restrictToMyIp return { provision: { @@ -91,10 +99,10 @@ export class AwsInputPrompter extends AbstractInputPrompter { + if (!this._cliRestrictToMyIp) { + return { ipv4: ['0.0.0.0/0'], ipv6: ['::/0'] } + } + + const cidrs = await fetchCurrentIpCidrs() + this.logger.info( + `Detected current IPs: IPv4=${cidrs.ipv4[0]}${cidrs.ipv6[0] ? `, IPv6=${cidrs.ipv6[0]}` : ' (no IPv6 detected)'}` + ) + return cidrs + } + private async instanceType(region: string, useSpot: boolean, instanceType?: string): Promise { if (instanceType) { @@ -325,6 +353,7 @@ export class AwsCliCommandGenerator extends CliCommandGenerator { .option('--region ', 'Region in which to deploy instance') .option('--zone ', 'Availability zone in which to deploy instance') .option('--image-id ', 'Existing AMI ID for instance server. Disk size must be equal or greater than image size.') + .option('--no-restrict-to-my-ip', 'Allow inbound traffic from all IPs instead of restricting to your current IP') .action(async (rawCliArgs: unknown) => { // Parse raw CLI args using Zod schema early to ensure type safety const cliArgs = AwsCreateCliArgsSchema.parse(rawCliArgs) diff --git a/src/providers/aws/provisioner.ts b/src/providers/aws/provisioner.ts index bcf7a3ee..bad4a46e 100644 --- a/src/providers/aws/provisioner.ts +++ b/src/providers/aws/provisioner.ts @@ -4,6 +4,7 @@ import { AwsDataDiskSnapshotPulumiClient, PulumiStackConfigAwsDataDiskSnapshot } import { AwsBaseImagePulumiClient, PulumiStackConfigAwsBaseImage } from './pulumi/base-image-snapshot'; import { AbstractInstanceProvisioner, InstanceProvisionerArgs, ProvisionerActionOptions } from '../../core/provisioner'; import { AwsClient } from './sdk-client'; +import { fetchCurrentIpCidrs } from '../../tools/ip'; import { AwsProvisionInputV1, AwsProvisionOutputV1 } from './state'; import { DATA_DISK_STATE_LIVE, DATA_DISK_STATE_SNAPSHOT } from '../../core/const'; @@ -92,7 +93,7 @@ export class AwsProvisioner extends AbstractInstanceProvisioner { const sshPublicKeyContent = new SshKeyLoader().loadSshPublicKeyContent(this.args.provisionInput.ssh) + // If the user chose open access (0.0.0.0/0), preserve that choice as-is. + // If the user chose IP restriction, re-fetch their current IP on every provision + // so the security group stays current across create and start flows. + let allowedCidrs = this.args.provisionInput.allowedCidrs + if (allowedCidrs.ipv4[0] !== '0.0.0.0/0') { + allowedCidrs = await fetchCurrentIpCidrs() + this.logger.info( + `Refreshed IPs for security group: IPv4=${allowedCidrs.ipv4[0]}${allowedCidrs.ipv6[0] ? `, IPv6=${allowedCidrs.ipv6[0]}` : ' (no IPv6 detected)'}` + ) + } + return { instanceType: this.args.provisionInput.instanceType, publicIpType: this.args.provisionInput.publicIpType, @@ -156,6 +168,7 @@ export class AwsProvisioner extends AbstractInstanceProvisioner | void> { const publicKeyContent = config.require("publicSshKeyContent"); const useSpot = config.requireBoolean("useSpot"); const ingressPorts = config.requireObject("ingressPorts") + const allowedCidrs = config.getObject<{ ipv4: string[], ipv6: string[] }>("allowedCidrs") const imageId = config.get("imageId") const dataDisk = config.getObject<{ state: "present" | "absent", sizeGb: number, snapshotId?: string }>("dataDisk") const instanceServerState = config.get("instanceServerState") as "present" | "absent" | undefined @@ -368,11 +369,12 @@ async function awsPulumiProgram(): Promise | void> { dataDisk: dataDisk, instanceServerState: instanceServerState, ingressPorts: ingressPorts.map(p => ({ - fromPort: p.port, - toPort: p.port, - protocol: p.protocol, - cidrBlocks: ["0.0.0.0/0"], - ipv6CidrBlocks: ["::/0"] + fromPort: p.port, + toPort: p.port, + protocol: p.protocol, + cidrBlocks: allowedCidrs?.ipv4 ?? ["0.0.0.0/0"], + ipv6CidrBlocks: allowedCidrs?.ipv6 ?? ["::/0"], + description: p.description, })) }) @@ -405,6 +407,10 @@ export interface PulumiStackConfigAws { notificationEmail: string }, ingressPorts: SimplePortDefinition[] + allowedCidrs?: { + ipv4: string[] + ipv6: string[] + } } export interface AwsPulumiOutput { @@ -463,6 +469,8 @@ export class AwsPulumiClient extends InstancePulumiClient { + return new Promise((resolve) => { + const req = https.request({ + hostname: 'checkip.global.api.aws', + path: '/', + method: 'GET', + family, + timeout: timeoutMs, + }, (res) => { + let data = '' + res.on('data', (chunk: string) => { data += chunk }) + res.on('end', () => resolve(data.trim())) + }) + req.on('timeout', () => { req.destroy(); resolve(undefined) }) + req.on('error', () => resolve(undefined)) + req.end() + }) +} + +/** + * Fetch the current external IPv4 and IPv6 addresses and return them as CIDR ranges. + * Throws if the IPv4 address cannot be detected. + * IPv6 is best-effort; an empty array is returned if unavailable. + */ +export async function fetchCurrentIpCidrs(): Promise<{ ipv4: string[], ipv6: string[] }> { + const [ipv4, ipv6] = await Promise.all([fetchCurrentIp(4), fetchCurrentIp(6)]) + if (!ipv4) { + throw new Error('Could not detect current IPv4 address. Check your internet connection.') + } + return { + ipv4: [`${ipv4}/32`], + ipv6: ipv6 ? [`${ipv6}/128`] : [], + } +} diff --git a/test/resources/states/v1-root-data-dir/instances/aws-dummy/state.yml b/test/resources/states/v1-root-data-dir/instances/aws-dummy/state.yml index 1246f3ef..7ed08357 100644 --- a/test/resources/states/v1-root-data-dir/instances/aws-dummy/state.yml +++ b/test/resources/states/v1-root-data-dir/instances/aws-dummy/state.yml @@ -11,6 +11,11 @@ provision: publicIpType: static region: eu-central-1 useSpot: true + allowedCidrs: + ipv4: + - 0.0.0.0/0 + ipv6: + - ::/0 ssh: user: ubuntu privateKeyContentBase64: ZHVtbXkta2V5 diff --git a/test/unit/providers/aws/cli.spec.ts b/test/unit/providers/aws/cli.spec.ts index 9e193776..0fd4cef6 100644 --- a/test/unit/providers/aws/cli.spec.ts +++ b/test/unit/providers/aws/cli.spec.ts @@ -34,6 +34,7 @@ describe('AWS input prompter', () => { enable: true, }, deleteInstanceServerOnStop: true, + allowedCidrs: { ipv4: ['0.0.0.0/0'], ipv6: ['::/0'] }, }, configuration: { ...DEFAULT_COMMON_INPUT.configuration @@ -69,8 +70,8 @@ describe('AWS input prompter', () => { const expected: PartialDeep = { ...TEST_INPUT, provision: { - // publicIpType is not set via CLI - ...lodash.omit(TEST_INPUT.provision, "publicIpType"), + // publicIpType and allowedCidrs are not set via CLI args — resolved at prompt time + ...lodash.omit(TEST_INPUT.provision, "publicIpType", "allowedCidrs"), ssh: lodash.omit(TEST_INPUT.provision.ssh, "user"), costAlert: { limit: 999,