Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 37 additions & 37 deletions src/core/const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,40 +68,41 @@ export const CLOUDYPAD_SUNSHINE_IMAGE_REGISTRY = "ghcr.io/pierrebeucher/cloudypa
export interface SimplePortDefinition {
port: number
protocol: string
description?: string
}

/**
* Ports used by Wolf
* 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)' },
]

/**
Expand All @@ -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)' },
]

/**
Expand Down
39 changes: 34 additions & 5 deletions src/providers/aws/cli.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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),
})

/**
Expand Down Expand Up @@ -75,11 +77,17 @@ export const SUPPORTED_INSTANCE_TYPES = [

export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, AwsProvisionInputV1, CommonConfigurationInputV1> {

// 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<AwsInstanceInput> {
this._cliRestrictToMyIp = cliArgs.restrictToMyIp

return {
provision: {
Expand All @@ -91,10 +99,10 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw
useSpot: cliArgs.spot,
costAlert: costAlertCliArgsIntoConfig(cliArgs),
deleteInstanceServerOnStop: cliArgs.deleteInstanceServerOnStop,
dataDiskSnapshot: cliArgs.dataDiskSnapshot ? {
enable: cliArgs.dataDiskSnapshot
dataDiskSnapshot: cliArgs.dataDiskSnapshot ? {
enable: cliArgs.dataDiskSnapshot
} : undefined,
baseImageSnapshot: cliArgs.baseImageSnapshot ? {
baseImageSnapshot: cliArgs.baseImageSnapshot ? {
enable: cliArgs.baseImageSnapshot,
keepOnDeletion: cliArgs.baseImageKeepOnDeletion
} : undefined
Expand All @@ -116,10 +124,11 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw
const dataDiskSizeGb = await this.dataDiskSize(partialInput.provision?.dataDiskSizeGb)
const publicIpType = await this.publicIpType(partialInput.provision?.publicIpType)
const costAlert = await this.costAlert(partialInput.provision?.costAlert)

const allowedCidrs = await this.resolveAllowedCidrs()

const awsInput: AwsInstanceInput = lodash.merge(
{},
commonInput,
commonInput,
{
provision:{
diskSize: rootDiskSize,
Expand All @@ -129,6 +138,7 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw
region: region,
zone: zone,
useSpot: useSpot,
allowedCidrs: allowedCidrs,
costAlert: costAlert,
deleteInstanceServerOnStop: partialInput.provision?.deleteInstanceServerOnStop,
dataDiskSnapshot: partialInput.provision?.dataDiskSnapshot?.enable ? {
Expand All @@ -145,6 +155,24 @@ export class AwsInputPrompter extends AbstractInputPrompter<AwsCreateCliArgs, Aw

}

/**
* Resolve allowed CIDRs for security group ingress based on CLI flags.
*
* - If --no-restrict-to-my-ip was passed, use open CIDRs (no restriction).
* - Otherwise (default), fetch and return the user's current IP as restricted CIDRs.
*/
private async resolveAllowedCidrs(): Promise<{ ipv4: string[], ipv6: string[] }> {
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<string> {

if (instanceType) {
Expand Down Expand Up @@ -325,6 +353,7 @@ export class AwsCliCommandGenerator extends CliCommandGenerator {
.option('--region <region>', 'Region in which to deploy instance')
.option('--zone <zone>', 'Availability zone in which to deploy instance')
.option('--image-id <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)
Expand Down
17 changes: 15 additions & 2 deletions src/providers/aws/provisioner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -92,7 +93,7 @@ export class AwsProvisioner extends AbstractInstanceProvisioner<AwsProvisionInpu

// Build and run main Pulumi stack
const pulumiClient = this.buildMainPulumiClient()
const stackConfig = this.buildMainPulumiConfig()
const stackConfig = await this.buildMainPulumiConfig()
await pulumiClient.setConfig(stackConfig)
const pulumiOutputs = await pulumiClient.up({ cancel: opts?.pulumiCancel })

Expand Down Expand Up @@ -143,9 +144,20 @@ export class AwsProvisioner extends AbstractInstanceProvisioner<AwsProvisionInpu
/**
* Build Pulumi config from provision input, including runtime state.
*/
private buildMainPulumiConfig(): PulumiStackConfigAws {
private async buildMainPulumiConfig(): Promise<PulumiStackConfigAws> {
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,
Expand All @@ -156,6 +168,7 @@ export class AwsProvisioner extends AbstractInstanceProvisioner<AwsProvisionInpu
useSpot: this.args.provisionInput.useSpot,
billingAlert: this.args.provisionInput.costAlert ?? undefined,
ingressPorts: this.getStreamingServerPorts(),
allowedCidrs,
instanceServerState: this.args.provisionInput.runtime?.instanceServerState,
dataDisk: this.args.provisionInput.dataDiskSizeGb ? {
// only set data disk as absent if desired data disk state is explicitly set to snapshot
Expand Down
18 changes: 13 additions & 5 deletions src/providers/aws/pulumi/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ async function awsPulumiProgram(): Promise<Record<string, any> | void> {
const publicKeyContent = config.require("publicSshKeyContent");
const useSpot = config.requireBoolean("useSpot");
const ingressPorts = config.requireObject<SimplePortDefinition[]>("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
Expand Down Expand Up @@ -368,11 +369,12 @@ async function awsPulumiProgram(): Promise<Record<string, any> | 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,
}))
})

Expand Down Expand Up @@ -405,6 +407,10 @@ export interface PulumiStackConfigAws {
notificationEmail: string
},
ingressPorts: SimplePortDefinition[]
allowedCidrs?: {
ipv4: string[]
ipv6: string[]
}
}

export interface AwsPulumiOutput {
Expand Down Expand Up @@ -463,6 +469,8 @@ export class AwsPulumiClient extends InstancePulumiClient<PulumiStackConfigAws,
if(config.instanceServerState) await stack.setConfig("instanceServerState", { value: config.instanceServerState})
if(config.dataDisk) await stack.setConfig("dataDisk", { value: JSON.stringify(config.dataDisk)})

if(config.allowedCidrs) await stack.setConfig("allowedCidrs", { value: JSON.stringify(config.allowedCidrs) })

if(config.billingAlert){
await stack.setConfig("billingAlertEnabled", { value: "true"})
await stack.setConfig("billingAlertLimit", { value: config.billingAlert.limit.toString()})
Expand Down
4 changes: 4 additions & 0 deletions src/providers/aws/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ const AwsProvisionInputV1Schema = CommonProvisionInputV1Schema.extend({
region: z.string().describe("AWS region"),
zone: z.string().optional().describe("AWS availability zone"),
useSpot: z.boolean().describe("Whether to use spot instances"),
allowedCidrs: z.object({
ipv4: z.array(z.string()),
ipv6: z.array(z.string()),
}).default({ ipv4: ['0.0.0.0/0'], ipv6: ['::/0'] }).describe("Allowed CIDRs for security group ingress. Defaults to open access; set to specific ranges to restrict inbound traffic to those IPs, refreshed on every provision."),
costAlert: z.object({
limit: z.number().describe("Cost alert limit (USD)"),
notificationEmail: z.string().describe("Cost alert notification email"),
Expand Down
42 changes: 42 additions & 0 deletions src/tools/ip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as https from 'https'

/**
* Fetch the current external IP address for the given address family.
*
* Returns undefined if the request fails or times out (e.g. no IPv6 connectivity).
* Uses the AWS checkip endpoint which is reliable and provider-agnostic.
*/
export function fetchCurrentIp(family: 4 | 6, timeoutMs = 5000): Promise<string | undefined> {
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`] : [],
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions test/unit/providers/aws/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,8 +70,8 @@ describe('AWS input prompter', () => {
const expected: PartialDeep<AwsInstanceInput> = {
...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,
Expand Down