@infoxchange/make-it-so 2.19.2 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/cdk-constructs/IxTroubleshootingBastion.d.ts +50 -0
- package/dist/cdk-constructs/IxTroubleshootingBastion.d.ts.map +1 -0
- package/dist/cdk-constructs/IxTroubleshootingBastion.js +118 -0
- package/dist/cdk-constructs/index.d.ts +1 -0
- package/dist/cdk-constructs/index.d.ts.map +1 -1
- package/dist/cdk-constructs/index.js +1 -0
- package/dist/deployConfig.d.ts.map +1 -1
- package/dist/deployConfig.js +16 -12
- package/dist/lib/utils/strings.d.ts +7 -0
- package/dist/lib/utils/strings.d.ts.map +1 -0
- package/dist/lib/utils/strings.js +18 -0
- package/package.json +1 -1
- package/src/cdk-constructs/IxTroubleshootingBastion.ts +185 -0
- package/src/cdk-constructs/index.ts +1 -0
- package/src/deployConfig.ts +24 -12
- package/src/lib/utils/strings.ts +19 -0
package/README.md
CHANGED
|
@@ -421,6 +421,88 @@ new IxWebsiteRedirect(scope, "WebsiteRedirect", {
|
|
|
421
421
|
|
|
422
422
|
</details>
|
|
423
423
|
|
|
424
|
+
<details>
|
|
425
|
+
<summary><strong>IxTroubleshootingBastion</strong> - Deploys a temporary EC2 host for debugging things only reachable from inside the VPC.</summary>
|
|
426
|
+
|
|
427
|
+
Sometimes you need to poke at something that only exists inside the VPC — an RDS instance, an Elasticache cluster, an internal API. This deploys a small host in the standard IX VPC that you can SSH into and use as a jump box.
|
|
428
|
+
|
|
429
|
+
It is built on [CDK's BastionHostLinux](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.BastionHostLinux.html) and accepts all the same props, with these differences:
|
|
430
|
+
|
|
431
|
+
- It always attaches to the standard IX VPC and its private subnets (via `IxVpcDetails`), so there is no `vpc` prop.
|
|
432
|
+
- It is set up for SSH rather than SSM Session Manager. EC2 generates a key pair and CloudFormation stores the private key in Parameter Store, so no key material passes through the CloudFormation template, an env var, or your shell history.
|
|
433
|
+
- It defaults to the smallest instance type available, `t4g.nano`.
|
|
434
|
+
- It tags itself with `CreatorResource=IxTroubleshootingBastion` so you can find it without knowing the stack or instance name.
|
|
435
|
+
|
|
436
|
+
```typescript
|
|
437
|
+
import { IxTroubleshootingBastion } from "@infoxchange/make-it-so/cdk-constructs";
|
|
438
|
+
|
|
439
|
+
// Deploy it behind an env var so it isn't part of a normal deploy
|
|
440
|
+
if (process.env.TROUBLESHOOTING_BASTION) {
|
|
441
|
+
new IxTroubleshootingBastion(stack, "TroubleshootingBastion", {
|
|
442
|
+
packages: ["postgresql16"],
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
> [!WARNING]
|
|
448
|
+
> This is a debugging tool, not something to leave running. It should be reasonably secure as it's private IP means it's
|
|
449
|
+
> only connectable over the VPN and it can only be connected to with the SSH key. But still better not to tempt fate so
|
|
450
|
+
> remove it once you are done.
|
|
451
|
+
|
|
452
|
+
#### Connecting:
|
|
453
|
+
|
|
454
|
+
On a CI deploy the instructions below are emitted as a workflow annotation with the values already filled in. Otherwise,
|
|
455
|
+
find the host and download it's key pair:
|
|
456
|
+
|
|
457
|
+
```shell
|
|
458
|
+
ix aws auth -A app:$APP_NAME/$APP_ENV -- aws ec2 describe-instances \
|
|
459
|
+
--filters Name=tag:CreatorResource,Values=IxTroubleshootingBastion \
|
|
460
|
+
Name=instance-state-name,Values=running \
|
|
461
|
+
--query "Reservations[].Instances[].[PrivateIpAddress,KeyName]" --output text
|
|
462
|
+
|
|
463
|
+
ix aws auth -A app:$APP_NAME/$APP_ENV -- aws ec2 describe-key-pairs --key-names <key name> \
|
|
464
|
+
--query "KeyPairs[].KeyPairId" --output text
|
|
465
|
+
|
|
466
|
+
ix aws auth -A app:$APP_NAME/$APP_ENV -- aws ssm get-parameter --name /ec2/keypair/<key pair id> --with-decryption \
|
|
467
|
+
--query Parameter.Value --output text > /tmp/troubleshooting-bastion.pem
|
|
468
|
+
|
|
469
|
+
chmod 400 /tmp/troubleshooting-bastion.pem # SSH will not accept the key otherwise
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
If you are connected to the VPN you should be able to SSH to the bastion host with:
|
|
473
|
+
|
|
474
|
+
```shell
|
|
475
|
+
ssh -i /tmp/troubleshooting-bastion.pem ec2-user@<private ip>
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
To reach a service through the bastion rather than from it, use a local port forward. For example to get at Postgres on RDS from your machine:
|
|
479
|
+
|
|
480
|
+
```shell
|
|
481
|
+
ssh -N -i /tmp/troubleshooting-bastion.pem \
|
|
482
|
+
-L 5433:<rds endpoint>:5432 \
|
|
483
|
+
ec2-user@<private ip>
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
Note that whatever you are connecting to needs a security group that accepts traffic from the bastion, either by allowing the private subnet CIDRs or by having the bastion's `securityGroup` added to it.
|
|
487
|
+
|
|
488
|
+
#### Options:
|
|
489
|
+
|
|
490
|
+
| Prop | Type | Description |
|
|
491
|
+
| -------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
492
|
+
| allowSshFrom | string[] | (optional) CIDRs allowed to reach the host on port 22. Defaults to `["0.0.0.0/0"]` |
|
|
493
|
+
| packages | string[] | (optional) Extra packages to `dnf install` at boot, eg a database client to poke at RDS with |
|
|
494
|
+
| [...BastionHostLinuxProps] | | Any props accepted by [BastionHostLinux](https://docs.aws.amazon.com/cdk/api/v2/docs/aws-cdk-lib.aws_ec2.BastionHostLinux.html#construct-props) other than `vpc` |
|
|
495
|
+
|
|
496
|
+
#### Properties:
|
|
497
|
+
|
|
498
|
+
| Properties | Type | Description |
|
|
499
|
+
| ------------- | ---------------- | -------------------------------------------------------------------------------------- |
|
|
500
|
+
| bastion | BastionHostLinux | The underlying AWS CDK BastionHostLinux instance |
|
|
501
|
+
| keyPair | KeyPair | The generated EC2 key pair. Its `keyPairId` gives the Parameter Store path for the key |
|
|
502
|
+
| securityGroup | ISecurityGroup | The host's security group, to add to the security groups of whatever it needs to reach |
|
|
503
|
+
|
|
504
|
+
</details>
|
|
505
|
+
|
|
424
506
|
<details>
|
|
425
507
|
<summary><strong>IxVpcDetails</strong> - Fetches the standard VPC and subnets that exist in all IX workload aws accounts.</summary>
|
|
426
508
|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { BastionHostLinux, ISecurityGroup, KeyPair } from "aws-cdk-lib/aws-ec2";
|
|
2
|
+
import { Construct } from "constructs";
|
|
3
|
+
type ConstructScope = ConstructorParameters<typeof Construct>[0];
|
|
4
|
+
type ConstructId = ConstructorParameters<typeof Construct>[1];
|
|
5
|
+
type BastionHostProps = ConstructorParameters<typeof BastionHostLinux>[2];
|
|
6
|
+
type Props = Omit<BastionHostProps, "vpc"> & {
|
|
7
|
+
/**
|
|
8
|
+
* CIDRs allowed to reach the bastion on port 22. Defaults to anywhere, which is only reasonable because the bastion
|
|
9
|
+
* has no public IP — the subnet is private, so reaching it still means coming in over the VPN.
|
|
10
|
+
*/
|
|
11
|
+
allowSshFrom?: string[];
|
|
12
|
+
/** Extra packages to `dnf install` at boot, eg a database client to poke at RDS with. */
|
|
13
|
+
packages?: string[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* A short-lived EC2 host for poking at things that are only reachable from inside the VPC (an RDS instance, a redis
|
|
17
|
+
* cluster, an internal API).
|
|
18
|
+
*
|
|
19
|
+
* Unlike a plain BastionHostLinux this is set up for SSH rather than SSM Session Manager. EC2 generates the key pair
|
|
20
|
+
* and CloudFormation stores the private key in Parameter Store, so no key material passes through the template or an
|
|
21
|
+
* env var. On a CI deploy the instructions for connecting are emitted as a workflow annotation. Otherwise the host
|
|
22
|
+
* tags itself so you can find it, and its key, without knowing the stack or instance name:
|
|
23
|
+
*
|
|
24
|
+
* aws ec2 describe-instances \
|
|
25
|
+
* --filters Name=tag:CreatorResource,Values=IxTroubleshootingBastion \
|
|
26
|
+
* Name=instance-state-name,Values=running \
|
|
27
|
+
* --query "Reservations[].Instances[].[PrivateIpAddress,KeyName]" --output text
|
|
28
|
+
* aws ec2 describe-key-pairs --key-names <key name> --query "KeyPairs[].KeyPairId" --output text
|
|
29
|
+
* aws ssm get-parameter --name /ec2/keypair/<key pair id> --with-decryption \
|
|
30
|
+
* --query Parameter.Value --output text > /tmp/troubleshooting-bastion.pem
|
|
31
|
+
* chmod 400 /tmp/troubleshooting-bastion.pem
|
|
32
|
+
* ssh -i /tmp/troubleshooting-bastion.pem ec2-user@<private ip>
|
|
33
|
+
*
|
|
34
|
+
* CloudFormation deletes that parameter along with the key pair, so nothing is left behind — but it needs
|
|
35
|
+
* `ssm:PutParameter` and `ssm:DeleteParameter` on the deploying role to create and remove the stack. The delete check
|
|
36
|
+
* runs against a fabricated parameter name, so a missing permission shows up as an AccessDeniedException naming a
|
|
37
|
+
* parameter that doesn't exist.
|
|
38
|
+
*
|
|
39
|
+
* This is a debugging tool, not something to leave running: it is a host in the private subnets accepting SSH from
|
|
40
|
+
* anywhere by default, so remove it once you are done.
|
|
41
|
+
*/
|
|
42
|
+
export declare class IxTroubleshootingBastion extends Construct {
|
|
43
|
+
bastion: BastionHostLinux;
|
|
44
|
+
keyPair: KeyPair;
|
|
45
|
+
securityGroup: ISecurityGroup;
|
|
46
|
+
constructor(scope: ConstructScope, id: ConstructId, { allowSshFrom, securityGroup, packages, ...bastionProps }?: Props);
|
|
47
|
+
private installPackages;
|
|
48
|
+
}
|
|
49
|
+
export {};
|
|
50
|
+
//# sourceMappingURL=IxTroubleshootingBastion.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"IxTroubleshootingBastion.d.ts","sourceRoot":"","sources":["../../src/cdk-constructs/IxTroubleshootingBastion.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,gBAAgB,EAKhB,cAAc,EACd,OAAO,EAOR,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAKvC,KAAK,cAAc,GAAG,qBAAqB,CAAC,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AACjE,KAAK,WAAW,GAAG,qBAAqB,CAAC,OAAO,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC;AAC9D,KAAK,gBAAgB,GAAG,qBAAqB,CAAC,OAAO,gBAAgB,CAAC,CAAC,CAAC,CAAC,CAAC;AAE1E,KAAK,KAAK,GAAG,IAAI,CAAC,gBAAgB,EAAE,KAAK,CAAC,GAAG;IAC3C;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,yFAAyF;IACzF,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC;CACrB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBAAa,wBAAyB,SAAQ,SAAS;IAC9C,OAAO,EAAE,gBAAgB,CAAC;IAE1B,OAAO,EAAE,OAAO,CAAC;IAEjB,aAAa,EAAE,cAAc,CAAC;gBAGnC,KAAK,EAAE,cAAc,EACrB,EAAE,EAAE,WAAW,EACf,EACE,YAA4B,EAC5B,aAAa,EACb,QAAa,EACb,GAAG,YAAY,EAChB,GAAE,KAAU;IAwFf,OAAO,CAAC,eAAe;CAkBxB"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { CfnOutput, Tags } from "aws-cdk-lib";
|
|
2
|
+
import { AmazonLinuxCpuType, BastionHostLinux, InstanceArchitecture, InstanceClass, InstanceSize, InstanceType, KeyPair, KeyPairType, MachineImage, Peer, Port, SecurityGroup, SubnetType, } from "aws-cdk-lib/aws-ec2";
|
|
3
|
+
import { Construct } from "constructs";
|
|
4
|
+
import { IxVpcDetails } from "./IxVpcDetails.js";
|
|
5
|
+
import deployConfig from "../deployConfig.js";
|
|
6
|
+
import { deindent } from "../lib/utils/strings.js";
|
|
7
|
+
/**
|
|
8
|
+
* A short-lived EC2 host for poking at things that are only reachable from inside the VPC (an RDS instance, a redis
|
|
9
|
+
* cluster, an internal API).
|
|
10
|
+
*
|
|
11
|
+
* Unlike a plain BastionHostLinux this is set up for SSH rather than SSM Session Manager. EC2 generates the key pair
|
|
12
|
+
* and CloudFormation stores the private key in Parameter Store, so no key material passes through the template or an
|
|
13
|
+
* env var. On a CI deploy the instructions for connecting are emitted as a workflow annotation. Otherwise the host
|
|
14
|
+
* tags itself so you can find it, and its key, without knowing the stack or instance name:
|
|
15
|
+
*
|
|
16
|
+
* aws ec2 describe-instances \
|
|
17
|
+
* --filters Name=tag:CreatorResource,Values=IxTroubleshootingBastion \
|
|
18
|
+
* Name=instance-state-name,Values=running \
|
|
19
|
+
* --query "Reservations[].Instances[].[PrivateIpAddress,KeyName]" --output text
|
|
20
|
+
* aws ec2 describe-key-pairs --key-names <key name> --query "KeyPairs[].KeyPairId" --output text
|
|
21
|
+
* aws ssm get-parameter --name /ec2/keypair/<key pair id> --with-decryption \
|
|
22
|
+
* --query Parameter.Value --output text > /tmp/troubleshooting-bastion.pem
|
|
23
|
+
* chmod 400 /tmp/troubleshooting-bastion.pem
|
|
24
|
+
* ssh -i /tmp/troubleshooting-bastion.pem ec2-user@<private ip>
|
|
25
|
+
*
|
|
26
|
+
* CloudFormation deletes that parameter along with the key pair, so nothing is left behind — but it needs
|
|
27
|
+
* `ssm:PutParameter` and `ssm:DeleteParameter` on the deploying role to create and remove the stack. The delete check
|
|
28
|
+
* runs against a fabricated parameter name, so a missing permission shows up as an AccessDeniedException naming a
|
|
29
|
+
* parameter that doesn't exist.
|
|
30
|
+
*
|
|
31
|
+
* This is a debugging tool, not something to leave running: it is a host in the private subnets accepting SSH from
|
|
32
|
+
* anywhere by default, so remove it once you are done.
|
|
33
|
+
*/
|
|
34
|
+
export class IxTroubleshootingBastion extends Construct {
|
|
35
|
+
bastion;
|
|
36
|
+
keyPair;
|
|
37
|
+
securityGroup;
|
|
38
|
+
constructor(scope, id, { allowSshFrom = ["0.0.0.0/0"], securityGroup, packages = [], ...bastionProps } = {}) {
|
|
39
|
+
super(scope, id);
|
|
40
|
+
const { vpc } = new IxVpcDetails(this, `${id}-IxVpcDetails`);
|
|
41
|
+
this.securityGroup =
|
|
42
|
+
securityGroup ??
|
|
43
|
+
new SecurityGroup(this, `${id}-SecurityGroup`, {
|
|
44
|
+
vpc,
|
|
45
|
+
allowAllOutbound: true,
|
|
46
|
+
description: `Security group for ${deployConfig.appName} ${deployConfig.environment} troubleshooting bastion`,
|
|
47
|
+
});
|
|
48
|
+
for (const cidr of allowSshFrom) {
|
|
49
|
+
this.securityGroup.addIngressRule(Peer.ipv4(cidr), Port.tcp(22), `Allow SSH to the troubleshooting bastion from ${cidr}`);
|
|
50
|
+
}
|
|
51
|
+
// Omitting publicKeyMaterial is what makes this a generated key pair, which is what gets the private key written
|
|
52
|
+
// to Parameter Store.
|
|
53
|
+
this.keyPair = new KeyPair(this, `${id}-KeyPair`, {
|
|
54
|
+
type: KeyPairType.ED25519,
|
|
55
|
+
});
|
|
56
|
+
// The smallest instance type available, and the cheapest of the 512MiB ones.
|
|
57
|
+
const instanceType = bastionProps.instanceType ??
|
|
58
|
+
InstanceType.of(InstanceClass.T4G, InstanceSize.NANO);
|
|
59
|
+
this.bastion = new BastionHostLinux(this, `${id}-Bastion`, {
|
|
60
|
+
instanceName: `${deployConfig.appName}-${deployConfig.environment}-${id}`,
|
|
61
|
+
// IxVpcDetails registers its subnets as isolated, so the BastionHostLinux default of PRIVATE_WITH_EGRESS finds
|
|
62
|
+
// no subnets to place the instance in.
|
|
63
|
+
subnetSelection: { subnetType: SubnetType.PRIVATE_ISOLATED },
|
|
64
|
+
instanceType,
|
|
65
|
+
// Pinned rather than left to the `bastionHostUseAmazonLinux2023ByDefault` feature flag so the OS the packages
|
|
66
|
+
// below are installed with is predictable.
|
|
67
|
+
machineImage: MachineImage.latestAmazonLinux2023({
|
|
68
|
+
cpuType: instanceType.architecture === InstanceArchitecture.ARM_64
|
|
69
|
+
? AmazonLinuxCpuType.ARM_64
|
|
70
|
+
: AmazonLinuxCpuType.X86_64,
|
|
71
|
+
}),
|
|
72
|
+
...bastionProps,
|
|
73
|
+
vpc,
|
|
74
|
+
securityGroup: this.securityGroup,
|
|
75
|
+
});
|
|
76
|
+
// BastionHostLinux takes no keyPair/keyName prop of its own (aws-cdk#18565), so this goes on the L1 directly.
|
|
77
|
+
// cloud-init picks the key up from instance metadata and writes it to the default user's authorized_keys, which
|
|
78
|
+
// is why there's no key handling in the user data below.
|
|
79
|
+
this.bastion.instance.instance.keyName = this.keyPair.keyPairName;
|
|
80
|
+
// Lets the host be found with `describe-instances --filters Name=tag:CreatorResource,...` without having to know
|
|
81
|
+
// the stack or instance name. See the connection instructions above.
|
|
82
|
+
Tags.of(this.bastion).add("CreatorResource", "IxTroubleshootingBastion");
|
|
83
|
+
this.installPackages(packages);
|
|
84
|
+
const tmpCertPath = "/tmp/troubleshooting-bastion.pem";
|
|
85
|
+
const message = deindent(`
|
|
86
|
+
A troubleshooting bastion host has been deployed. MAKE SURE TO REMOVE IT AFTER USE.
|
|
87
|
+
|
|
88
|
+
To fetch the private key used to connect to the bastion host run:
|
|
89
|
+
ix aws auth -A app:${deployConfig.appName}/${deployConfig.environment} -- aws ssm get-parameter --name /ec2/keypair/${this.keyPair.keyPairId} --with-decryption --query Parameter.Value --output text > ${tmpCertPath}
|
|
90
|
+
chmod 400 ${tmpCertPath}
|
|
91
|
+
|
|
92
|
+
To connect to the bastion host ensure you're connected to the VPN then run:
|
|
93
|
+
ssh -i ${tmpCertPath} ec2-user@${this.bastion.instancePrivateIp}
|
|
94
|
+
`);
|
|
95
|
+
let outputValue;
|
|
96
|
+
if (process.env.CI) {
|
|
97
|
+
outputValue = `The following note is available as an annotation in the CI workflow run:\n::warning title=Troubleshooting Bastion::${message.replaceAll("\n", "%0A")}`;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
outputValue = message;
|
|
101
|
+
}
|
|
102
|
+
// We can't just log this with console.log() since the CDK tokens aren't resolvable at that point.
|
|
103
|
+
new CfnOutput(this, "TroubleshootingBastionNote", {
|
|
104
|
+
value: outputValue,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
installPackages(packages) {
|
|
108
|
+
if (!packages.length) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const { userData } = this.bastion.instance;
|
|
112
|
+
// The subnet has no direct egress, so reaching the package repos goes via the proxy.
|
|
113
|
+
if (deployConfig.vpcHttpProxy) {
|
|
114
|
+
userData.addCommands(`export http_proxy=${deployConfig.vpcHttpProxy}`, `export https_proxy=${deployConfig.vpcHttpProxy}`, `export no_proxy=169.254.169.254,localhost,127.0.0.1`);
|
|
115
|
+
}
|
|
116
|
+
userData.addCommands(`dnf install -y ${packages.join(" ")}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -8,5 +8,6 @@ export * from "./IxElasticache.js";
|
|
|
8
8
|
export * from "./IxApi.js";
|
|
9
9
|
export * from "./IxQuicksightWorkspace.js";
|
|
10
10
|
export * from "./IxCloudWatchAlarm.js";
|
|
11
|
+
export * from "./IxTroubleshootingBastion.js";
|
|
11
12
|
export * from "./SiteOidcAuth/index.js";
|
|
12
13
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cdk-constructs/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,YAAY,CAAC;AAC3B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,yBAAyB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/cdk-constructs/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,kBAAkB,CAAC;AACjC,cAAc,oBAAoB,CAAC;AACnC,cAAc,mBAAmB,CAAC;AAClC,cAAc,mBAAmB,CAAC;AAClC,cAAc,oBAAoB,CAAC;AACnC,cAAc,YAAY,CAAC;AAC3B,cAAc,4BAA4B,CAAC;AAC3C,cAAc,wBAAwB,CAAC;AACvC,cAAc,+BAA+B,CAAC;AAC9C,cAAc,yBAAyB,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deployConfig.d.ts","sourceRoot":"","sources":["../src/deployConfig.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"deployConfig.d.ts","sourceRoot":"","sources":["../src/deployConfig.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAqGA,wBAA0C;AAG1C,eAAO,MAAM,eAAe;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAAmC,CAAC"}
|
package/dist/deployConfig.js
CHANGED
|
@@ -26,12 +26,14 @@ const ixDeployConfigSchema = z
|
|
|
26
26
|
environment: z.enum(["dev", "test", "uat", "prod"]),
|
|
27
27
|
workloadGroup: z.enum(["ds", "srs"]),
|
|
28
28
|
primaryAwsRegion: z.literal("ap-southeast-2"),
|
|
29
|
-
siteDomains: z
|
|
30
|
-
.
|
|
31
|
-
.
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.
|
|
29
|
+
siteDomains: z.string().transform((val) => val
|
|
30
|
+
.split(",")
|
|
31
|
+
.map((domain) => domain.trim())
|
|
32
|
+
.filter(Boolean)),
|
|
33
|
+
siteDomainAliases: z.string().transform((val) => val
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((domain) => domain.trim())
|
|
36
|
+
.filter(Boolean)),
|
|
35
37
|
isInternalApp: z.coerce.boolean(),
|
|
36
38
|
deploymentType: z.enum(["docker", "serverless"]),
|
|
37
39
|
sourceCommitRef: z.string().min(1),
|
|
@@ -52,12 +54,14 @@ const nonIxDeployConfigSchema = z
|
|
|
52
54
|
environment: z.string(),
|
|
53
55
|
workloadGroup: z.string(),
|
|
54
56
|
primaryAwsRegion: z.string(),
|
|
55
|
-
siteDomains: z
|
|
56
|
-
.
|
|
57
|
-
.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.
|
|
57
|
+
siteDomains: z.string().transform((val) => val
|
|
58
|
+
.split(",")
|
|
59
|
+
.map((domain) => domain.trim())
|
|
60
|
+
.filter(Boolean)),
|
|
61
|
+
siteDomainAliases: z.string().transform((val) => val
|
|
62
|
+
.split(",")
|
|
63
|
+
.map((domain) => domain.trim())
|
|
64
|
+
.filter(Boolean)),
|
|
61
65
|
isInternalApp: z
|
|
62
66
|
.string()
|
|
63
67
|
.transform((val) => (val ? val.toLowerCase() === "true" : undefined)),
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips the shared leading whitespace off every line of a template literal, so a multi-line string can be indented
|
|
3
|
+
* to match the code it sits in without that indentation ending up in the output. Blank lines are emptied rather than
|
|
4
|
+
* kept as whitespace, and leading/trailing blank lines are dropped.
|
|
5
|
+
*/
|
|
6
|
+
export declare function deindent(str: string): string;
|
|
7
|
+
//# sourceMappingURL=strings.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"strings.d.ts","sourceRoot":"","sources":["../../../src/lib/utils/strings.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAa5C"}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips the shared leading whitespace off every line of a template literal, so a multi-line string can be indented
|
|
3
|
+
* to match the code it sits in without that indentation ending up in the output. Blank lines are emptied rather than
|
|
4
|
+
* kept as whitespace, and leading/trailing blank lines are dropped.
|
|
5
|
+
*/
|
|
6
|
+
export function deindent(str) {
|
|
7
|
+
const lines = str.split("\n");
|
|
8
|
+
const minIndent = lines
|
|
9
|
+
.filter((line) => line.trim().length > 0)
|
|
10
|
+
.reduce((min, line) => {
|
|
11
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
12
|
+
return Math.min(min, indent);
|
|
13
|
+
}, Infinity);
|
|
14
|
+
return lines
|
|
15
|
+
.map((line) => (line.trim().length === 0 ? "" : line.slice(minIndent)))
|
|
16
|
+
.join("\n")
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { CfnOutput, Tags } from "aws-cdk-lib";
|
|
2
|
+
import {
|
|
3
|
+
AmazonLinuxCpuType,
|
|
4
|
+
BastionHostLinux,
|
|
5
|
+
InstanceArchitecture,
|
|
6
|
+
InstanceClass,
|
|
7
|
+
InstanceSize,
|
|
8
|
+
InstanceType,
|
|
9
|
+
ISecurityGroup,
|
|
10
|
+
KeyPair,
|
|
11
|
+
KeyPairType,
|
|
12
|
+
MachineImage,
|
|
13
|
+
Peer,
|
|
14
|
+
Port,
|
|
15
|
+
SecurityGroup,
|
|
16
|
+
SubnetType,
|
|
17
|
+
} from "aws-cdk-lib/aws-ec2";
|
|
18
|
+
import { Construct } from "constructs";
|
|
19
|
+
import { IxVpcDetails } from "./IxVpcDetails.js";
|
|
20
|
+
import deployConfig from "../deployConfig.js";
|
|
21
|
+
import { deindent } from "../lib/utils/strings.js";
|
|
22
|
+
|
|
23
|
+
type ConstructScope = ConstructorParameters<typeof Construct>[0];
|
|
24
|
+
type ConstructId = ConstructorParameters<typeof Construct>[1];
|
|
25
|
+
type BastionHostProps = ConstructorParameters<typeof BastionHostLinux>[2];
|
|
26
|
+
|
|
27
|
+
type Props = Omit<BastionHostProps, "vpc"> & {
|
|
28
|
+
/**
|
|
29
|
+
* CIDRs allowed to reach the bastion on port 22. Defaults to anywhere, which is only reasonable because the bastion
|
|
30
|
+
* has no public IP — the subnet is private, so reaching it still means coming in over the VPN.
|
|
31
|
+
*/
|
|
32
|
+
allowSshFrom?: string[];
|
|
33
|
+
/** Extra packages to `dnf install` at boot, eg a database client to poke at RDS with. */
|
|
34
|
+
packages?: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A short-lived EC2 host for poking at things that are only reachable from inside the VPC (an RDS instance, a redis
|
|
39
|
+
* cluster, an internal API).
|
|
40
|
+
*
|
|
41
|
+
* Unlike a plain BastionHostLinux this is set up for SSH rather than SSM Session Manager. EC2 generates the key pair
|
|
42
|
+
* and CloudFormation stores the private key in Parameter Store, so no key material passes through the template or an
|
|
43
|
+
* env var. On a CI deploy the instructions for connecting are emitted as a workflow annotation. Otherwise the host
|
|
44
|
+
* tags itself so you can find it, and its key, without knowing the stack or instance name:
|
|
45
|
+
*
|
|
46
|
+
* aws ec2 describe-instances \
|
|
47
|
+
* --filters Name=tag:CreatorResource,Values=IxTroubleshootingBastion \
|
|
48
|
+
* Name=instance-state-name,Values=running \
|
|
49
|
+
* --query "Reservations[].Instances[].[PrivateIpAddress,KeyName]" --output text
|
|
50
|
+
* aws ec2 describe-key-pairs --key-names <key name> --query "KeyPairs[].KeyPairId" --output text
|
|
51
|
+
* aws ssm get-parameter --name /ec2/keypair/<key pair id> --with-decryption \
|
|
52
|
+
* --query Parameter.Value --output text > /tmp/troubleshooting-bastion.pem
|
|
53
|
+
* chmod 400 /tmp/troubleshooting-bastion.pem
|
|
54
|
+
* ssh -i /tmp/troubleshooting-bastion.pem ec2-user@<private ip>
|
|
55
|
+
*
|
|
56
|
+
* CloudFormation deletes that parameter along with the key pair, so nothing is left behind — but it needs
|
|
57
|
+
* `ssm:PutParameter` and `ssm:DeleteParameter` on the deploying role to create and remove the stack. The delete check
|
|
58
|
+
* runs against a fabricated parameter name, so a missing permission shows up as an AccessDeniedException naming a
|
|
59
|
+
* parameter that doesn't exist.
|
|
60
|
+
*
|
|
61
|
+
* This is a debugging tool, not something to leave running: it is a host in the private subnets accepting SSH from
|
|
62
|
+
* anywhere by default, so remove it once you are done.
|
|
63
|
+
*/
|
|
64
|
+
export class IxTroubleshootingBastion extends Construct {
|
|
65
|
+
public bastion: BastionHostLinux;
|
|
66
|
+
|
|
67
|
+
public keyPair: KeyPair;
|
|
68
|
+
|
|
69
|
+
public securityGroup: ISecurityGroup;
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
scope: ConstructScope,
|
|
73
|
+
id: ConstructId,
|
|
74
|
+
{
|
|
75
|
+
allowSshFrom = ["0.0.0.0/0"],
|
|
76
|
+
securityGroup,
|
|
77
|
+
packages = [],
|
|
78
|
+
...bastionProps
|
|
79
|
+
}: Props = {},
|
|
80
|
+
) {
|
|
81
|
+
super(scope, id);
|
|
82
|
+
|
|
83
|
+
const { vpc } = new IxVpcDetails(this, `${id}-IxVpcDetails`);
|
|
84
|
+
|
|
85
|
+
this.securityGroup =
|
|
86
|
+
securityGroup ??
|
|
87
|
+
new SecurityGroup(this, `${id}-SecurityGroup`, {
|
|
88
|
+
vpc,
|
|
89
|
+
allowAllOutbound: true,
|
|
90
|
+
description: `Security group for ${deployConfig.appName} ${deployConfig.environment} troubleshooting bastion`,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
for (const cidr of allowSshFrom) {
|
|
94
|
+
this.securityGroup.addIngressRule(
|
|
95
|
+
Peer.ipv4(cidr),
|
|
96
|
+
Port.tcp(22),
|
|
97
|
+
`Allow SSH to the troubleshooting bastion from ${cidr}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Omitting publicKeyMaterial is what makes this a generated key pair, which is what gets the private key written
|
|
102
|
+
// to Parameter Store.
|
|
103
|
+
this.keyPair = new KeyPair(this, `${id}-KeyPair`, {
|
|
104
|
+
type: KeyPairType.ED25519,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The smallest instance type available, and the cheapest of the 512MiB ones.
|
|
108
|
+
const instanceType =
|
|
109
|
+
bastionProps.instanceType ??
|
|
110
|
+
InstanceType.of(InstanceClass.T4G, InstanceSize.NANO);
|
|
111
|
+
|
|
112
|
+
this.bastion = new BastionHostLinux(this, `${id}-Bastion`, {
|
|
113
|
+
instanceName: `${deployConfig.appName}-${deployConfig.environment}-${id}`,
|
|
114
|
+
// IxVpcDetails registers its subnets as isolated, so the BastionHostLinux default of PRIVATE_WITH_EGRESS finds
|
|
115
|
+
// no subnets to place the instance in.
|
|
116
|
+
subnetSelection: { subnetType: SubnetType.PRIVATE_ISOLATED },
|
|
117
|
+
instanceType,
|
|
118
|
+
// Pinned rather than left to the `bastionHostUseAmazonLinux2023ByDefault` feature flag so the OS the packages
|
|
119
|
+
// below are installed with is predictable.
|
|
120
|
+
machineImage: MachineImage.latestAmazonLinux2023({
|
|
121
|
+
cpuType:
|
|
122
|
+
instanceType.architecture === InstanceArchitecture.ARM_64
|
|
123
|
+
? AmazonLinuxCpuType.ARM_64
|
|
124
|
+
: AmazonLinuxCpuType.X86_64,
|
|
125
|
+
}),
|
|
126
|
+
...bastionProps,
|
|
127
|
+
vpc,
|
|
128
|
+
securityGroup: this.securityGroup,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// BastionHostLinux takes no keyPair/keyName prop of its own (aws-cdk#18565), so this goes on the L1 directly.
|
|
132
|
+
// cloud-init picks the key up from instance metadata and writes it to the default user's authorized_keys, which
|
|
133
|
+
// is why there's no key handling in the user data below.
|
|
134
|
+
this.bastion.instance.instance.keyName = this.keyPair.keyPairName;
|
|
135
|
+
|
|
136
|
+
// Lets the host be found with `describe-instances --filters Name=tag:CreatorResource,...` without having to know
|
|
137
|
+
// the stack or instance name. See the connection instructions above.
|
|
138
|
+
Tags.of(this.bastion).add("CreatorResource", "IxTroubleshootingBastion");
|
|
139
|
+
|
|
140
|
+
this.installPackages(packages);
|
|
141
|
+
|
|
142
|
+
const tmpCertPath = "/tmp/troubleshooting-bastion.pem";
|
|
143
|
+
const message = deindent(`
|
|
144
|
+
A troubleshooting bastion host has been deployed. MAKE SURE TO REMOVE IT AFTER USE.
|
|
145
|
+
|
|
146
|
+
To fetch the private key used to connect to the bastion host run:
|
|
147
|
+
ix aws auth -A app:${deployConfig.appName}/${deployConfig.environment} -- aws ssm get-parameter --name /ec2/keypair/${this.keyPair.keyPairId} --with-decryption --query Parameter.Value --output text > ${tmpCertPath}
|
|
148
|
+
chmod 400 ${tmpCertPath}
|
|
149
|
+
|
|
150
|
+
To connect to the bastion host ensure you're connected to the VPN then run:
|
|
151
|
+
ssh -i ${tmpCertPath} ec2-user@${this.bastion.instancePrivateIp}
|
|
152
|
+
`);
|
|
153
|
+
|
|
154
|
+
let outputValue;
|
|
155
|
+
if (process.env.CI) {
|
|
156
|
+
outputValue = `The following note is available as an annotation in the CI workflow run:\n::warning title=Troubleshooting Bastion::${message.replaceAll("\n", "%0A")}`;
|
|
157
|
+
} else {
|
|
158
|
+
outputValue = message;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// We can't just log this with console.log() since the CDK tokens aren't resolvable at that point.
|
|
162
|
+
new CfnOutput(this, "TroubleshootingBastionNote", {
|
|
163
|
+
value: outputValue,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
private installPackages(packages: string[]) {
|
|
168
|
+
if (!packages.length) {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const { userData } = this.bastion.instance;
|
|
173
|
+
|
|
174
|
+
// The subnet has no direct egress, so reaching the package repos goes via the proxy.
|
|
175
|
+
if (deployConfig.vpcHttpProxy) {
|
|
176
|
+
userData.addCommands(
|
|
177
|
+
`export http_proxy=${deployConfig.vpcHttpProxy}`,
|
|
178
|
+
`export https_proxy=${deployConfig.vpcHttpProxy}`,
|
|
179
|
+
`export no_proxy=169.254.169.254,localhost,127.0.0.1`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
userData.addCommands(`dnf install -y ${packages.join(" ")}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
package/src/deployConfig.ts
CHANGED
|
@@ -29,12 +29,18 @@ const ixDeployConfigSchema = z
|
|
|
29
29
|
environment: z.enum(["dev", "test", "uat", "prod"]),
|
|
30
30
|
workloadGroup: z.enum(["ds", "srs"]),
|
|
31
31
|
primaryAwsRegion: z.literal("ap-southeast-2"),
|
|
32
|
-
siteDomains: z
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
32
|
+
siteDomains: z.string().transform((val) =>
|
|
33
|
+
val
|
|
34
|
+
.split(",")
|
|
35
|
+
.map((domain) => domain.trim())
|
|
36
|
+
.filter(Boolean),
|
|
37
|
+
),
|
|
38
|
+
siteDomainAliases: z.string().transform((val) =>
|
|
39
|
+
val
|
|
40
|
+
.split(",")
|
|
41
|
+
.map((domain) => domain.trim())
|
|
42
|
+
.filter(Boolean),
|
|
43
|
+
),
|
|
38
44
|
isInternalApp: z.coerce.boolean(),
|
|
39
45
|
deploymentType: z.enum(["docker", "serverless"]),
|
|
40
46
|
sourceCommitRef: z.string().min(1),
|
|
@@ -56,12 +62,18 @@ const nonIxDeployConfigSchema = z
|
|
|
56
62
|
environment: z.string(),
|
|
57
63
|
workloadGroup: z.string(),
|
|
58
64
|
primaryAwsRegion: z.string(),
|
|
59
|
-
siteDomains: z
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
siteDomains: z.string().transform((val) =>
|
|
66
|
+
val
|
|
67
|
+
.split(",")
|
|
68
|
+
.map((domain) => domain.trim())
|
|
69
|
+
.filter(Boolean),
|
|
70
|
+
),
|
|
71
|
+
siteDomainAliases: z.string().transform((val) =>
|
|
72
|
+
val
|
|
73
|
+
.split(",")
|
|
74
|
+
.map((domain) => domain.trim())
|
|
75
|
+
.filter(Boolean),
|
|
76
|
+
),
|
|
65
77
|
isInternalApp: z
|
|
66
78
|
.string()
|
|
67
79
|
.transform((val) => (val ? val.toLowerCase() === "true" : undefined)),
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strips the shared leading whitespace off every line of a template literal, so a multi-line string can be indented
|
|
3
|
+
* to match the code it sits in without that indentation ending up in the output. Blank lines are emptied rather than
|
|
4
|
+
* kept as whitespace, and leading/trailing blank lines are dropped.
|
|
5
|
+
*/
|
|
6
|
+
export function deindent(str: string): string {
|
|
7
|
+
const lines = str.split("\n");
|
|
8
|
+
const minIndent = lines
|
|
9
|
+
.filter((line) => line.trim().length > 0)
|
|
10
|
+
.reduce((min, line) => {
|
|
11
|
+
const indent = line.match(/^\s*/)?.[0].length ?? 0;
|
|
12
|
+
return Math.min(min, indent);
|
|
13
|
+
}, Infinity);
|
|
14
|
+
|
|
15
|
+
return lines
|
|
16
|
+
.map((line) => (line.trim().length === 0 ? "" : line.slice(minIndent)))
|
|
17
|
+
.join("\n")
|
|
18
|
+
.trim();
|
|
19
|
+
}
|