diff --git a/README.md b/README.md index c170a4c..250ff8b 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,9 @@ The goal of the project is to develop Custom CDK components in Java that use AWS Login to AWS Console and open a [Cloud Shell](https://aws.amazon.com/cloudshell/). -### Clone Repo and Install Maven/Java +### Clone Repo ```bash -sudo yum -y install maven git clone https://github.com/docwho2/java-chime-voice-sdk-cdk.git cd java-chime-voice-sdk-cdk diff --git a/config.sh b/config.sh index dba66da..739bf2e 100644 --- a/config.sh +++ b/config.sh @@ -1,8 +1,30 @@ # Common stuff between deploy and destroy scripts -# Stack name for the SMA general deployment -STACK_NAME=chime-sdk-cdk-provisioning +# Stack name +STACK_NAME=chime-voice-cdk-provision # Regions we will deploy to (the only supported US regions for Chime PSTN SDK) declare -a regions=( us-east-1 us-west-2) + + +# Special Vars you can enable that trigger creating more resources in the stacks +# You can use "export VOICE_CONNECTOR=TRUE" for example in shell before running deploy instead of changing here + +# Will setup a Voice Connector so you can place SIP calls into SMA +# VOICE_CONNECTOR=TRUE + +# Will add this IP address to Voice Connector termination allow list so you can place SIP calls into the SMA (You can always update in Console later as well) +# VOICE_CONNECTOR_ALLOW_IP=162.216.219.185 + +# If you have an existing phone number in Chime (in unassigned state) create SIP rule pointing number to SMA +# CHIME_PHONE_NUMBER + +# If you have an Asterisk for example, create VC, and allow termination and origination to this IP (Implies VOICE_CONNECTOR=true) +# PBX_HOSTNAME=54.0.0.1 + + +# Provision a phone number as part of the stack in the given area code (experimental and not recommended) +# It can take 15 mins to get a number in success state, recommend getting number first, then set CHIME_PHONE_NUMBER +# CHIME_AREA_CODE=612 + diff --git a/deploy.sh b/deploy.sh index 7c99637..9e0b02a 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,5 +1,11 @@ #!/bin/bash +# Exit immediately if a command exits with a non-zero status. +set -e + +# Stack names and regions +source config.sh + ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text) if [ "$AWS_EXECUTION_ENV" = "CloudShell" ]; then diff --git a/src/main/java/cloud/cleo/chimesma/cdk/ChimePhoneNumberStack.java b/src/main/java/cloud/cleo/chimesma/cdk/ChimePhoneNumberStack.java index 218811b..0530810 100644 --- a/src/main/java/cloud/cleo/chimesma/cdk/ChimePhoneNumberStack.java +++ b/src/main/java/cloud/cleo/chimesma/cdk/ChimePhoneNumberStack.java @@ -14,16 +14,17 @@ import software.amazon.awscdk.StackProps; import software.amazon.awscdk.services.ssm.StringParameter; import software.amazon.awscdk.services.ssm.StringParameterProps; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.ENV_VARS.*; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.getEnv; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.hasEnv; /** - * Stack to provision and Chime Phone number and create SIP rule pointing to it + * Stack to provision a Chime Phone number and create SIP rule pointing to it * * @author sjensen */ public class ChimePhoneNumberStack extends Stack { - private final static String CHIME_AREA_CODE = System.getenv("CHIME_AREA_CODE"); - public ChimePhoneNumberStack(final App parent, final String id, List smas) { this(parent, id, null, smas); } @@ -31,40 +32,61 @@ public ChimePhoneNumberStack(final App parent, final String id, List smas) { super(parent, id, props); - String phoneNumber; - ChimeWaitForNumber wait = null; - if (CHIME_AREA_CODE.length() < 12) { + // Existing Phone Number already provisioned, just create the Sip Rule + if (hasEnv(CHIME_PHONE_NUMBER)) { + final var phoneNumber = getEnv(CHIME_PHONE_NUMBER); + // Create SIP Rule pointing to the SMA's + new ChimeSipRulePhone(this, phoneNumber, smas); + + new StringParameter(this, "PhoneNumParam", StringParameterProps.builder() + .parameterName("/" + getStackName() + "/CHIME_PHONE_NUMBER") + .description("The Phone Number that was created manually and provided to stack") + .stringValue(phoneNumber) + .build()); + + new CfnOutput(this, "PhoneNumber", CfnOutputProps.builder() + .description("The Phone Number that was created manually and provided to stack") + .value(phoneNumber) + .build()); + + } else if (hasEnv(CHIME_AREA_CODE)) { // Search for a phone Number - final var search = new ChimePhoneNumberSearch(this, CHIME_AREA_CODE); + final var search = new ChimePhoneNumberSearch(this, getEnv(CHIME_AREA_CODE)); // Order the phone Number final var order = new ChimePhoneNumberOrder(this, search.getPhoneNumber()); // Make sure the Phone Number is ready before creating SIP Rule - wait = new ChimeWaitForNumber(this, order.getOrderId()); + final var wait = new ChimeWaitForNumber(this, order.getOrderId()); - phoneNumber = search.getPhoneNumber(); - } else { - phoneNumber = CHIME_AREA_CODE; - } + final var phoneNumber = search.getPhoneNumber(); - // Create SIP Rule pointing to the SMA's - var sr = new ChimeSipRulePhone(this, phoneNumber, smas); - if (wait != null) { + // Create SIP Rule pointing to the SMA's + var sr = new ChimeSipRulePhone(this, phoneNumber, smas); + + // Add the Dependancy ensure rule is not created until number finishes provisioning sr.getNode().addDependency(wait); - } - new StringParameter(this, "PhoneNumParam", StringParameterProps.builder() - .parameterName("/" + getStackName() + "/CHIME_PHONE_NUMBER") - .description("The ARN for the Voice Connector") - .stringValue(phoneNumber) - .build()); + new StringParameter(this, "PhoneNumParam", StringParameterProps.builder() + .parameterName("/" + getStackName() + "/CHIME_PHONE_NUMBER") + .description("The Phone Number that was provisioned") + .stringValue(phoneNumber) + .build()); - new CfnOutput(this, "PhoneNumber", CfnOutputProps.builder() - .description("The Phone Number that was provisioned") - .value(phoneNumber) - .build()); + new CfnOutput(this, "PhoneNumber", CfnOutputProps.builder() + .description("The Phone Number that was provisioned") + .value(phoneNumber) + .build()); + } + // Kick out both SMA's just for fun, shows power of multi-region stack references + int count = 1; + for (var sma : smas) { + new CfnOutput(this, "sma-" + count++, CfnOutputProps.builder() + .description("The ID for the Session Media App (SMA)") + .value(sma.getSMAId()) + .build()); + } } } diff --git a/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureApp.java b/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureApp.java index e40f267..b3564b4 100644 --- a/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureApp.java +++ b/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureApp.java @@ -1,25 +1,47 @@ package cloud.cleo.chimesma.cdk; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.ENV_VARS.*; import java.util.List; import software.amazon.awscdk.App; import software.amazon.awscdk.Environment; import software.amazon.awscdk.StackProps; -public final class InfrastructureApp { +public final class InfrastructureApp extends App { private static final String STACK_DESC = "Provision Chime Voice SDK resources (VoiceConnector, SIP Rule, SIP Media App)"; /** - * If set in the environment, setup Origination to point to it and allow from termination as well + * Environment Variables used to trigger features */ - private final static String PBX_HOSTNAME = System.getenv("PBX_HOSTNAME"); - - private final static String TWILIO = System.getenv("TWILIO"); + public enum ENV_VARS { + /** + * If set in the environment, setup Origination to point to Voice Connector and allow from termination as well + */ + PBX_HOSTNAME, + /** + * Provision Twilio SIP Trunk (experimental) + */ + TWILIO, + /** + * Attempt to provision a phone number in this area code (US only and experimental) + */ + CHIME_AREA_CODE, + /** + * Existing Phone number in Chime Voice. This will trigger pointing a SIP rule at this number + */ + CHIME_PHONE_NUMBER, + /** + * Provision a Voice Connector so SIP calls can be made in and out. Implied if PBX_HOSTNAME set. + */ + VOICE_CONNECTOR, + /** + * Single IP address to allow to call the Voice Connector (Cannot be private range or will fail) + */ + VOICE_CONNECTOR_ALLOW_IP + } - private final static String CHIME_AREA_CODE = System.getenv("CHIME_AREA_CODE"); - public static void main(final String[] args) { - final var app = new App(); + final var app = new InfrastructureApp(); // Required Param String accountId = (String) app.getNode().tryGetContext("accountId"); @@ -33,40 +55,71 @@ public static void main(final String[] args) { final var east = new InfrastructureStack(app, "east", StackProps.builder() .description(STACK_DESC) .stackName(stackName) - .env(makeEnv(accountId, regionEast)) + .env(makeStackEnv(accountId, regionEast)) .build()); final var west = new InfrastructureStack(app, "west", StackProps.builder() .description(STACK_DESC) .stackName(stackName) - .env(makeEnv(accountId, regionWest)) + .env(makeStackEnv(accountId, regionWest)) .build()); - if (TWILIO != null && !TWILIO.isBlank()) { + if (hasEnv(TWILIO)) { new TwilioStack(app, "twilio", StackProps.builder() .description("Provision Twilio Resources") .stackName(stackName + "-twilio") - .env(makeEnv(accountId, regionEast)) + .env(makeStackEnv(accountId, regionEast)) .crossRegionReferences(Boolean.TRUE) .build(), east.getVCHostName(), west.getVCHostName()); } // Provision Chime Phone Number if area code provided // - if (CHIME_AREA_CODE != null && ! CHIME_AREA_CODE.isBlank()) { - new ChimePhoneNumberStack(app, "phone", StackProps.builder() + if (hasEnv(CHIME_AREA_CODE, CHIME_PHONE_NUMBER)) { + new ChimePhoneNumberStack(app, "phone", StackProps.builder() .description("Provision Chime Phone Number") .stackName(stackName + "-phone") - .env(makeEnv(accountId, regionEast)) + .env(makeStackEnv(accountId, regionEast)) .crossRegionReferences(Boolean.TRUE) .build(), List.of(east.getSMA(), west.getSMA())); } - + app.synth(); } - static Environment makeEnv(String accountId, String region) { + /** + * Get the value for one of the ENV variables + * @param envVar + * @return + */ + public static String getEnv(ENV_VARS envVar) { + return System.getenv(envVar.name()); + } + + /** + * Is any of the provided env vars set (OR condition) + * + * @param envVars + * @return + */ + public static boolean hasEnv(ENV_VARS... envVars) { + for (var envVar : envVars) { + final var env = getEnv(envVar); + if (env != null && !env.isBlank()) { + return true; + } + } + return false; + } + + /** + * Create a Stack Environment + * @param accountId + * @param region + * @return + */ + static Environment makeStackEnv(String accountId, String region) { return Environment.builder() .account(accountId) .region(region) diff --git a/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureStack.java b/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureStack.java index 11c6bb7..3b7e00b 100644 --- a/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureStack.java +++ b/src/main/java/cloud/cleo/chimesma/cdk/InfrastructureStack.java @@ -4,13 +4,12 @@ import cloud.cleo.chimesma.cdk.customresources.ChimeSipMediaApp; import cloud.cleo.chimesma.cdk.customresources.ChimeSipRuleVC; import cloud.cleo.chimesma.cdk.resources.ChimeSMAFunction; -import java.util.ArrayList; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.ENV_VARS.*; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.hasEnv; import java.util.List; import software.amazon.awscdk.App; import software.amazon.awscdk.CfnOutput; import software.amazon.awscdk.CfnOutputProps; -import software.amazon.awscdk.services.ec2.AclCidr; -import software.constructs.Construct; import software.amazon.awscdk.Stack; import software.amazon.awscdk.StackProps; import software.amazon.awscdk.services.lambda.Function; @@ -24,22 +23,16 @@ */ public class InfrastructureStack extends Stack { - /** - * If set in the environment, setup Origination to point to it and allow from termination as well - */ - private final static String PBX_HOSTNAME = System.getenv("PBX_HOSTNAME"); - private final static String VOICE_CONNECTOR = System.getenv("VOICE_CONNECTOR"); - private final ChimeVoiceConnector vc; private final ChimeSipMediaApp sma; - public InfrastructureStack(final App parent, final String id) { - this(parent, id, null); + public InfrastructureStack(final App app, final String id) { + this(app, id, null); } - public InfrastructureStack(final Construct parent, final String id, final StackProps props) { - super(parent, id, props); + public InfrastructureStack(final App app, final String id, final StackProps props) { + super(app, id, props); // Simple SMA Handler that speaks prompt and hangs up Function lambda = new ChimeSMAFunction(this, "sma-lambda"); @@ -64,22 +57,12 @@ public InfrastructureStack(final Construct parent, final String id, final StackP .value(sma.getSMAId()) .build()); - final boolean hasPBX = PBX_HOSTNAME != null && !PBX_HOSTNAME.isBlank(); - final boolean hasVC = VOICE_CONNECTOR != null && !VOICE_CONNECTOR.isBlank(); String vc_arn = "PSTN"; - if (hasVC || hasPBX) { - // Start with list of Twilio NA ranges for SIP Trunking - var cidrAllowList = List.of(AclCidr.ipv4("54.172.60.0/30"), AclCidr.ipv4("54.244.51.0/30"), - // Europe, Ireland and Frankfurt - AclCidr.ipv4("54.171.127.192/30"), AclCidr.ipv4("35.156.191.128/30")); - if (hasPBX) { - cidrAllowList = new ArrayList(cidrAllowList); - cidrAllowList.add(AclCidr.ipv4(PBX_HOSTNAME + "/32")); - } - + if ( hasEnv(VOICE_CONNECTOR,PBX_HOSTNAME) ) { + // Voice Connector - vc = new ChimeVoiceConnector(this, cidrAllowList, PBX_HOSTNAME); + vc = new ChimeVoiceConnector(this); // SIP rule that associates the SMA with the Voice Connector new ChimeSipRuleVC(this, vc, List.of(sma)); @@ -94,6 +77,12 @@ public InfrastructureStack(final Construct parent, final String id, final StackP .description("The Hostname for the Voice Connector") .value(vc.getOutboundName()) .build()); + + // throw out a SIP URL so you can call it with your favor SIP App (after adding IP to VC manually) + new CfnOutput(this, "SIPUri", CfnOutputProps.builder() + .description("SIP Uri to call into the Session Media App") + .value("sip:+17035550122@" + vc.getOutboundName()) + .build()); // If VC was created set to ARN otherwise leave at PSTN vc_arn = vc.getArn(); diff --git a/src/main/java/cloud/cleo/chimesma/cdk/TwilioStack.java b/src/main/java/cloud/cleo/chimesma/cdk/TwilioStack.java index 2cb6ea3..c11f9a2 100644 --- a/src/main/java/cloud/cleo/chimesma/cdk/TwilioStack.java +++ b/src/main/java/cloud/cleo/chimesma/cdk/TwilioStack.java @@ -7,16 +7,12 @@ import software.amazon.awscdk.StackProps; /** - * CDK Stack + * CDK Stack * * @author sjensen */ public class TwilioStack extends Stack { - /** - * If set in the environment, setup Origination to point to it and allow from termination as well - */ - private final static String PBX_HOSTNAME = System.getenv("PBX_HOSTNAME"); public TwilioStack(final App parent, final String id, String vc1, String vc2) { this(parent, id, null,vc1,vc2); diff --git a/src/main/java/cloud/cleo/chimesma/cdk/customresources/ChimeVoiceConnector.java b/src/main/java/cloud/cleo/chimesma/cdk/customresources/ChimeVoiceConnector.java index 7d46d51..82d9bdd 100644 --- a/src/main/java/cloud/cleo/chimesma/cdk/customresources/ChimeVoiceConnector.java +++ b/src/main/java/cloud/cleo/chimesma/cdk/customresources/ChimeVoiceConnector.java @@ -19,6 +19,10 @@ import software.amazon.awscdk.services.ec2.AclCidr; import software.amazon.awscdk.services.iam.PolicyStatement; import software.amazon.awscdk.services.logs.RetentionDays; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.ENV_VARS.*; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.getEnv; +import static cloud.cleo.chimesma.cdk.InfrastructureApp.hasEnv; +import java.util.ArrayList; /** * @@ -28,25 +32,13 @@ public class ChimeVoiceConnector extends AwsCustomResource { private final static String ID = "VC-CR"; - - /** * The Voice Connector ID in the API response */ private final static String VC_ID = "VoiceConnector.VoiceConnectorId"; private final static String VC_ARN = "VoiceConnector.VoiceConnectorArn"; - - public ChimeVoiceConnector(Stack scope) { - this(scope,null,null); - } - - public ChimeVoiceConnector(Stack scope, List termAllow) { - this(scope,termAllow,null); - } - - public ChimeVoiceConnector(Stack scope, List termAllow, String pbx) { super(scope, ID, AwsCustomResourceProps.builder() .resourceType("Custom::VoiceConnector") .installLatestAwsSdk(Boolean.FALSE) @@ -65,29 +57,47 @@ public ChimeVoiceConnector(Stack scope, List termAllow, String pbx) { .logRetention(RetentionDays.ONE_MONTH) .build()); - - /** - // Don't enable SIP logging on VC, This can be done manually SIP Logs - final var logging = new AwsCustomResource(scope, ID + "-LOG", AwsCustomResourceProps.builder() - .resourceType("Custom::VoiceConnectorLogging") - .installLatestAwsSdk(Boolean.FALSE) - .policy(AwsCustomResourcePolicy.fromStatements(List.of(PolicyStatement.Builder.create().actions(List.of("chime:*", "logs:*")).resources(List.of("*")).build()))) - .onCreate(AwsSdkCall.builder() - .service("@aws-sdk/client-chime-sdk-voice") - .action("PutVoiceConnectorLoggingConfigurationCommand") - .physicalResourceId(PhysicalResourceId.of("logging")) - .parameters(Map.of("VoiceConnectorId", getResponseFieldReference(VC_ID), - "LoggingConfiguration", Map.of("EnableSIPLogs", true, "EnableMediaMetricLogs", false))) - .build()) - .build()); - */ - - // If term allow not provided, just open up to anything for ease of use - if ( termAllow == null ) { - termAllow = List.of(AclCidr.anyIpv4()); + * // Don't enable SIP logging on VC, This can be done manually SIP Logs final var logging = new + * AwsCustomResource(scope, ID + "-LOG", AwsCustomResourceProps.builder() + * .resourceType("Custom::VoiceConnectorLogging") .installLatestAwsSdk(Boolean.FALSE) + * .policy(AwsCustomResourcePolicy.fromStatements(List.of(PolicyStatement.Builder.create().actions(List.of("chime:*", + * "logs:*")).resources(List.of("*")).build()))) .onCreate(AwsSdkCall.builder() + * .service("@aws-sdk/client-chime-sdk-voice") .action("PutVoiceConnectorLoggingConfigurationCommand") + * .physicalResourceId(PhysicalResourceId.of("logging")) .parameters(Map.of("VoiceConnectorId", + * getResponseFieldReference(VC_ID), "LoggingConfiguration", Map.of("EnableSIPLogs", true, + * "EnableMediaMetricLogs", false))) .build()) .build()); + */ + // + // IP ranges to all to call into the VC + var termAllow = new ArrayList(); + + // Add Twilio + if (hasEnv(PBX_HOSTNAME, TWILIO)) { + // Start with list of Twilio NA ranges for SIP Trunking + termAllow.add(AclCidr.ipv4("54.172.60.0/30")); + termAllow.add(AclCidr.ipv4("54.244.51.0/30")); + + // Europe, Ireland and Frankfurt + termAllow.add(AclCidr.ipv4("54.171.127.192/30")); + termAllow.add(AclCidr.ipv4("35.156.191.128/30")); } - + + // Allow PBX to call in + if (hasEnv(PBX_HOSTNAME)) { + termAllow.add(AclCidr.ipv4(getEnv(PBX_HOSTNAME) + "/32")); + } + + // Generally Used so a given client IP can call as well + if (hasEnv(VOICE_CONNECTOR_ALLOW_IP)) { + termAllow.add(AclCidr.ipv4(getEnv(VOICE_CONNECTOR_ALLOW_IP) + "/32")); + } + + // If nothing at this point, just throw something in there so termination can be enabled, because something must be set + if (termAllow.isEmpty()) { + termAllow.add(AclCidr.ipv4("162.216.219.160/27")); + } + new AwsCustomResource(scope, ID + "-TERM", AwsCustomResourceProps.builder() .resourceType("Custom::VoiceConnectorTerm") .installLatestAwsSdk(Boolean.FALSE) @@ -105,7 +115,7 @@ public ChimeVoiceConnector(Stack scope, List termAllow, String pbx) { /** * Only need to configure origination if outbound calls are needed for SIP */ - if ( pbx != null ) { + if (hasEnv(PBX_HOSTNAME)) { new AwsCustomResource(scope, ID + "-ORIG", AwsCustomResourceProps.builder() .resourceType("Custom::VoiceConnectorOrig") .installLatestAwsSdk(Boolean.FALSE) @@ -115,7 +125,7 @@ public ChimeVoiceConnector(Stack scope, List termAllow, String pbx) { .action("PutVoiceConnectorOriginationCommand") .physicalResourceId(PhysicalResourceId.of("origination")) .parameters(Map.of("VoiceConnectorId", getResponseFieldReference(VC_ID), - "Origination", Map.of("Routes", List.of(Map.of("Host", pbx, "Port", 5060, "Protocol", "UDP", "Priority", 1, "Weight", 1)), "Disabled", false))) + "Origination", Map.of("Routes", List.of(Map.of("Host", getEnv(PBX_HOSTNAME), "Port", 5060, "Protocol", "UDP", "Priority", 1, "Weight", 1)), "Disabled", false))) .build()) .logRetention(RetentionDays.ONE_MONTH) .build()); diff --git a/src/test/java/cloud/cleo/chimesma/cdk/InfrastructureStackTest.java b/src/test/java/cloud/cleo/chimesma/cdk/InfrastructureStackTest.java index 8f74170..6f14c18 100644 --- a/src/test/java/cloud/cleo/chimesma/cdk/InfrastructureStackTest.java +++ b/src/test/java/cloud/cleo/chimesma/cdk/InfrastructureStackTest.java @@ -22,7 +22,7 @@ public class InfrastructureStackTest { @BeforeAll public static void init() { App app = new App(); - InfrastructureStack stack = new InfrastructureStack(app, "test"); + InfrastructureStack stack = new InfrastructureStack(app,"test"); stackJson = JSON.valueToTree(app.synth().getStackArtifact(stack.getArtifactId()).getTemplate()); }