├── infrastructure ├── modules │ ├── app │ │ ├── main.tf │ │ ├── cloudwatch.tf │ │ ├── variables.tf │ │ ├── versions.tf │ │ ├── container_instances.tf │ │ ├── sg.tf │ │ ├── ssm.tf │ │ ├── iam.tf │ │ └── ecs.tf │ └── container_instances │ │ ├── templates │ │ └── setup.sh │ │ ├── outputs.tf │ │ ├── sg.tf │ │ ├── ssm.tf │ │ ├── variables.tf │ │ ├── launch_template.tf │ │ ├── iam.tf │ │ └── asg.tf └── production │ ├── variables.tf │ ├── versions.tf │ ├── main.tf │ ├── terraform.tf │ └── .terraform.lock.hcl ├── .tool-versions ├── .release-please-manifest.json ├── lib ├── http_server │ ├── index.js │ └── http_server.js ├── gateways │ ├── index.js │ ├── dummy_gateway.js │ ├── smpp_delivery_receipt.js │ ├── goip_gateway.js │ └── smpp_gateway.js └── somleng_client.js ├── assets ├── diagram.png ├── goip-sms-2_configurations_sim.png ├── sms_gateway_connection_status.png └── goip-sms-1_configurations_preferences.png ├── index.js ├── .gitignore ├── .prettierrc.json ├── sea-config.json ├── test ├── integration │ └── smpp_server.js ├── test_helper.js └── lib │ ├── somleng_client.test.js │ └── gateways │ └── smpp_delivery_receipt.test.js ├── .github ├── dependabot.yml └── workflows │ ├── dependabot-auto-merge.yml │ └── build.yml ├── release-please-config.json ├── .eslintrc.json ├── bin ├── build-sea-package └── cli.js ├── package.json ├── LICENSE ├── Dockerfile ├── README.md └── CHANGELOG.md /infrastructure/modules/app/main.tf: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | nodejs 23.3.0 2 | terraform 1.11.3 3 | -------------------------------------------------------------------------------- /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "2.0.5" 3 | } 4 | -------------------------------------------------------------------------------- /lib/http_server/index.js: -------------------------------------------------------------------------------- 1 | export { default } from "./http_server.js"; 2 | -------------------------------------------------------------------------------- /assets/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/somleng/sms-gateway/HEAD/assets/diagram.png -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import SomlengClient from "./lib/somleng_client"; 2 | 3 | export { SomlengClient }; 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | npm-debug.log 4 | .env 5 | .DS_Store 6 | tmp 7 | .terraform 8 | -------------------------------------------------------------------------------- /infrastructure/production/variables.tf: -------------------------------------------------------------------------------- 1 | variable "aws_region" { 2 | default = "ap-southeast-1" 3 | } 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": true, 4 | "tabWidth": 2, 5 | "useTabs": false 6 | } 7 | -------------------------------------------------------------------------------- /assets/goip-sms-2_configurations_sim.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/somleng/sms-gateway/HEAD/assets/goip-sms-2_configurations_sim.png -------------------------------------------------------------------------------- /assets/sms_gateway_connection_status.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/somleng/sms-gateway/HEAD/assets/sms_gateway_connection_status.png -------------------------------------------------------------------------------- /assets/goip-sms-1_configurations_preferences.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/somleng/sms-gateway/HEAD/assets/goip-sms-1_configurations_preferences.png -------------------------------------------------------------------------------- /sea-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "build/somleng-sms-gateway.js", 3 | "output": "build/sea-prep.blob", 4 | "disableExperimentalSEAWarning": true 5 | } 6 | -------------------------------------------------------------------------------- /test/integration/smpp_server.js: -------------------------------------------------------------------------------- 1 | import smpp from "smpp"; 2 | 3 | var server = smpp.createServer({ 4 | debug: true, 5 | }); 6 | 7 | server.listen(2775); 8 | -------------------------------------------------------------------------------- /infrastructure/modules/app/cloudwatch.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "app" { 2 | name = var.app_identifier 3 | retention_in_days = 7 4 | } 5 | -------------------------------------------------------------------------------- /infrastructure/modules/app/variables.tf: -------------------------------------------------------------------------------- 1 | variable "app_identifier" {} 2 | variable "app_image" {} 3 | variable "region" {} 4 | 5 | 6 | variable "smpp_port" { 7 | default = "2775" 8 | } 9 | -------------------------------------------------------------------------------- /infrastructure/production/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | } 6 | } 7 | required_version = ">= 0.13" 8 | } 9 | -------------------------------------------------------------------------------- /infrastructure/modules/app/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | } 6 | } 7 | required_version = ">= 0.13" 8 | } 9 | 10 | -------------------------------------------------------------------------------- /lib/gateways/index.js: -------------------------------------------------------------------------------- 1 | import GoIPGateway from "./goip_gateway.js"; 2 | import DummyGateway from "./dummy_gateway.js"; 3 | import SMPPGateway from "./smpp_gateway.js"; 4 | 5 | export { GoIPGateway, SMPPGateway, DummyGateway }; 6 | -------------------------------------------------------------------------------- /infrastructure/production/main.tf: -------------------------------------------------------------------------------- 1 | module "app" { 2 | source = "../modules/app" 3 | 4 | app_identifier = "sms-gateway" 5 | app_image = "somleng/sms-gateway" 6 | region = data.terraform_remote_state.core_infrastructure.outputs.hydrogen_region 7 | smpp_port = "18013" 8 | } 9 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/templates/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | systemctl enable amazon-ssm-agent 4 | systemctl start amazon-ssm-agent 5 | 6 | # ECS config 7 | cat <<'EOF' >> /etc/ecs/ecs.config 8 | ECS_CLUSTER=${cluster_name} 9 | ECS_RESERVED_MEMORY=256 10 | ECS_ENABLE_CONTAINER_METADATA=true 11 | EOF 12 | -------------------------------------------------------------------------------- /infrastructure/modules/app/container_instances.tf: -------------------------------------------------------------------------------- 1 | module "container_instances" { 2 | source = "../container_instances" 3 | 4 | identifier = var.app_identifier 5 | vpc = var.region.vpc 6 | instance_subnets = var.region.vpc.private_subnets 7 | cluster_name = aws_ecs_cluster.this.name 8 | max_capacity = 2 9 | } 10 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/outputs.tf: -------------------------------------------------------------------------------- 1 | output "autoscaling_group" { 2 | value = aws_autoscaling_group.this 3 | } 4 | 5 | output "ec2_instance_type" { 6 | value = data.aws_ec2_instance_type.this 7 | } 8 | 9 | output "security_group" { 10 | value = aws_security_group.this 11 | } 12 | 13 | output "iam_role" { 14 | value = aws_iam_role.this 15 | } 16 | -------------------------------------------------------------------------------- /infrastructure/modules/app/sg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "this" { 2 | name = var.app_identifier 3 | vpc_id = var.region.vpc.vpc_id 4 | } 5 | 6 | resource "aws_security_group_rule" "egress" { 7 | type = "egress" 8 | to_port = 0 9 | protocol = "-1" 10 | from_port = 0 11 | security_group_id = aws_security_group.this.id 12 | cidr_blocks = ["0.0.0.0/0"] 13 | } 14 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/sg.tf: -------------------------------------------------------------------------------- 1 | resource "aws_security_group" "this" { 2 | name = "${var.identifier}-container-instance" 3 | vpc_id = var.vpc.vpc_id 4 | } 5 | 6 | resource "aws_security_group_rule" "egress" { 7 | type = "egress" 8 | to_port = 0 9 | protocol = "-1" 10 | from_port = 0 11 | security_group_id = aws_security_group.this.id 12 | cidr_blocks = ["0.0.0.0/0"] 13 | } 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: 'daily' 7 | ignore: 8 | # ignore until https://github.com/airbnb/javascript/issues/2961 is fixed 9 | - dependency-name: "eslint" 10 | update-types: ["version-update:semver-major"] 11 | 12 | - package-ecosystem: 'github-actions' 13 | directory: '/' 14 | schedule: 15 | interval: 'daily' 16 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/ssm.tf: -------------------------------------------------------------------------------- 1 | # Automatically update the SSM agent 2 | 3 | # https://docs.aws.amazon.com/systems-manager/latest/userguide/sysman-state-cli.html 4 | # https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_association 5 | resource "aws_ssm_association" "update_ssm_agent" { 6 | name = "AWS-UpdateSSMAgent" 7 | 8 | targets { 9 | key = "tag:Name" 10 | values = [var.identifier] 11 | } 12 | 13 | schedule_expression = "cron(0 19 ? * SAT *)" 14 | } 15 | -------------------------------------------------------------------------------- /infrastructure/production/terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | backend "s3" { 3 | bucket = "infrastructure.somleng.org" 4 | key = "sms-gateway.tfstate" 5 | encrypt = true 6 | region = "ap-southeast-1" 7 | } 8 | } 9 | 10 | provider "aws" { 11 | region = var.aws_region 12 | } 13 | 14 | data "terraform_remote_state" "core_infrastructure" { 15 | backend = "s3" 16 | 17 | config = { 18 | bucket = "infrastructure.somleng.org" 19 | key = "core.tfstate" 20 | region = var.aws_region 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/dependabot-auto-merge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: ${{ github.actor == 'dependabot[bot]' }} 12 | steps: 13 | - name: Enable auto-merge for Dependabot PRs 14 | run: gh pr merge --auto --merge "$PR_URL" 15 | env: 16 | PR_URL: ${{github.event.pull_request.html_url}} 17 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 18 | -------------------------------------------------------------------------------- /release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bootstrap-sha": "2b176c9eff1e8e73c75d40c1cb380618c799c812", 3 | "include-component-in-tag": false, 4 | "packages": { 5 | ".": { 6 | "changelog-path": "CHANGELOG.md", 7 | "release-type": "node", 8 | "bump-minor-pre-major": false, 9 | "bump-patch-for-minor-pre-major": false, 10 | "draft": false, 11 | "prerelease": false, 12 | "package-name": "somleng-sms-gateway" 13 | } 14 | }, 15 | "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json" 16 | } 17 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/variables.tf: -------------------------------------------------------------------------------- 1 | variable "instance_type" { 2 | default = "t4g.small" 3 | } 4 | 5 | variable "identifier" {} 6 | variable "vpc" {} 7 | variable "instance_subnets" {} 8 | variable "cluster_name" {} 9 | 10 | variable "max_capacity" { 11 | default = 10 12 | } 13 | variable "security_groups" { 14 | default = [] 15 | } 16 | 17 | variable associate_public_ip_address { 18 | default = false 19 | } 20 | 21 | variable "user_data" { 22 | type = list( 23 | object( 24 | { 25 | path = string, 26 | content = string, 27 | permissions = string 28 | } 29 | ) 30 | ) 31 | default = [] 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "airbnb-base", 8 | "plugin:prettier/recommended" 9 | ], 10 | "plugins": [ 11 | "prettier" 12 | ], 13 | "overrides": [], 14 | "parserOptions": { 15 | "ecmaVersion": "latest", 16 | "sourceType": "module" 17 | }, 18 | "rules": { 19 | "lines-between-class-members": [ 20 | "error", 21 | "always", 22 | { 23 | "exceptAfterSingleLine": true 24 | } 25 | ], 26 | "allowForLoopAfterthoughts": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/launch_template.tf: -------------------------------------------------------------------------------- 1 | resource "aws_launch_template" "this" { 2 | name_prefix = var.identifier 3 | image_id = jsondecode(data.aws_ssm_parameter.this_ami.value).image_id 4 | instance_type = data.aws_ec2_instance_type.this.instance_type 5 | 6 | iam_instance_profile { 7 | name = aws_iam_instance_profile.this.name 8 | } 9 | 10 | network_interfaces { 11 | associate_public_ip_address = var.associate_public_ip_address 12 | security_groups = concat([aws_security_group.this.id], var.security_groups) 13 | } 14 | 15 | user_data = base64encode(join("\n", [ 16 | "#cloud-config", 17 | yamlencode({ 18 | # https://cloudinit.readthedocs.io/en/latest/topics/modules.html 19 | write_files : local.user_data, 20 | runcmd : [for i, v in local.user_data : v.path] 21 | }) 22 | ])) 23 | 24 | lifecycle { 25 | create_before_destroy = true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /infrastructure/modules/app/ssm.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ssm_parameter" "device_key" { 2 | name = "${var.app_identifier}.device_key" 3 | type = "SecureString" 4 | value = "change-me" 5 | 6 | lifecycle { 7 | ignore_changes = [value] 8 | } 9 | } 10 | 11 | resource "aws_ssm_parameter" "smpp_host" { 12 | name = "${var.app_identifier}.smpp_host" 13 | type = "SecureString" 14 | value = "change-me" 15 | 16 | lifecycle { 17 | ignore_changes = [value] 18 | } 19 | } 20 | 21 | resource "aws_ssm_parameter" "smpp_username" { 22 | name = "${var.app_identifier}.smpp_username" 23 | type = "SecureString" 24 | value = "change-me" 25 | 26 | lifecycle { 27 | ignore_changes = [value] 28 | } 29 | } 30 | 31 | resource "aws_ssm_parameter" "smpp_password" { 32 | name = "${var.app_identifier}.smpp_password" 33 | type = "SecureString" 34 | value = "change-me" 35 | 36 | lifecycle { 37 | ignore_changes = [value] 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /bin/build-sea-package: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # From https://nodejs.org/api/single-executable-applications.html#generating-single-executable-preparation-blobs 4 | 5 | set -e 6 | 7 | node --experimental-sea-config sea-config.json 8 | 9 | package_name="${1:-somleng-sms-gateway}" 10 | 11 | if command -v node | grep "asdf" > /dev/null; then 12 | cp $(asdf which node) build/${package_name} 13 | else 14 | cp $(command -v node) build/${package_name} 15 | fi 16 | 17 | case "$(uname -s)" in 18 | Darwin) 19 | codesign --remove-signature build/${package_name} 20 | npx --yes postject build/${package_name} NODE_SEA_BLOB build/sea-prep.blob \ 21 | --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \ 22 | --macho-segment-name NODE_SEA \ 23 | --overwrite 24 | codesign --sign - build/${package_name} 25 | ;; 26 | 27 | Linux) 28 | npx --yes postject build/${package_name} NODE_SEA_BLOB build/sea-prep.blob \ 29 | --sentinel-fuse NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2 \ 30 | --overwrite 31 | ;; 32 | 33 | *) 34 | echo 'Other OS' 35 | ;; 36 | esac 37 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "somleng-sms-gateway", 3 | "version": "2.0.5", 4 | "type": "module", 5 | "main": "index.js", 6 | "bin": { 7 | "somleng-sms-gateway": "bin/cli.js" 8 | }, 9 | "scripts": { 10 | "test": "NODE_OPTIONS='--experimental-vm-modules' jest", 11 | "build": "esbuild bin/cli.js --bundle --platform=node --outfile=build/somleng-sms-gateway.js", 12 | "dist": "npm run build && bin/build-sea-package" 13 | }, 14 | "keywords": [], 15 | "author": "Somleng Team", 16 | "license": "MIT", 17 | "dependencies": { 18 | "@anycable/core": "^1.1.4", 19 | "@sentry/node": "^10.32.0", 20 | "commander": "^14.0.2", 21 | "smpp": "^0.6.0-rc.4", 22 | "winston": "^3.19.0", 23 | "ws": "^8.18.3" 24 | }, 25 | "devDependencies": { 26 | "esbuild": "^0.27.2", 27 | "eslint": "^8.57.1", 28 | "eslint-config-airbnb-base": "^15.0.0", 29 | "eslint-config-prettier": "^10.1.8", 30 | "eslint-plugin-prettier": "^5.5.4", 31 | "jest": "^30.2.0", 32 | "prettier": "3.7.4" 33 | }, 34 | "directories": { 35 | "lib": "lib" 36 | }, 37 | "description": "" 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Somleng 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/iam.tf: -------------------------------------------------------------------------------- 1 | data "aws_iam_policy_document" "assume_role" { 2 | statement { 3 | effect = "Allow" 4 | 5 | principals { 6 | type = "Service" 7 | identifiers = ["ec2.amazonaws.com"] 8 | } 9 | 10 | actions = ["sts:AssumeRole"] 11 | } 12 | } 13 | 14 | resource "aws_iam_role" "this" { 15 | name = "${var.identifier}_ecs_container_instance_role" 16 | 17 | assume_role_policy = data.aws_iam_policy_document.assume_role.json 18 | } 19 | 20 | resource "aws_iam_instance_profile" "this" { 21 | name = "${var.identifier}_ecs_container_instance_profile" 22 | role = aws_iam_role.this.name 23 | } 24 | 25 | resource "aws_iam_role_policy_attachment" "ecs" { 26 | role = aws_iam_role.this.id 27 | policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" 28 | } 29 | 30 | resource "aws_iam_role_policy_attachment" "ecs_ec2_role" { 31 | role = aws_iam_role.this.id 32 | policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEC2ContainerServiceforEC2Role" 33 | } 34 | 35 | resource "aws_iam_role_policy_attachment" "ssm" { 36 | role = aws_iam_role.this.name 37 | policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" 38 | } 39 | -------------------------------------------------------------------------------- /test/test_helper.js: -------------------------------------------------------------------------------- 1 | import { jest } from "@jest/globals"; 2 | 3 | jest.useFakeTimers(); 4 | 5 | export class TestCable { 6 | constructor() { 7 | this.outgoing = []; 8 | this.channels = {}; 9 | } 10 | 11 | subscribe(channel) { 12 | channel.attached(this); 13 | channel.connecting(); 14 | 15 | this.channels[channel.identifier] = channel; 16 | channel.connected(); 17 | 18 | return channel; 19 | } 20 | 21 | async perform(identifier, action, payload = {}) { 22 | if (!this.channels[identifier]) { 23 | throw Error(`Channel not found: ${identifier}`); 24 | } 25 | 26 | this.outgoing.push({ action, payload }); 27 | 28 | return Promise.resolve(); 29 | } 30 | 31 | unsubscribe(channel) { 32 | let identifier = channel.identifier; 33 | 34 | if (!this.channels[identifier]) { 35 | throw Error(`Channel not found: ${identifier}`); 36 | } 37 | 38 | channel.closed(); 39 | 40 | delete this.channels[identifier]; 41 | } 42 | 43 | broadcast(channel_identifier, message, metadata) { 44 | if (!this.channels[channel_identifier]) { 45 | throw Error(`Channel not found: ${channel_identifier}`); 46 | } 47 | 48 | this.channels[channel_identifier].receive(message, metadata); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /lib/gateways/dummy_gateway.js: -------------------------------------------------------------------------------- 1 | import { setTimeout as setTimeoutAsync } from "timers/promises"; 2 | 3 | class DummyGateway { 4 | #onDeliveryReceiptCallback; 5 | #onReceivedCallback; 6 | 7 | async connect() { 8 | await setTimeoutAsync(1000); 9 | console.log("DummyGateway.connect() was called"); 10 | } 11 | 12 | config() { 13 | return {}; 14 | } 15 | 16 | isConnected() { 17 | return true; 18 | } 19 | 20 | async sendMessage(params) { 21 | const channel = params.channel; 22 | const response = { messageId: 1 }; 23 | 24 | await setTimeoutAsync(1000); 25 | console.log(`DummyGateway.sendMessage(${channel}, ${JSON.stringify(params)}) was called`); 26 | 27 | setTimeout(() => { 28 | const status = channel !== 999 ? "delivered" : "failed"; 29 | this.#onDeliveryReceiptCallback && 30 | this.#onDeliveryReceiptCallback({ messageId: response.messageId, status: status }); 31 | }, 1000); 32 | 33 | return response; 34 | } 35 | 36 | onReceived(callback) { 37 | console.log("Subscribed to DummyGateway.onReceived()"); 38 | this.#onReceivedCallback = callback; 39 | } 40 | 41 | onDeliveryReceipt(callback) { 42 | console.log("Subscribed to DummyGateway.onDeliveryReceipt()"); 43 | this.#onDeliveryReceiptCallback = callback; 44 | } 45 | } 46 | 47 | export default DummyGateway; 48 | -------------------------------------------------------------------------------- /test/lib/somleng_client.test.js: -------------------------------------------------------------------------------- 1 | import { TestCable } from "../test_helper"; 2 | import SomlengClient from "../../lib/somleng_client"; 3 | import { createLogger, transports } from "winston"; 4 | 5 | describe(SomlengClient, () => { 6 | let client; 7 | let cable; 8 | const logger = createLogger({ 9 | transports: [new transports.Console({ silent: true })], 10 | }); 11 | 12 | beforeEach(async () => { 13 | cable = new TestCable(); 14 | client = new SomlengClient({ cable, logger }); 15 | 16 | await client.subscribe(); 17 | }); 18 | 19 | it("handles incoming new messages", async () => { 20 | const data = { 21 | id: "id", 22 | channel: 1, 23 | from: "85510888888", 24 | to: "85510777777", 25 | body: "this is a test", 26 | }; 27 | 28 | client.onNewMessage((message) => { 29 | expect(message).toEqual(data); 30 | }); 31 | 32 | cable.broadcast('{"channel":"SMSMessageChannel"}', data); 33 | }); 34 | 35 | it("notifies delivery receipt", async () => { 36 | await client.notifyMessageStatus("data"); 37 | 38 | expect(cable.outgoing).toEqual([{ action: "sent", payload: "data" }]); 39 | }); 40 | 41 | it("receives a new message", async () => { 42 | await client.receivedMessage("message"); 43 | 44 | expect(cable.outgoing).toEqual([{ action: "received", payload: "message" }]); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /lib/gateways/smpp_delivery_receipt.js: -------------------------------------------------------------------------------- 1 | const PATTERN = /^id:(?[\w-]+)\ssub:(?\d+)\sdlvrd:(?\d+).+stat:(?\w+)/; 2 | 3 | class InvalidMessageFormatError extends Error {} 4 | class UnsupportedDeliveryStatusError extends Error {} 5 | 6 | class SMPPDeliveryReceipt { 7 | constructor(params) { 8 | this.messageId = params.messageId; 9 | this.status = params.status; 10 | } 11 | 12 | static parse(message) { 13 | const matches = message.match(PATTERN); 14 | 15 | if (matches === null) { 16 | throw new InvalidMessageFormatError("Invalid Delivery Message Format"); 17 | } 18 | 19 | let result = { messageId: matches.groups.messageId }; 20 | if (matches.groups.stat === "DELIVERED" || matches.groups.stat === "DELIVRD") { 21 | result.status = "delivered"; 22 | } else if (matches.groups.stat === "ENROUTE" || matches.groups.stat === "ENROUTE") { 23 | result.status = "sent"; 24 | } else if ( 25 | matches.groups.stat === "UNDELIVERABLE" || 26 | matches.groups.stat === "UNDELIV" || 27 | matches.groups.stat === "REJECTD" || 28 | matches.groups.stat === "EXPIRED" 29 | ) { 30 | result.status = "failed"; 31 | } else { 32 | throw new UnsupportedDeliveryStatusError("Unsupported Delivery Status Error"); 33 | } 34 | 35 | return new SMPPDeliveryReceipt(result); 36 | } 37 | } 38 | 39 | export { SMPPDeliveryReceipt, InvalidMessageFormatError, UnsupportedDeliveryStatusError }; 40 | -------------------------------------------------------------------------------- /infrastructure/production/.terraform.lock.hcl: -------------------------------------------------------------------------------- 1 | # This file is maintained automatically by "terraform init". 2 | # Manual edits may be lost in future updates. 3 | 4 | provider "registry.terraform.io/hashicorp/aws" { 5 | version = "6.14.1" 6 | hashes = [ 7 | "h1:oacWXQzS6BmkN3lcKKxafYOGUQcUOpneJikw8hqLUyA=", 8 | "zh:14d0b4b3dffb3368e6257136bbab1f93d419863dd65d99ef80ca2c1dd3c72a1e", 9 | "zh:1de3601251f87a0a989c4b3474baa2efcaf491804f8d7afe15421b728bac5dc5", 10 | "zh:2cfe42b853a3b4117bdbb73e5715035eac9b8d753d6e653fd5f30a807a36b985", 11 | "zh:3dd8a0336face356928faf2396065634739ef2c3ac3dcaa655570df205559fd9", 12 | "zh:42712baca386b84e089b1db8b7844038557f4039b32d8702611aa67eadef7d0f", 13 | "zh:4ffc698099e4d7ffc6b0490a4e78ad66b041afd54e988b8bf8e229bcdd4b3ead", 14 | "zh:52a6a3b01cb34394b0d06b273b27702fb9d795290a02e5824e198315787e8446", 15 | "zh:56eae388c48a844401e44811719dc23be84de538468fd12b7265b06acbf4b51d", 16 | "zh:614a918fdf27416b2ee2ce1737895b791f59f9deff3b61246c62a992eabfb8eb", 17 | "zh:68605e159177b57fdc4a26bb2caff69a7b69593a601145b7ab5a86fd44b28b9f", 18 | "zh:771ac00fd5f211052d735ff0e4b9ec67288abd1e22ffea4ed774aec73c7e5687", 19 | "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", 20 | "zh:a1355841161e5b53dc3078c88aae1972fd4a9c0d30309b18b1951137b96571fa", 21 | "zh:a3c8ca40c1fa7ad76d3d4c3c0039b66a93cc96399e757d2caa0b5cdedce9d3e8", 22 | "zh:c77e02a72ef9eb0eb65faaf84c33af843520622dbb51ec31d04ca371bd4d4ee8", 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/node:current AS build-base 2 | WORKDIR "/src" 3 | RUN apt-get update -qq && \ 4 | apt-get install --no-install-recommends -y git 5 | RUN git clone https://github.com/somleng/sms-gateway.git && \ 6 | cd sms-gateway && \ 7 | npm ci && \ 8 | npm run build && \ 9 | package_name=$(npm pkg get name | xargs echo) && \ 10 | package_version=$(npm pkg get version | xargs echo) && \ 11 | mkdir -p build && \ 12 | echo "$package_name" > build/package_name.txt && \ 13 | echo "$package_version" > build/package_version.txt 14 | 15 | FROM public.ecr.aws/docker/library/node:current AS build-linux 16 | COPY --from=build-base /src /src 17 | WORKDIR /src/sms-gateway 18 | RUN package_name=$(cat build/package_name.txt) && \ 19 | package_version=$(cat build/package_version.txt) && \ 20 | full_package_name=$package_name-linux-$(arch)-v$package_version && \ 21 | echo "$full_package_name" > build/full_package_name.txt && \ 22 | npm run dist $full_package_name 23 | 24 | FROM scratch AS export-linux 25 | COPY --from=build-linux /src/sms-gateway/build/ / 26 | 27 | FROM public.ecr.aws/docker/library/debian:bookworm-slim 28 | ARG APP_ROOT="/app" 29 | WORKDIR $APP_ROOT 30 | 31 | RUN apt-get update -qq && \ 32 | apt-get install --no-install-recommends -y wget libatomic1 && \ 33 | rm -rf /var/lib/apt/lists/* 34 | 35 | RUN mkdir -p $APP_ROOT 36 | 37 | COPY --link --from=export-linux /somleng-sms-gateway-linux-* $APP_ROOT/ 38 | 39 | RUN mv $APP_ROOT/somleng-sms-gateway-linux-* $APP_ROOT/somleng-sms-gateway && \ 40 | chmod +x $APP_ROOT/somleng-sms-gateway 41 | 42 | ENV PATH="$PATH:$APP_ROOT" 43 | HEALTHCHECK --interval=10s --timeout=5s --retries=10 CMD wget --server-response --spider --quiet http://localhost:3210 2>&1 | grep '200 OK' > /dev/null 44 | CMD ["somleng-sms-gateway"] 45 | -------------------------------------------------------------------------------- /infrastructure/modules/container_instances/asg.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | user_data = concat(var.user_data, [ 3 | { 4 | path = "/opt/setup.sh" 5 | content = templatefile( 6 | "${path.module}/templates/setup.sh", 7 | { 8 | cluster_name = var.cluster_name 9 | } 10 | ) 11 | permissions = "755" 12 | } 13 | ]) 14 | } 15 | 16 | # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/ecs-optimized_AMI.html 17 | # https://docs.aws.amazon.com/AmazonECS/latest/developerguide/retrieve-ecs-optimized_AMI.html 18 | data "aws_ssm_parameter" "this_ami" { 19 | name = "/aws/service/ecs/optimized-ami/amazon-linux-2023/arm64/recommended" 20 | } 21 | 22 | data "aws_ec2_instance_type" "this" { 23 | instance_type = var.instance_type 24 | } 25 | 26 | resource "aws_autoscaling_group" "this" { 27 | name = var.identifier 28 | 29 | launch_template { 30 | id = aws_launch_template.this.id 31 | version = aws_launch_template.this.latest_version 32 | } 33 | 34 | vpc_zone_identifier = var.instance_subnets 35 | max_size = var.max_capacity 36 | min_size = 0 37 | desired_capacity = 0 38 | wait_for_capacity_timeout = 0 39 | protect_from_scale_in = true 40 | # Turn on metrics collection 41 | # https://docs.aws.amazon.com/autoscaling/ec2/APIReference/API_EnableMetricsCollection.html 42 | metrics_granularity = "1Minute" 43 | enabled_metrics = [ 44 | "GroupInServiceInstances" 45 | ] 46 | 47 | tag { 48 | key = "Name" 49 | value = var.identifier 50 | propagate_at_launch = true 51 | } 52 | 53 | tag { 54 | key = "AmazonECSManaged" 55 | value = "" 56 | propagate_at_launch = true 57 | } 58 | 59 | lifecycle { 60 | ignore_changes = [desired_capacity] 61 | create_before_destroy = true 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /lib/gateways/goip_gateway.js: -------------------------------------------------------------------------------- 1 | import SMPPGateway from "./smpp_gateway.js"; 2 | 3 | class GoIPGateway { 4 | #smppGateways = new Map(); 5 | 6 | constructor(config) { 7 | this.host = config.host; 8 | this.port = config.port; 9 | this.systemId = config.systemId; 10 | this.password = config.password; 11 | this.channels = config.channels; 12 | this.debug = config.debug; 13 | 14 | for (let channel = 1; channel <= this.channels; channel++) { 15 | this.#smppGateways.set( 16 | channel, 17 | new SMPPGateway({ 18 | host: this.host, 19 | port: this.port, 20 | systemId: `${this.systemId}${String(channel).padStart(2, "0")}`, 21 | password: this.password, 22 | debug: this.debug, 23 | }), 24 | ); 25 | } 26 | } 27 | 28 | config() { 29 | return { 30 | host: this.host, 31 | port: this.port, 32 | systemId: this.systemId, 33 | password: this.password, 34 | channels: this.channels, 35 | }; 36 | } 37 | 38 | async connect() { 39 | for (const smppGateway of this.#smppGateways.values()) { 40 | await smppGateway.connect(); 41 | } 42 | } 43 | 44 | isConnected() { 45 | const channel = this.#randomChannel(); 46 | if (this.#smppGateways.has(channel)) { 47 | return this.#smppGateways.get(channel).isConnected(); 48 | } else { 49 | return false; 50 | } 51 | } 52 | 53 | async sendMessage(params) { 54 | const channel = params.channel || this.#randomChannel(); 55 | if (this.#smppGateways.has(channel)) { 56 | const deliveryReceipt = await this.#smppGateways.get(channel).sendMessage(params); 57 | return deliveryReceipt; 58 | } else { 59 | throw new Error(`Channel #${channel} doesn't exist.`); 60 | } 61 | } 62 | 63 | onReceived(callback) { 64 | for (const smppGateway of this.#smppGateways.values()) { 65 | smppGateway.onReceived(callback); 66 | } 67 | } 68 | 69 | onDeliveryReceipt(callback) { 70 | for (const smppGateway of this.#smppGateways.values()) { 71 | smppGateway.onDeliveryReceipt(callback); 72 | } 73 | } 74 | 75 | #randomChannel() { 76 | let keys = Array.from(this.#smppGateways.keys()); 77 | return keys[Math.floor(Math.random() * keys.length)]; 78 | } 79 | } 80 | 81 | export default GoIPGateway; 82 | -------------------------------------------------------------------------------- /lib/somleng_client.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-classes-per-file */ 2 | 3 | import WebSocket from "ws"; 4 | import { createCable, Channel } from "@anycable/core"; 5 | 6 | class SMSMessageChannel extends Channel { 7 | static identifier = "SMSMessageChannel"; 8 | } 9 | 10 | class SMSGatewayConnectionChannel extends Channel { 11 | static identifier = "SMSGatewayConnectionChannel"; 12 | } 13 | 14 | class SomlengClient { 15 | #cable; 16 | #messageChannel; 17 | #connectionChannel; 18 | 19 | constructor(config) { 20 | this.domain = config.domain; 21 | this.deviceKey = config.deviceKey; 22 | this.logger = config.logger; 23 | 24 | this.#cable = config.cable || this.#createCable(); 25 | this.#messageChannel = new SMSMessageChannel(); 26 | this.#connectionChannel = new SMSGatewayConnectionChannel(); 27 | } 28 | 29 | async subscribe() { 30 | await this.#cable.subscribe(this.#messageChannel).ensureSubscribed(); 31 | await this.#cable.subscribe(this.#connectionChannel).ensureSubscribed(); 32 | 33 | this.#ping(); 34 | } 35 | 36 | isConnected() { 37 | return this.#connectionChannel.state === "connected"; 38 | } 39 | 40 | onNewMessage(callback) { 41 | return this.#messageChannel.on("message", async (data) => { 42 | this.logger.debug("[messageChannel.on(message)]", data); 43 | if (data.type === "message_send_request") { 44 | await this.#messageChannel.perform("message_send_requested", { id: data.message_id }); 45 | } else if (data.type === "message_send_request_confirmed") { 46 | await callback(data.message); 47 | } else { 48 | // Unhandled message type 49 | } 50 | }); 51 | } 52 | 53 | async notifyMessageStatus(data) { 54 | return await this.#messageChannel.perform("sent", data); 55 | } 56 | 57 | async receivedMessage(message) { 58 | return await this.#messageChannel.perform("received", message); 59 | } 60 | 61 | #createCable() { 62 | return createCable(`${this.domain}/cable`, { 63 | protocol: "actioncable-v1-ext-json", 64 | websocketImplementation: WebSocket, 65 | websocketOptions: { headers: { "x-device-key": this.deviceKey } }, 66 | }); 67 | } 68 | 69 | #ping() { 70 | setInterval(async () => { 71 | if (this.isConnected()) { 72 | await this.#connectionChannel.perform("ping"); 73 | } 74 | }, 30000); 75 | } 76 | } 77 | 78 | export default SomlengClient; 79 | -------------------------------------------------------------------------------- /infrastructure/modules/app/iam.tf: -------------------------------------------------------------------------------- 1 | # ECS task role 2 | data "aws_iam_policy_document" "ecs_task_assume_role_policy" { 3 | version = "2012-10-17" 4 | statement { 5 | sid = "" 6 | effect = "Allow" 7 | actions = ["sts:AssumeRole"] 8 | 9 | principals { 10 | type = "Service" 11 | identifiers = ["ecs-tasks.amazonaws.com"] 12 | } 13 | } 14 | } 15 | 16 | resource "aws_iam_role" "ecs_task_role" { 17 | name = "${var.app_identifier}-ecs-task-role" 18 | assume_role_policy = data.aws_iam_policy_document.ecs_task_assume_role_policy.json 19 | } 20 | 21 | # https://aws.amazon.com/blogs/containers/new-using-amazon-ecs-exec-access-your-containers-fargate-ec2/ 22 | resource "aws_iam_policy" "ecs_exec_policy" { 23 | name = "${var.app_identifier}-ecs-exec-policy" 24 | 25 | policy = < { 8 | it("parses delivery receipt from Kannel", () => { 9 | const message = 10 | "id:5516a151-4970-45b6-b73e-95aefdb11cbf sub:001 dlvrd:001 submit date:2302231414 done date:230223194211 stat:DELIVRD err:000 text:Hello"; 11 | 12 | const deliveryReceipt = SMPPDeliveryReceipt.parse(message); 13 | 14 | expect(deliveryReceipt.messageId).toEqual("5516a151-4970-45b6-b73e-95aefdb11cbf"); 15 | expect(deliveryReceipt.status).toEqual("delivered"); 16 | }); 17 | 18 | it("parses sent delivery receipt from GoIP", () => { 19 | const message = 20 | "id:4791967 sub:0 dlvrd:1 submit date:2302281329 done date:2302281329 stat:ENROUTE err:0 Text:Hello World from out"; 21 | 22 | const deliveryReceipt = SMPPDeliveryReceipt.parse(message); 23 | 24 | expect(deliveryReceipt.messageId).toEqual("4791967"); 25 | expect(deliveryReceipt.status).toEqual("sent"); 26 | }); 27 | 28 | it("parses failed delivery receipt from GoIP", () => { 29 | const message = 30 | "id:88316820 sub:0 dlvrd:0 submit date:2302281324 done date:2302281325 stat:UNDELIV err:500 Text:Hello World from out"; 31 | 32 | const deliveryReceipt = SMPPDeliveryReceipt.parse(message); 33 | 34 | expect(deliveryReceipt.messageId).toEqual("88316820"); 35 | expect(deliveryReceipt.status).toEqual("failed"); 36 | }); 37 | 38 | it("parses rejected receipt from Kannel", () => { 39 | const message = 40 | "id:577e505a-dbd7-46ab-a04c-5a927ffb85b1 sub:001 dlvrd:000 submit date:2408201338 done date:240820093823 stat:REJECTD err:00B text:You"; 41 | 42 | const deliveryReceipt = SMPPDeliveryReceipt.parse(message); 43 | 44 | expect(deliveryReceipt.status).toEqual("failed"); 45 | }); 46 | 47 | it("parses a expired receipt", () => { 48 | const message = 49 | "id:0003493221 sub:001 dlvrd:001 submit date:2511240413 done date:2511260413 stat:EXPIRED err:254 text:??????????"; 50 | 51 | const deliveryReceipt = SMPPDeliveryReceipt.parse(message); 52 | 53 | expect(deliveryReceipt.status).toEqual("failed"); 54 | }); 55 | 56 | it("handles unsupported message format", () => { 57 | const message = 58 | "id:88316820 sub:0 dlvrd:0 submit date:2302281324 done date:2302281325 err:500 Text:Hello World from out"; 59 | 60 | expect(() => { 61 | SMPPDeliveryReceipt.parse(message); 62 | }).toThrow(InvalidMessageFormatError); 63 | }); 64 | 65 | it("handles unsupported delivery status", () => { 66 | const message = 67 | "id:5516a151-4970-45b6-b73e-95aefdb11cbf sub:001 dlvrd:001 submit date:2302231414 done date:230223194211 stat:FOOBAR err:000 text:Hello"; 68 | 69 | expect(() => { 70 | SMPPDeliveryReceipt.parse(message); 71 | }).toThrow(UnsupportedDeliveryStatusError); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /lib/http_server/http_server.js: -------------------------------------------------------------------------------- 1 | import * as http from "http"; 2 | 3 | const HTML_CONTENT = ` 4 | 5 | 6 | 7 | Somleng SMS Gateway 8 | 9 | 10 | 65 | 66 | 67 | 68 |
69 |

Somleng SMS Gateway

70 | 71 |
72 |

Somleng Platform

73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 |
ParameterValue
connectionStatus{{somleng_connection_status}}
83 | 84 |

Gateway Parameters

85 | 86 | 87 | 88 | 89 | 90 | {{gateway_parameters}} 91 |
ParameterValue
92 |
93 |
94 | 95 | 96 | 97 | `; 98 | 99 | class HTTPServer { 100 | constructor(config) { 101 | this.port = config.port; 102 | this.server = http.createServer(this.#requestListener.bind(this)); 103 | 104 | this.somlengClient = config.somlengClient; 105 | this.gateway = config.gateway; 106 | } 107 | 108 | start() { 109 | this.server.listen(this.port, () => { 110 | console.log(`Server is running on http://0.0.0.0:${this.port}`); 111 | }); 112 | } 113 | 114 | #requestListener(request, response) { 115 | response.setHeader("Content-Type", "text/html"); 116 | let content = HTML_CONTENT; 117 | 118 | if (this.gateway.isConnected() && this.somlengClient.isConnected()) { 119 | response.writeHead(200); 120 | } else { 121 | response.writeHead(500); 122 | } 123 | 124 | content = content.replace( 125 | "{{somleng_connection_status}}", 126 | this.#connectionStatus(this.somlengClient.isConnected()), 127 | ); 128 | content = content.replace("{{gateway_parameters}}", this.#gatewayParameters()); 129 | 130 | response.end(content); 131 | } 132 | 133 | #connectionStatus(isConnected) { 134 | return isConnected ? "Connected" : "Disconnected"; 135 | } 136 | 137 | #gatewayParameters() { 138 | let content = ` 139 | 140 | connectionStatus 141 | ${this.#connectionStatus(this.gateway.isConnected())} 142 | `; 143 | 144 | for (const [key, value] of Object.entries(this.gateway.config())) { 145 | content += ` 146 | 147 | ${key} 148 | ${value} 149 | 150 | `; 151 | } 152 | 153 | return content; 154 | } 155 | } 156 | 157 | export default HTTPServer; 158 | -------------------------------------------------------------------------------- /infrastructure/modules/app/ecs.tf: -------------------------------------------------------------------------------- 1 | resource "aws_ecs_cluster" "this" { 2 | name = var.app_identifier 3 | 4 | setting { 5 | name = "containerInsights" 6 | value = "disabled" 7 | } 8 | } 9 | 10 | resource "aws_ecs_task_definition" "this" { 11 | family = var.app_identifier 12 | network_mode = "awsvpc" 13 | requires_compatibilities = ["EC2"] 14 | container_definitions = jsonencode([ 15 | { 16 | name = "app", 17 | image = "${var.app_image}:latest", 18 | logConfiguration = { 19 | logDriver = "awslogs" 20 | options = { 21 | awslogs-group = aws_cloudwatch_log_group.app.name, 22 | awslogs-region = var.region.aws_region, 23 | } 24 | }, 25 | startTimeout = 120, 26 | healthCheck = { 27 | command = ["CMD-SHELL", "wget --server-response --spider --quiet http://localhost:3210 2>&1 | grep '200 OK' > /dev/null"], 28 | interval = 10, 29 | retries = 10, 30 | timeout = 5 31 | }, 32 | essential = true, 33 | portMappings = [ 34 | { 35 | containerPort = 3210 36 | } 37 | ], 38 | secrets = [ 39 | { 40 | name = "DEVICE_KEY", 41 | valueFrom = aws_ssm_parameter.device_key.name 42 | }, 43 | { 44 | name = "SMPP_HOST", 45 | valueFrom = aws_ssm_parameter.smpp_host.name 46 | }, 47 | { 48 | name = "SMPP_USERNAME", 49 | valueFrom = aws_ssm_parameter.smpp_username.name 50 | }, 51 | { 52 | name = "SMPP_PASSWORD", 53 | valueFrom = aws_ssm_parameter.smpp_password.name 54 | }, 55 | ], 56 | environment = [ 57 | { 58 | name = "SMPP_PORT", 59 | value = var.smpp_port 60 | } 61 | ], 62 | command = [ 63 | "sh", 64 | "-c", 65 | "somleng-sms-gateway smpp -v -e production -k $DEVICE_KEY --flash-sms-encoding --smpp-host $SMPP_HOST --smpp-port $SMPP_PORT --smpp-system-id $SMPP_USERNAME --smpp-password $SMPP_PASSWORD" 66 | ] 67 | } 68 | ]) 69 | 70 | task_role_arn = aws_iam_role.ecs_task_role.arn 71 | execution_role_arn = aws_iam_role.task_execution_role.arn 72 | memory = module.container_instances.ec2_instance_type.memory_size - 512 73 | } 74 | 75 | # Capacity Provider 76 | resource "aws_ecs_capacity_provider" "this" { 77 | name = var.app_identifier 78 | 79 | auto_scaling_group_provider { 80 | auto_scaling_group_arn = module.container_instances.autoscaling_group.arn 81 | managed_termination_protection = "ENABLED" 82 | managed_draining = "ENABLED" 83 | 84 | managed_scaling { 85 | maximum_scaling_step_size = 1000 86 | minimum_scaling_step_size = 1 87 | status = "ENABLED" 88 | target_capacity = 100 89 | } 90 | } 91 | } 92 | 93 | resource "aws_ecs_cluster_capacity_providers" "this" { 94 | cluster_name = aws_ecs_cluster.this.name 95 | 96 | capacity_providers = [ 97 | aws_ecs_capacity_provider.this.name, 98 | "FARGATE" 99 | ] 100 | } 101 | 102 | 103 | resource "aws_ecs_service" "this" { 104 | name = var.app_identifier 105 | cluster = aws_ecs_cluster.this.id 106 | task_definition = aws_ecs_task_definition.this.arn 107 | desired_count = 1 108 | 109 | capacity_provider_strategy { 110 | capacity_provider = aws_ecs_capacity_provider.this.name 111 | weight = 1 112 | } 113 | 114 | network_configuration { 115 | subnets = var.region.vpc.private_subnets 116 | security_groups = [ 117 | aws_security_group.this.id, 118 | ] 119 | } 120 | 121 | deployment_circuit_breaker { 122 | enable = true 123 | rollback = true 124 | } 125 | 126 | placement_constraints { 127 | type = "distinctInstance" 128 | } 129 | 130 | depends_on = [ 131 | aws_iam_role.task_execution_role 132 | ] 133 | 134 | lifecycle { 135 | ignore_changes = [task_definition, desired_count] 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /lib/gateways/smpp_gateway.js: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/node"; 2 | import smpp from "smpp"; 3 | import { 4 | SMPPDeliveryReceipt, 5 | InvalidMessageFormatError, 6 | UnsupportedDeliveryStatusError, 7 | } from "./smpp_delivery_receipt.js"; 8 | 9 | const GSM_FLASH_ENCODING = 0x10; 10 | const UCS2_FLASH_ENCODING = 0x18; 11 | 12 | class SMPPGateway { 13 | #onDeliveryReceiptCallback; 14 | #onReceivedCallback; 15 | #session; 16 | #reconnectInterval = false; 17 | #isConnected = false; 18 | 19 | constructor(config) { 20 | this.host = config.host; 21 | this.port = config.port; 22 | this.systemId = config.systemId; 23 | this.password = config.password; 24 | this.flashSmsEncoding = config.flashSmsEncoding; 25 | this.debug = config.debug; 26 | } 27 | 28 | config() { 29 | return { 30 | host: this.host, 31 | port: this.port, 32 | systemId: this.systemId, 33 | password: this.password, 34 | }; 35 | } 36 | 37 | async connect() { 38 | this.#createConnectionSession(); 39 | this.#handleReconnection(); 40 | this.#handleDeliverSM(); 41 | } 42 | 43 | isConnected() { 44 | return this.#isConnected; 45 | } 46 | 47 | sendMessage(params) { 48 | return new Promise((resolve) => { 49 | const submitSmParams = { 50 | registered_delivery: 1, 51 | source_addr: params.source, 52 | destination_addr: params.destination, 53 | }; 54 | 55 | if (this.flashSmsEncoding) { 56 | const encoding = smpp.encodings.detect(params.shortMessage); 57 | 58 | switch (encoding) { 59 | case "ASCII": 60 | submitSmParams.short_message = smpp.encodings.ASCII.encode(params.shortMessage); 61 | submitSmParams.data_coding = GSM_FLASH_ENCODING; 62 | break; 63 | case "LATIN1": 64 | submitSmParams.short_message = smpp.encodings.LATIN1.encode(params.shortMessage); 65 | submitSmParams.data_coding = GSM_FLASH_ENCODING; 66 | break; 67 | default: 68 | submitSmParams.short_message = smpp.encodings.UCS2.encode(params.shortMessage); 69 | submitSmParams.data_coding = UCS2_FLASH_ENCODING; 70 | } 71 | } else { 72 | submitSmParams.short_message = params.shortMessage; 73 | } 74 | 75 | this.#session.submit_sm(submitSmParams, (pdu) => { 76 | resolve({ messageId: pdu.message_id, commandStatus: pdu.command_status }); 77 | }); 78 | }); 79 | } 80 | 81 | onReceived(callback) { 82 | this.#onReceivedCallback = callback; 83 | } 84 | 85 | onDeliveryReceipt(callback) { 86 | this.#onDeliveryReceiptCallback = callback; 87 | } 88 | 89 | #handleDeliverSM() { 90 | this.#session.on("deliver_sm", (pdu) => { 91 | // Send deliver_sm_resp 92 | this.#session.send(pdu.response()); 93 | 94 | const rawMessage = pdu.short_message.message; 95 | 96 | if (pdu.esm_class === smpp.ESM_CLASS.MC_DELIVERY_RECEIPT) { 97 | try { 98 | const deliveryReceipt = SMPPDeliveryReceipt.parse(rawMessage); 99 | this.#onDeliveryReceiptCallback && this.#onDeliveryReceiptCallback(deliveryReceipt); 100 | } catch (error) { 101 | if ( 102 | error instanceof InvalidMessageFormatError || 103 | error instanceof UnsupportedDeliveryStatusError 104 | ) { 105 | Sentry.captureException(error, { extra: { rawMessage: rawMessage } }); 106 | console.log("Unsupported delivery receipt message: ", rawMessage); 107 | } 108 | } 109 | } else { 110 | this.#onReceivedCallback && 111 | this.#onReceivedCallback({ 112 | source: pdu.source_addr, 113 | destination: pdu.destination_addr, 114 | shortMessage: rawMessage, 115 | }); 116 | } 117 | }); 118 | } 119 | 120 | #createConnectionSession() { 121 | const smppConfig = { 122 | url: `smpp://${this.host}:${this.port}`, 123 | auto_enquire_link_period: 10000, 124 | debug: this.debug, 125 | }; 126 | this.#session = smpp.connect(smppConfig, () => { 127 | this.#session.bind_transceiver( 128 | { system_id: this.systemId, password: this.password }, 129 | (pdu) => { 130 | if (pdu.command_status !== 0) { 131 | throw new Error("Failed to connect to SMPP Server!"); 132 | } 133 | }, 134 | ); 135 | }); 136 | } 137 | 138 | #handleReconnection() { 139 | // Keep the program running while the connection dropped 140 | this.#session.on("error", () => {}); 141 | this.#session.socket.on("readable", () => { 142 | this.#isConnected = true; 143 | 144 | if (this.#reconnectInterval !== false) { 145 | clearInterval(this.#reconnectInterval); 146 | this.#reconnectInterval = false; 147 | } 148 | }); 149 | 150 | this.#session.socket.on("close", () => { 151 | this.#isConnected = false; 152 | 153 | if (this.#reconnectInterval === false) { 154 | this.#reconnectInterval = setInterval(() => { 155 | this.#session.connect(); 156 | this.#session.resume(); 157 | }, 3000); 158 | } 159 | }); 160 | } 161 | } 162 | 163 | export default SMPPGateway; 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Somleng SMS Gateway 2 | 3 | [![GitHub Action](https://github.com/somleng/sms-gateway/actions/workflows/build.yml/badge.svg)](https://github.com/somleng/sms-gateway/actions) 4 | 5 | Somleng SMS Gateway (part of [The Somleng Project](https://github.com/somleng/somleng-project)) is used to set up your own on-premise SMS gateway system and connect it to Somleng. This will give you the ability to take full control of your SMS infrastructure. 6 | 7 | ![Somleng SMS Gateway](assets/diagram.png) 8 | 9 | ## Download and Install the SMS Gateway 10 | 11 | ### Docker (Recommended for most users) 12 | 13 | ```sh 14 | docker pull somleng/sms-gateway 15 | ``` 16 | 17 | ### Downloading a pre-built binary 18 | 19 | Download the [latest release](https://github.com/somleng/sms-gateway/releases) for your operating system. Note that currently we only have pre-built binaries for Linux. 20 | 21 | ## Building from source 22 | 23 | This will build a new single executable application binary for your OS. 24 | 25 | ```sh 26 | npm run dist 27 | ``` 28 | 29 | ## Usage 30 | 31 | ### Synopsis 32 | 33 | ```sh 34 | Usage: somleng-sms-gateway [options] [command] 35 | 36 | Options: 37 | -k, --key Device key 38 | -d, --domain Somleng Domain (default: "wss://app.somleng.org") 39 | -p, --http-server-port HTTP Server Port (default: "3210") 40 | -e, --environment Environment (production or development) (default: "development") 41 | -v, --verbose Output extra debugging 42 | -h, --help display help for command 43 | 44 | Commands: 45 | goip [options] connect to GoIP Gateway 46 | smpp [options] connect to SMPP Gateway 47 | dummy connect to dummy Gateway 48 | help [command] display help for commandd 49 | ``` 50 | 51 | The application supports a number of connection modes including: 52 | 53 | * [SMPP](#smpp) 54 | * [GoIP (GSM Gateway)](#goip-gsm-gateway) 55 | * [Dummy](#dummy) 56 | 57 | ### Running with Docker 58 | 59 | ```sh 60 | docker run -p 3210:3210 somleng/sms-gateway somleng-sms-gateway [options] [command] 61 | ``` 62 | 63 | ### Web Interface 64 | 65 | The Web interface displays connection information and configuration parameters. By default, it's available 66 | at [http://localhost:3210](http://localhost:3210). 67 | 68 | ![SMPP Gateway Web Interface](assets/sms_gateway_connection_status.png) 69 | 70 | ### Connection Modes 71 | 72 | The documentation for running the SMS Gateway for each connection mode can be found below. 73 | 74 | #### SMPP 75 | 76 | The SMPP connection mode is used to connect to a single SMPP server. 77 | 78 | ```sh 79 | Usage: somleng-sms-gateway smpp [options] 80 | 81 | connect to SMPP Gateway 82 | 83 | Options: 84 | --smpp-host SMPP host 85 | --smpp-port SMPP port (default: "2775") 86 | --smpp-system-id SMPP System ID 87 | --smpp-password SMPP password 88 | -h, --help display help for command 89 | ``` 90 | 91 | #### GoIP (GSM Gateway) 92 | 93 | The GoIP connection mode is used to connect to a [GoIP GSM Gateway](https://en.wikipedia.org/wiki/GoIP) 94 | 95 | ##### GSM Gateway Configuration 96 | 97 | Using the GoIP GSM Gateway web portal, configure the following: 98 | 99 | Under **Configurations** -> **Preferences**: 100 | 101 | 1. Set SMPP SMSC to **Enable** 102 | 2. Fill in **ID** with an SMPP system ID 103 | 3. Fill in **Password** with an SMPP password 104 | 4. Fill in **Port** with an SMPP port 105 | 5. Set Channel number to **Enable** 106 | 6. Set ENROUTE status to **Enable** 107 | 108 | ![GoIP Configuration Preferences](assets/goip-sms-1_configurations_preferences.png) 109 | 110 | Under **Configurations** -> **SIM**: 111 | 112 | 1. Set **Try to Get SIM Number from SIM Card** to **Disable** 113 | 2. For each channel on your gateway, enter the phone number for the SIM card in E.164 format. 114 | 115 | *Note: We recommend that you manually configure the phone numbers for each SIM in your gateway to ensure 116 | the format is in E.164. These numbers must exactly match the phone numbers that you configure on the [Somleng Dashboard](https://www.somleng.org/docs.html#sms_gateway_configuration_guide_create_phone_number).* 117 | 118 | ![GoIP Configuration SIM](assets/goip-sms-2_configurations_sim.png) 119 | 120 | ##### Start the SMS Gateway 121 | 122 | Start the SMS Gateway in GoIP mode, ensuring that the configuration parameters match what you configured above. 123 | 124 | ```sh 125 | Usage: somleng-sms-gateway goip [options] 126 | 127 | connect to GoIP Gateway 128 | 129 | Options: 130 | --goip-smpp-host SMPP host 131 | --goip-smpp-port SMPP port (default: "2775") 132 | --goip-smpp-system-id SMPP System ID 133 | --goip-smpp-password SMPP password 134 | --goip-channels Number of channels 135 | -h, --help display help for command 136 | ``` 137 | 138 | #### Dummy 139 | 140 | The Dummy connection mode is used to test the connection between the SMS Gateway and Somleng. 141 | Outbound messages are simply output to stdout. 142 | 143 | ```sh 144 | Usage: somleng-sms-gateway dummy [options] 145 | 146 | connect to dummy Gateway 147 | 148 | Options: 149 | -h, --help display help for command 150 | ``` 151 | 152 | ## License 153 | 154 | The software is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 155 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import * as Sentry from "@sentry/node"; 4 | import packageJson from "../package.json" with { type: "json" }; 5 | 6 | import { program } from "commander"; 7 | import { createLogger, format, transports } from "winston"; 8 | 9 | import SomlengClient from "../lib/somleng_client.js"; 10 | import HTTPServer from "../lib/http_server/index.js"; 11 | import { GoIPGateway, SMPPGateway, DummyGateway } from "../lib/gateways/index.js"; 12 | 13 | const SENTRY_DSN = 14 | "https://b4c80554595b4e75a9904318a8fe005d@o125014.ingest.us.sentry.io/4504756942864384"; 15 | 16 | async function main() { 17 | let options = {}; 18 | let gateway; 19 | let outboundQueue = new Map(); 20 | 21 | program 22 | .requiredOption("-k, --key ", "Device key") 23 | .requiredOption("-d, --domain ", "Somleng Domain", "wss://app.somleng.org") 24 | .requiredOption("-p, --http-server-port ", "HTTP Server Port", "3210") 25 | .option("-e, --environment ", "Environment (production or development)", "development") 26 | .option("-v, --verbose", "Output extra debugging") 27 | .showHelpAfterError(); 28 | 29 | program 30 | .command("goip") 31 | .description("connect to GoIP Gateway") 32 | .requiredOption("--goip-smpp-host ", "SMPP host") 33 | .requiredOption("--goip-smpp-port ", "SMPP port", "2775") 34 | .requiredOption("--goip-smpp-system-id ", "SMPP System ID") 35 | .requiredOption("--goip-smpp-password ", "SMPP password") 36 | .requiredOption("--goip-channels ", "Number of channels") 37 | .action((commandOptions) => { 38 | gateway = new GoIPGateway({ 39 | host: commandOptions.goipSmppHost, 40 | port: commandOptions.goipSmppPort, 41 | systemId: commandOptions.goipSmppSystemId, 42 | password: commandOptions.goipSmppPassword, 43 | channels: commandOptions.goipChannels, 44 | debug: program.opts().verbose, 45 | }); 46 | }); 47 | 48 | program 49 | .command("smpp") 50 | .description("connect to SMPP Gateway") 51 | .requiredOption("--smpp-host ", "SMPP host") 52 | .requiredOption("--smpp-port ", "SMPP port", "2775") 53 | .requiredOption("--smpp-system-id ", "SMPP System ID") 54 | .requiredOption("--smpp-password ", "SMPP password") 55 | .option("--flash-sms-encoding", "Enable Flash SMS encoding") 56 | .action((commandOptions) => { 57 | gateway = new SMPPGateway({ 58 | host: commandOptions.smppHost, 59 | port: commandOptions.smppPort, 60 | systemId: commandOptions.smppSystemId, 61 | password: commandOptions.smppPassword, 62 | flashSmsEncoding: commandOptions.flashSmsEncoding, 63 | debug: program.opts().verbose, 64 | }); 65 | }); 66 | 67 | program 68 | .command("dummy") 69 | .description("connect to dummy Gateway") 70 | .action((commandOptions) => { 71 | gateway = new DummyGateway(); 72 | }); 73 | 74 | program.parse(); 75 | options = { ...options, ...program.opts() }; 76 | 77 | Sentry.init({ 78 | dsn: options.environment === "production" ? SENTRY_DSN : null, 79 | release: packageJson.version, 80 | }); 81 | 82 | const logger = createLogger({ 83 | level: options.verbose ? "debug" : "info", 84 | format: format.combine( 85 | format.label({ label: "Somleng SMS Gateway" }), 86 | format.timestamp(), 87 | format.json(), 88 | ), 89 | transports: [new transports.Console()], 90 | }); 91 | 92 | logger.debug("Connecting to Gateway"); 93 | await gateway.connect(); 94 | 95 | const client = new SomlengClient({ 96 | domain: options.domain, 97 | deviceKey: options.key, 98 | logger: logger, 99 | }); 100 | 101 | const httpServer = new HTTPServer({ 102 | port: options.httpServerPort, 103 | gateway: gateway, 104 | somlengClient: client, 105 | }); 106 | httpServer.start(); 107 | 108 | const notifyMessageStatus = async (id, status) => { 109 | return await client.notifyMessageStatus({ 110 | id: id, 111 | status: status, 112 | }); 113 | }; 114 | 115 | logger.debug("Connecting to Somleng"); 116 | await client.subscribe(); 117 | 118 | client.onNewMessage(async (message) => { 119 | try { 120 | const response = await gateway.sendMessage({ 121 | channel: message.channel, 122 | source: message.from, 123 | destination: message.to, 124 | shortMessage: message.body, 125 | }); 126 | 127 | logger.debug("Sending a message", message); 128 | 129 | if (response.messageId && response.messageId.toString().length) { 130 | outboundQueue.set(response.messageId, { messageId: message.id }); 131 | 132 | await notifyMessageStatus(message.id, "sent"); 133 | } else { 134 | await notifyMessageStatus(message.id, "failed"); 135 | } 136 | } catch (e) { 137 | console.error(e.message); 138 | await notifyMessageStatus(message.id, "failed"); 139 | } 140 | }); 141 | 142 | gateway.onDeliveryReceipt(async (deliveryReceipt) => { 143 | logger.debug("onDeliveryReceipt", deliveryReceipt); 144 | 145 | const numericMessageId = parseInt(deliveryReceipt.messageId).toString(); 146 | let queueKey; 147 | 148 | if (outboundQueue.has(deliveryReceipt.messageId)) { 149 | queueKey = deliveryReceipt.messageId; 150 | } else if (outboundQueue.has(numericMessageId)) { 151 | queueKey = numericMessageId; 152 | } 153 | 154 | if (queueKey) { 155 | const outboundMessage = outboundQueue.get(queueKey); 156 | 157 | logger.debug("notifyMessageStatus: ", outboundMessage.messageId); 158 | 159 | // "sent" is handled in onNewMessage 160 | if (deliveryReceipt.status !== "sent") { 161 | await notifyMessageStatus(outboundMessage.messageId, deliveryReceipt.status); 162 | } 163 | 164 | outboundQueue.delete(queueKey); 165 | } 166 | }); 167 | 168 | gateway.onReceived(async (message) => { 169 | logger.debug("onReceived", message); 170 | 171 | client.receivedMessage({ 172 | from: message.source, 173 | to: message.destination, 174 | body: message.shortMessage, 175 | }); 176 | }); 177 | } 178 | 179 | main().catch((error) => { 180 | console.error(error); 181 | Sentry.captureException(error); 182 | 183 | // Cannot use process.exit(1) here because it will not trigger the Sentry error capture 184 | process.exitCode = 1; 185 | }); 186 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: Build 3 | 4 | jobs: 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | 9 | env: 10 | CI: true 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v6 15 | 16 | - name: Setup Node 17 | uses: actions/setup-node@v6 18 | with: 19 | node-version-file: ".tool-versions" 20 | cache: "npm" 21 | 22 | - name: Setup dependencies 23 | run: npm ci 24 | 25 | - name: Run Tests 26 | run: npm run test 27 | 28 | release: 29 | name: Release 30 | runs-on: ubuntu-latest 31 | if: github.ref == 'refs/heads/main' 32 | outputs: 33 | release_created: ${{ steps.release-please.outputs.release_created }} 34 | release_tag: ${{ steps.release-please.outputs.tag_name }} 35 | needs: 36 | - build 37 | 38 | steps: 39 | - name: Checkout 40 | uses: actions/checkout@v6 41 | 42 | - uses: googleapis/release-please-action@v4 43 | id: release-please 44 | with: 45 | token: ${{ secrets.SOMLENG_PERSONAL_ACCESS_TOKEN }} 46 | 47 | build-packages: 48 | name: Build Packages 49 | runs-on: ubuntu-latest 50 | needs: 51 | - release 52 | if: ${{ needs.release.outputs.release_created }} 53 | strategy: 54 | # Note Alpine isn't supported: https://nodejs.org/api/single-executable-applications.html#platform-support 55 | matrix: 56 | os: [linux] 57 | platform: [amd64, arm64] 58 | 59 | outputs: 60 | package_name: ${{ steps.get-package-name.outputs.package_name }} 61 | package_version: ${{ steps.get-package-name.outputs.package_version }} 62 | 63 | steps: 64 | - name: Checkout 65 | uses: actions/checkout@v6 66 | 67 | - name: Set up Docker Buildx 68 | uses: docker/setup-buildx-action@v3 69 | 70 | - name: Build Packages 71 | run: | 72 | docker buildx build --output type=local,dest=build --platform linux/${{ matrix.platform }} --target export-${{ matrix.os }} . 73 | 74 | - name: Get package name 75 | id: get-package-name 76 | run: | 77 | package_version=$(cat build/package_version.txt) 78 | package_name=$(cat build/package_name.txt) 79 | full_package_name=$(cat build/full_package_name.txt) 80 | echo "package_version=$package_version" >> $GITHUB_OUTPUT 81 | echo "package_name=$package_name" >> $GITHUB_OUTPUT 82 | echo "full_package_name=$full_package_name" >> $GITHUB_OUTPUT 83 | 84 | - name: Upload artifacts 85 | uses: actions/upload-artifact@v6 86 | with: 87 | name: ${{ steps.get-package-name.outputs.full_package_name }} 88 | path: build/${{ steps.get-package-name.outputs.full_package_name }} 89 | retention-days: 1 90 | 91 | upload-packages: 92 | name: Upload Packages 93 | runs-on: ubuntu-latest 94 | needs: 95 | - build-packages 96 | - release 97 | steps: 98 | - name: Checkout 99 | uses: actions/checkout@v6 100 | 101 | - name: Download All Artifacts 102 | uses: actions/download-artifact@v6 103 | with: 104 | path: build 105 | pattern: ${{ needs.build-packages.outputs.package_name }}-* 106 | merge-multiple: true 107 | 108 | - name: Add assets to Github Release 109 | env: 110 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 111 | run: | 112 | declare -a oss=("linux") 113 | declare -a platforms=("x86_64" "aarch64") 114 | 115 | for os in "${oss[@]}" 116 | do 117 | for platform in "${platforms[@]}" 118 | do 119 | pkg="${{ needs.build-packages.outputs.package_name }}-$os-$platform-v${{ needs.build-packages.outputs.package_version }}.zip" 120 | cp build/${{ needs.build-packages.outputs.package_name }}-$os-$platform-v${{ needs.build-packages.outputs.package_version }} ${{ needs.build-packages.outputs.package_name }} 121 | zip $pkg ${{ needs.build-packages.outputs.package_name }} 122 | 123 | gh release upload ${{ needs.release.outputs.release_tag }} $pkg 124 | done 125 | done 126 | 127 | - name: Log in to Docker Hub 128 | uses: docker/login-action@v3 129 | with: 130 | username: ${{ secrets.DOCKERHUB_USERNAME }} 131 | password: ${{ secrets.DOCKERHUB_PASSWORD }} 132 | 133 | - name: Login to GitHub Container Registry 134 | uses: docker/login-action@v3 135 | with: 136 | registry: ghcr.io 137 | username: ${{ github.repository_owner }} 138 | password: ${{ secrets.GITHUB_TOKEN }} 139 | 140 | - name: Extract metadata (tags, labels) for Docker 141 | id: meta 142 | uses: docker/metadata-action@v5 143 | with: 144 | images: | 145 | somleng/sms-gateway 146 | ghcr.io/somleng/sms-gateway 147 | tags: | 148 | # set latest tag for main branch 149 | type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'main') }} 150 | type=raw,value=${{ needs.release.outputs.release_tag }} 151 | 152 | - name: Set up Docker Buildx 153 | uses: docker/setup-buildx-action@v3 154 | 155 | - name: Build and push Docker image 156 | uses: docker/build-push-action@v6 157 | with: 158 | context: . 159 | push: true 160 | platforms: linux/amd64,linux/arm64 161 | tags: ${{ steps.meta.outputs.tags }} 162 | labels: ${{ steps.meta.outputs.labels }} 163 | 164 | deploy: 165 | name: Deploy 166 | runs-on: ubuntu-latest 167 | needs: 168 | - release 169 | - upload-packages 170 | steps: 171 | - name: Configure AWS credentials 172 | uses: aws-actions/configure-aws-credentials@v5 173 | with: 174 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 175 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 176 | role-to-assume: ${{ secrets.AWS_ROLE_TO_ASSUME }} 177 | role-skip-session-tagging: true 178 | role-duration-seconds: 3600 179 | aws-region: ap-southeast-1 180 | 181 | - name: Get current app task definition 182 | run: | 183 | aws ecs describe-task-definition --task-definition sms-gateway --query 'taskDefinition' > task-definition.json 184 | 185 | - name: Inject new APP image into app task definition 186 | id: render-app-task-def 187 | uses: aws-actions/amazon-ecs-render-task-definition@v1 188 | with: 189 | task-definition: task-definition.json 190 | container-name: app 191 | image: ghcr.io/somleng/sms-gateway:${{ needs.release.outputs.release_tag }} 192 | 193 | - name: Deploy App 194 | uses: aws-actions/amazon-ecs-deploy-task-definition@v2 195 | with: 196 | task-definition: ${{ steps.render-app-task-def.outputs.task-definition }} 197 | service: sms-gateway 198 | cluster: sms-gateway 199 | wait-for-service-stability: true 200 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [2.0.5](https://github.com/somleng/sms-gateway/compare/v2.0.4...v2.0.5) (2025-12-10) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * Fix build ([#610](https://github.com/somleng/sms-gateway/issues/610)) ([fc254e8](https://github.com/somleng/sms-gateway/commit/fc254e8c6147164f3324f6a07fe4bd6adb58af22)) 9 | 10 | ## [2.0.4](https://github.com/somleng/sms-gateway/compare/v2.0.3...v2.0.4) (2025-12-10) 11 | 12 | 13 | ### Bug Fixes 14 | 15 | * Improve Dockerfile ([#608](https://github.com/somleng/sms-gateway/issues/608)) ([8d4c315](https://github.com/somleng/sms-gateway/commit/8d4c3157b58dc0b0f40283ceb75edbba3407e7d4)) 16 | 17 | ## [2.0.3](https://github.com/somleng/sms-gateway/compare/v2.0.2...v2.0.3) (2025-11-26) 18 | 19 | 20 | ### Bug Fixes 21 | 22 | * Handle expired delivery receipts ([#599](https://github.com/somleng/sms-gateway/issues/599)) ([89b01ea](https://github.com/somleng/sms-gateway/commit/89b01eac0115a944ec833f61b3e9c72ab8b8dcfb)) 23 | 24 | ## [2.0.2](https://github.com/somleng/sms-gateway/compare/v2.0.1...v2.0.2) (2025-11-08) 25 | 26 | 27 | ### Bug Fixes 28 | 29 | * docker build ([b80f889](https://github.com/somleng/sms-gateway/commit/b80f889cd7ebd026ddd6e4633ed33e5623407724)) 30 | * docker build ([816c68e](https://github.com/somleng/sms-gateway/commit/816c68e92b0ed9a6ce643aab7362231108d4ed86)) 31 | 32 | ## [2.0.1](https://github.com/somleng/sms-gateway/compare/v2.0.0...v2.0.1) (2025-11-08) 33 | 34 | 35 | ### Bug Fixes 36 | 37 | * trigger build ([67d4e02](https://github.com/somleng/sms-gateway/commit/67d4e021bf6edb10b7d294e6a819ca9f36050aeb)) 38 | 39 | ## [2.0.0](https://github.com/somleng/sms-gateway/compare/v1.2.25...v2.0.0) (2025-11-08) 40 | 41 | 42 | ### ⚠ BREAKING CHANGES 43 | 44 | * Verify before sending the actual message ([#573](https://github.com/somleng/sms-gateway/issues/573)) 45 | 46 | ### Features 47 | 48 | * Verify before sending the actual message ([#573](https://github.com/somleng/sms-gateway/issues/573)) ([5dd48fe](https://github.com/somleng/sms-gateway/commit/5dd48fefa685cc1170364639c400f2d0bae9a361)) 49 | 50 | ## [1.2.25](https://github.com/somleng/sms-gateway/compare/v1.2.24...v1.2.25) (2025-09-25) 51 | 52 | 53 | ### Bug Fixes 54 | 55 | * Fix logging of delivery receipts ([#565](https://github.com/somleng/sms-gateway/issues/565)) ([994cca3](https://github.com/somleng/sms-gateway/commit/994cca390ffc6d3126d002b418f4e1c391ef1f63)) 56 | 57 | ## [1.2.24](https://github.com/somleng/sms-gateway/compare/v1.2.23...v1.2.24) (2025-09-25) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * Handle padded message ids in delivery receipts ([#563](https://github.com/somleng/sms-gateway/issues/563)) ([6392853](https://github.com/somleng/sms-gateway/commit/63928539d8b5ec2e3c58bd6bb40a78d9ed329529)) 63 | 64 | ## [1.2.23](https://github.com/somleng/sms-gateway/compare/v1.2.22...v1.2.23) (2025-09-25) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * Fix logging ([#561](https://github.com/somleng/sms-gateway/issues/561)) ([e68bd3c](https://github.com/somleng/sms-gateway/commit/e68bd3ce9a88ac60d52ac77391fd3754ae2eb1ea)) 70 | 71 | ## [1.2.22](https://github.com/somleng/sms-gateway/compare/v1.2.21...v1.2.22) (2025-09-25) 72 | 73 | 74 | ### Bug Fixes 75 | 76 | * Enable Flash SMS ([#559](https://github.com/somleng/sms-gateway/issues/559)) ([1590424](https://github.com/somleng/sms-gateway/commit/1590424500675fc3fa32b4aa225d88838d6ac8d5)) 77 | 78 | ## [1.2.21](https://github.com/somleng/sms-gateway/compare/v1.2.20...v1.2.21) (2025-09-05) 79 | 80 | 81 | ### Bug Fixes 82 | 83 | * release ([4a8cd08](https://github.com/somleng/sms-gateway/commit/4a8cd087c62c4a174d58a6656c53d2d2ddcb059e)) 84 | 85 | ## [1.2.20](https://github.com/somleng/sms-gateway/compare/v1.2.19...v1.2.20) (2025-08-11) 86 | 87 | 88 | ### Bug Fixes 89 | 90 | * update release please github action ([f9b408d](https://github.com/somleng/sms-gateway/commit/f9b408dcf5bd14d371bca08e2af9bd435d594a53)) 91 | 92 | ## [1.2.19](https://github.com/somleng/sms-gateway/compare/v1.2.18...v1.2.19) (2024-11-29) 93 | 94 | 95 | ### Bug Fixes 96 | 97 | * Add package version for Sentry ([b886cff](https://github.com/somleng/sms-gateway/commit/b886cffcd593d96d264486df2c931c98c52d6ab0)) 98 | 99 | ## [1.2.18](https://github.com/somleng/sms-gateway/compare/v1.2.17...v1.2.18) (2024-11-29) 100 | 101 | 102 | ### Bug Fixes 103 | 104 | * release ([24f4dce](https://github.com/somleng/sms-gateway/commit/24f4dcee42015f900c1e2c5743bfd24b3890fdbc)) 105 | 106 | ## [1.2.17](https://github.com/somleng/sms-gateway/compare/v1.2.16...v1.2.17) (2024-08-21) 107 | 108 | 109 | ### Bug Fixes 110 | 111 | * Handle rejected delivery receipt ([#388](https://github.com/somleng/sms-gateway/issues/388)) ([cd4d29c](https://github.com/somleng/sms-gateway/commit/cd4d29c2771d852ff4d388f524a69e299405136e)) 112 | 113 | ## [1.2.16](https://github.com/somleng/sms-gateway/compare/v1.2.15...v1.2.16) (2024-04-14) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * Only upload artifacts after a release ([c65076d](https://github.com/somleng/sms-gateway/commit/c65076d4fdcf4e643771367509aedc63cebf0bd7)) 119 | * Use Docker to build binaries ([#324](https://github.com/somleng/sms-gateway/issues/324)) ([52640c8](https://github.com/somleng/sms-gateway/commit/52640c8448fe57e97a7e06cdbbf77244babbbde7)) 120 | 121 | ## [1.2.15](https://github.com/somleng/sms-gateway/compare/v1.2.14...v1.2.15) (2024-04-13) 122 | 123 | 124 | ### Bug Fixes 125 | 126 | * Improve Dockerfile ([#322](https://github.com/somleng/sms-gateway/issues/322)) ([cfd9c17](https://github.com/somleng/sms-gateway/commit/cfd9c178e2e4c3a86c07cfa9828f11f59332681c)) 127 | 128 | ## [1.2.14](https://github.com/somleng/sms-gateway/compare/v1.2.13...v1.2.14) (2024-04-13) 129 | 130 | 131 | ### Bug Fixes 132 | 133 | * Deploy to GH Container Registry ([#320](https://github.com/somleng/sms-gateway/issues/320)) ([246ca16](https://github.com/somleng/sms-gateway/commit/246ca16f5c0bd74a5a4451cdb9e881a4f045481e)) 134 | 135 | ## [1.2.13](https://github.com/somleng/sms-gateway/compare/v1.2.12...v1.2.13) (2024-04-11) 136 | 137 | 138 | ### Bug Fixes 139 | 140 | * Use actioncable-v1-ext for improved data consistency ([#315](https://github.com/somleng/sms-gateway/issues/315)) ([baa0688](https://github.com/somleng/sms-gateway/commit/baa0688fca1f696daaccd917d3e560b8329f073d)) 141 | 142 | ## [1.2.12](https://github.com/somleng/sms-gateway/compare/v1.2.11...v1.2.12) (2024-04-08) 143 | 144 | 145 | ### Bug Fixes 146 | 147 | * Handle smpp deliver sm resp ([#311](https://github.com/somleng/sms-gateway/issues/311)) ([01e30fd](https://github.com/somleng/sms-gateway/commit/01e30fdc416355fcbabbc560f47b5ff43976a9df)) 148 | 149 | ## [1.2.11](https://github.com/somleng/sms-gateway/compare/v1.2.10...v1.2.11) (2024-01-17) 150 | 151 | 152 | ### Bug Fixes 153 | 154 | * Use debian-slim in Dockerfile ([#260](https://github.com/somleng/sms-gateway/issues/260)) ([37cdc69](https://github.com/somleng/sms-gateway/commit/37cdc69345ec2b3e1aee28cea2e7c1075b5821ba)) 155 | 156 | ## [1.2.10](https://github.com/somleng/sms-gateway/compare/v1.2.9...v1.2.10) (2024-01-17) 157 | 158 | 159 | ### Bug Fixes 160 | 161 | * Generate new packages ([#258](https://github.com/somleng/sms-gateway/issues/258)) ([e830914](https://github.com/somleng/sms-gateway/commit/e8309149edfb5f15f23132740fe1b7853450297b)) 162 | 163 | ## [1.2.9](https://github.com/somleng/sms-gateway/compare/v1.2.8...v1.2.9) (2024-01-17) 164 | 165 | 166 | ### Bug Fixes 167 | 168 | * Build docker for amd64 and arm64 ([2717372](https://github.com/somleng/sms-gateway/commit/2717372a60f22cb690b3bf76e0f319d1c9087267)) 169 | 170 | ## [1.2.8](https://github.com/somleng/sms-gateway/compare/v1.2.7...v1.2.8) (2024-01-17) 171 | 172 | 173 | ### Bug Fixes 174 | 175 | * bump version ([0172ea6](https://github.com/somleng/sms-gateway/commit/0172ea6bfb2e8ac3df2d15b6bc6763910784c15b)) 176 | * remove component from tag ([c974ae0](https://github.com/somleng/sms-gateway/commit/c974ae0c4171d9095341feae43f9d46f14c1c65b)) 177 | 178 | ## [1.2.7](https://github.com/somleng/sms-gateway/compare/somleng-sms-gateway-v1.2.6...somleng-sms-gateway-v1.2.7) (2024-01-17) 179 | 180 | 181 | ### Bug Fixes 182 | 183 | * bump version ([0172ea6](https://github.com/somleng/sms-gateway/commit/0172ea6bfb2e8ac3df2d15b6bc6763910784c15b)) 184 | 185 | ## [1.2.6](https://github.com/somleng/sms-gateway/compare/v1.2.5...v1.2.6) (2023-02-28) 186 | 187 | 188 | ### Bug Fixes 189 | 190 | * delivery receipt parser ([#31](https://github.com/somleng/sms-gateway/issues/31)) ([c369a29](https://github.com/somleng/sms-gateway/commit/c369a293eff6ffc09bb0f90fe9bfc3541f4ba24f)) 191 | 192 | ## [1.2.5](https://github.com/somleng/sms-gateway/compare/v1.2.4...v1.2.5) (2022-12-09) 193 | 194 | 195 | ### Bug Fixes 196 | 197 | * Docker build push ([#28](https://github.com/somleng/sms-gateway/issues/28)) ([8ec87d2](https://github.com/somleng/sms-gateway/commit/8ec87d22e800dd47c3622ec588831c94f0c35691)) 198 | 199 | ## [1.2.4](https://github.com/somleng/sms-gateway/compare/v1.2.3...v1.2.4) (2022-12-09) 200 | 201 | 202 | ### Bug Fixes 203 | 204 | * Add instructions for running with Docker ([#26](https://github.com/somleng/sms-gateway/issues/26)) ([8dfbde9](https://github.com/somleng/sms-gateway/commit/8dfbde918da9d7c8de07e7a49e91b5fefb3eaed3)) 205 | 206 | ## [1.2.3](https://github.com/somleng/sms-gateway/compare/v1.2.2...v1.2.3) (2022-12-09) 207 | 208 | 209 | ### Bug Fixes 210 | 211 | * Build docker image ([#24](https://github.com/somleng/sms-gateway/issues/24)) ([6296de4](https://github.com/somleng/sms-gateway/commit/6296de4a8aabb12c1f072cda6e1fbf6cf7bde240)) 212 | 213 | ## [1.2.2](https://github.com/somleng/sms-gateway/compare/v1.2.1...v1.2.2) (2022-12-09) 214 | 215 | 216 | ### Bug Fixes 217 | 218 | * Add build for alpine ([#20](https://github.com/somleng/sms-gateway/issues/20)) ([6c9299b](https://github.com/somleng/sms-gateway/commit/6c9299b6190304c1c1f00aa4eed6014a55224b5e)) 219 | 220 | ## [1.2.1](https://github.com/somleng/sms-gateway/compare/v1.2.0...v1.2.1) (2022-12-08) 221 | 222 | 223 | ### Bug Fixes 224 | 225 | * readme ([779e99b](https://github.com/somleng/sms-gateway/commit/779e99bb5221d100d823191d703ecff3d7b5b404)) 226 | 227 | ## [1.2.0](https://github.com/somleng/sms-gateway/compare/v1.1.1...v1.2.0) (2022-12-08) 228 | 229 | 230 | ### Features 231 | 232 | * Expose SMPP Gateway ([#16](https://github.com/somleng/sms-gateway/issues/16)) ([d5df48d](https://github.com/somleng/sms-gateway/commit/d5df48d4381cc7de4440c696385cb93e9a0f02ab)) 233 | 234 | ## [1.1.1](https://github.com/somleng/sms-gateway/compare/v1.1.0...v1.1.1) (2022-12-08) 235 | 236 | 237 | ### Bug Fixes 238 | 239 | * Add Somleng Status & Gateway Parameters ([#14](https://github.com/somleng/sms-gateway/issues/14)) ([783ae0f](https://github.com/somleng/sms-gateway/commit/783ae0ff295d0370725ac186effa239f848d4081)) 240 | 241 | ## [1.1.0](https://github.com/somleng/sms-gateway/compare/v1.0.8...v1.1.0) (2022-12-07) 242 | 243 | 244 | ### Features 245 | 246 | * HTTP Server for displaying gateway connection status ([#12](https://github.com/somleng/sms-gateway/issues/12)) ([2e70b37](https://github.com/somleng/sms-gateway/commit/2e70b379681ef9f0aef579743a5e51bd75595bab)) 247 | 248 | ## [1.0.8](https://github.com/somleng/sms-gateway/compare/v1.0.7...v1.0.8) (2022-12-06) 249 | 250 | 251 | ### Bug Fixes 252 | 253 | * remove `host` option from dummy gateway ([cde3e7c](https://github.com/somleng/sms-gateway/commit/cde3e7cf19cd421fc43b8585f716b02f31a01e02)) 254 | 255 | ## [1.0.7](https://github.com/somleng/sms-gateway/compare/v1.0.6...v1.0.7) (2022-12-06) 256 | 257 | 258 | ### Bug Fixes 259 | 260 | * Add link to Somleng Project to README ([f41c758](https://github.com/somleng/sms-gateway/commit/f41c758d149420589e374c8169c8604999ca0396)) 261 | 262 | ## [1.0.6](https://github.com/somleng/sms-gateway/compare/v1.0.5...v1.0.6) (2022-10-26) 263 | 264 | 265 | ### Bug Fixes 266 | 267 | * github actions build ([fbf2e7c](https://github.com/somleng/sms-gateway/commit/fbf2e7cbcbf93e7406d7d456dea4709b26ee8c4b)) 268 | 269 | ## [1.0.5](https://github.com/somleng/sms-gateway/compare/v1.0.4...v1.0.5) (2022-10-26) 270 | 271 | 272 | ### Bug Fixes 273 | 274 | * Github Action build scripts ([15e07ce](https://github.com/somleng/sms-gateway/commit/15e07ce1497578f3149881d4a9c0d2e25f871eb6)) 275 | 276 | ## [1.0.4](https://github.com/somleng/sms-gateway/compare/v1.0.3...v1.0.4) (2022-10-26) 277 | 278 | 279 | ### Bug Fixes 280 | 281 | * github action builds ([91592ac](https://github.com/somleng/sms-gateway/commit/91592ac3ddb6d3d7636fcf333faed2335ebe964e)) 282 | 283 | ## [1.0.3](https://github.com/somleng/sms-gateway/compare/v1.0.2...v1.0.3) (2022-10-26) 284 | 285 | 286 | ### Bug Fixes 287 | 288 | * Add more assets to Github Release ([a94d931](https://github.com/somleng/sms-gateway/commit/a94d931dbf3344b51b19a8730e8db56681f90dbf)) 289 | 290 | ## [1.0.2](https://github.com/somleng/sms-gateway/compare/v1.0.1...v1.0.2) (2022-10-26) 291 | 292 | 293 | ### Bug Fixes 294 | 295 | * github action release more files ([1b9cb33](https://github.com/somleng/sms-gateway/commit/1b9cb339a98d5fdf2afd73eba976ecaec4740912)) 296 | 297 | ## [1.0.1](https://github.com/somleng/sms-gateway/compare/v1.0.0...v1.0.1) (2022-10-26) 298 | 299 | 300 | ### Bug Fixes 301 | 302 | * github actions ([dfaa033](https://github.com/somleng/sms-gateway/commit/dfaa033077eebeda63cca4b2761e1ffd0f135dd6)) 303 | 304 | ## 1.0.0 (2022-10-26) 305 | 306 | 307 | ### Features 308 | 309 | * Setup Github Action ([#1](https://github.com/somleng/sms-gateway/issues/1)) ([6516c97](https://github.com/somleng/sms-gateway/commit/6516c973b3ad9e9adcbc6dee37927c7bf51d8b12)) 310 | --------------------------------------------------------------------------------