├── .DS_Store ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Frontend ├── .DS_Store ├── .gitignore ├── frontend.service ├── implementations │ ├── .DS_Store │ └── react │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ ├── components │ │ │ ├── PixelStreamingWrapper.tsx │ │ │ ├── login.component.tsx │ │ │ ├── start.component.tsx │ │ │ └── streaming.component.tsx │ │ ├── index.html │ │ ├── index.tsx │ │ └── services │ │ │ └── auth.service.ts │ │ ├── webpack.common.js │ │ ├── webpack.dev.js │ │ └── webpack.prod.js └── startFE.sh ├── LICENSE ├── Lambda ├── authorizeClient.py ├── createInstances.py ├── keepConnectionAlive.py ├── poller.py ├── registerInstances.py ├── requestSession.py ├── sendSessionDetails.py ├── terminateInstance.py └── uploadToDDB.py ├── Matchmaker ├── config.json ├── matchmaker.js ├── package-lock.json └── package.json ├── README.md ├── SignallingWebServer ├── cirrus.js └── startSS.sh ├── SolutionDesign.jpg └── infra └── create.yaml /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/pixel-streaming-at-scale/13f1ef08da8ad5efc839b3ff8930a7552587a160/.DS_Store -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /Frontend/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/pixel-streaming-at-scale/13f1ef08da8ad5efc839b3ff8930a7552587a160/Frontend/.DS_Store -------------------------------------------------------------------------------- /Frontend/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | types/ 4 | .vscode 5 | -------------------------------------------------------------------------------- /Frontend/frontend.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Start frontend server. 3 | 4 | [Service] 5 | Type=simple 6 | ExecStart=/bin/bash /usr/customapps/pixelstreaming/Frontend/startFE.sh 7 | 8 | [Install] 9 | WantedBy=multi-user.target 10 | -------------------------------------------------------------------------------- /Frontend/implementations/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/pixel-streaming-at-scale/13f1ef08da8ad5efc839b3ff8930a7552587a160/Frontend/implementations/.DS_Store -------------------------------------------------------------------------------- /Frontend/implementations/react/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epicgames-ps/reference-pixelstreamingfrontend-react-ue5.2", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "./src/index.tsx", 6 | "scripts": { 7 | "build": "npx webpack --config webpack.prod.js", 8 | "build-dev": "npx webpack --config webpack.dev.js", 9 | "watch": "npx webpack --watch", 10 | "serve": "webpack serve --config webpack.dev.js", 11 | "serve-prod": "webpack serve --config webpack.prod.js", 12 | "build-all": "npm link ../../library && cd ../../library && npm run build && cd ../implementations/react && npm run build", 13 | "build-dev-all": "npm link ../../library && cd ../../library && npm run build-dev && cd ../implementations/react && npm run build-dev" 14 | }, 15 | "devDependencies": { 16 | "@types/react": "^18.0.28", 17 | "@types/react-dom": "^18.0.11", 18 | "css-loader": "^6.7.3", 19 | "html-loader": "^4.2.0", 20 | "html-webpack-plugin": "^5.5.0", 21 | "path": "^0.12.7", 22 | "ts-loader": "^9.4.2", 23 | "typescript": "^4.9.4", 24 | "webpack": "^5.88.1", 25 | "webpack-cli": "^5.1.4", 26 | "webpack-dev-server": "^4.11.1" 27 | }, 28 | "dependencies": { 29 | "axios": "^1.4.0", 30 | "buffer": "^6.0.3", 31 | "dotenv": "^16.3.1", 32 | "react": "^18.2.0", 33 | "react-dom": "^18.2.0", 34 | "react-router-dom": "^6.14.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Frontend/implementations/react/src/components/PixelStreamingWrapper.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | import React, { useEffect, useRef, useState } from 'react'; 4 | import { 5 | Config, 6 | AllSettings, 7 | PixelStreaming 8 | } from '@epicgames-ps/lib-pixelstreamingfrontend-ue5.2'; 9 | 10 | export interface PixelStreamingWrapperProps { 11 | initialSettings?: Partial; 12 | } 13 | 14 | export const PixelStreamingWrapper = ({ 15 | initialSettings 16 | }: PixelStreamingWrapperProps) => { 17 | // A reference to parent div element that the Pixel Streaming library attaches into: 18 | const videoParent = useRef(null); 19 | 20 | // Pixel streaming library instance is stored into this state variable after initialization: 21 | const [pixelStreaming, setPixelStreaming] = useState(); 22 | 23 | // A boolean state variable that determines if the Click to play overlay is shown: 24 | const [clickToPlayVisible, setClickToPlayVisible] = useState(false); 25 | 26 | // Run on component mount: 27 | useEffect(() => { 28 | if (videoParent.current) { 29 | // Attach Pixel Streaming library to videoParent element: 30 | const config = new Config({ initialSettings }); 31 | const streaming = new PixelStreaming(config, { 32 | videoElementParent: videoParent.current 33 | }); 34 | 35 | // register a playStreamRejected handler to show Click to play overlay if needed: 36 | streaming.addEventListener('playStreamRejected', () => { 37 | setClickToPlayVisible(true); 38 | }); 39 | 40 | // Save the library instance into component state so that it can be accessed later: 41 | setPixelStreaming(streaming); 42 | 43 | // Clean up on component unmount: 44 | return () => { 45 | try { 46 | streaming.disconnect(); 47 | } catch {} 48 | }; 49 | } 50 | }, []); 51 | 52 | return ( 53 |
60 |
67 | {clickToPlayVisible && ( 68 |
{ 81 | pixelStreaming?.play(); 82 | setClickToPlayVisible(false); 83 | }} 84 | > 85 |
Click to play
86 |
87 | )} 88 |
89 | ); 90 | }; 91 | -------------------------------------------------------------------------------- /Frontend/implementations/react/src/components/login.component.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | import { Navigate } from "react-router-dom"; 3 | import AuthService from "../services/auth.service"; 4 | import {BrowserRouter as Router, Routes, Route} from 'react-router-dom'; 5 | import Streaming from './streaming.component'; 6 | import Start from './start.component'; 7 | 8 | type Props = {}; 9 | 10 | type State = { 11 | redirect: string | null, 12 | currentuser: string | null, 13 | secretToken : string | null 14 | }; 15 | 16 | export default class Login extends Component { 17 | constructor(props: Props) { 18 | super(props); 19 | this.state = { 20 | redirect: "", 21 | currentuser : null, 22 | secretToken : "" 23 | }; 24 | } 25 | 26 | async componentDidMount() { 27 | // FE being integrated with Cognito hosted UI, gets a Cognito code on succesful login 28 | // the code is being used to get user details and move forward 29 | var urlParams = new URLSearchParams(window.location.search); 30 | var myParam = urlParams.get('code'); 31 | const loggedUser = await AuthService.getCurrentUser(myParam); 32 | this.setState({currentuser:loggedUser}) 33 | // secret token is used later to interface with web socket 34 | this.setState({secretToken:AuthService.getSecretToken()}) 35 | 36 | } 37 | 38 | componentWillUnmount() { 39 | window.location.reload(); 40 | } 41 | 42 | render() { 43 | var currentUser=this.state.currentuser 44 | console.log("this is current user "+currentUser) 45 | return ( 46 | 47 | <>{currentUser ? :

User is being authenticated !!

} 48 | 49 | ()}/> 50 | 51 | 52 |
53 | ); 54 | 55 | 56 | 57 | 58 | } 59 | } -------------------------------------------------------------------------------- /Frontend/implementations/react/src/components/start.component.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | 3 | export default class Start extends Component { 4 | 5 | componentDidMount() { 6 | //window.location.href="https://metaverseloginpool.auth.ap-south-1.amazoncognito.com/oauth2/authorize?client_id=5s3a1a5q00v4dlur6c9na95ej6&response_type=code&scope=aws.cognito.signin.user.admin+email+openid&redirect_uri=https%3A%2F%2Fd29rttxscxdopy.cloudfront.net" 7 | } 8 | 9 | componentWillUnmount() { 10 | //window.location.reload(); 11 | } 12 | 13 | render() { 14 | 15 | return ( 16 |
Please sign in again !
17 | ); 18 | 19 | } 20 | } -------------------------------------------------------------------------------- /Frontend/implementations/react/src/components/streaming.component.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from "react"; 2 | import {useEffect, useState} from "react" 3 | import { Navigate } from "react-router-dom"; 4 | import AuthService from "../services/auth.service"; 5 | import { PixelStreamingWrapper } from './PixelStreamingWrapper'; 6 | 7 | type Props = { 8 | loggedUser:string | null, 9 | secretToken:string | null, 10 | }; 11 | 12 | type State = { 13 | serverState: string | null, 14 | messageText: string | null, 15 | serverMessage: string | null, 16 | disableButton : boolean | false 17 | }; 18 | 19 | 20 | var ws = new WebSocket(process.env.api_ws) 21 | var signallingServer=process.env.sig_ws 22 | 23 | export default class Streaming extends Component { 24 | constructor(props: Props) { 25 | super(props); 26 | this.state = { 27 | serverState: "", 28 | messageText : null, 29 | serverMessage : null, 30 | disableButton : false 31 | }; 32 | } 33 | 34 | componentDidMount() { 35 | const serverMessagesList :string[]= []; 36 | ws.onopen = () => { 37 | this.setState({serverState:'Connected to the server'}) 38 | console.log("Connected ! ") 39 | 40 | }; 41 | ws.onclose = (e) => { 42 | this.setState({serverState:'Disconnected. Check internet or server.'}) 43 | console.log("Disconnected ! ") 44 | }; 45 | ws.onerror = (e) => { 46 | console.log("Error on connection "+ e) 47 | this.setState({serverState:"Error while establishing connection "}) 48 | }; 49 | ws.onmessage = (e) => { 50 | serverMessagesList.push(e.data); 51 | console.log("Message received ! "+e.data) 52 | if((e.data).includes('signallingServer')) 53 | { 54 | // need to add error handling 55 | var parsedMsg=((e.data).split(":")[1]).split("\"")[1] 56 | this.setState({serverMessage:signallingServer+parsedMsg}) 57 | } 58 | else 59 | { 60 | console.log("Waiting for session") 61 | } 62 | 63 | 64 | }; 65 | } 66 | 67 | componentWillUnmount() 68 | { 69 | ws.close 70 | } 71 | 72 | render() { 73 | 74 | const submitMessage = () => { 75 | // need to add error handling 76 | ws.send(JSON.stringify({"user":this.props.loggedUser,"action":"reqSession","bearer":this.props.secretToken})); 77 | console.log("message sent !") 78 | 79 | } 80 | 81 | 82 | 83 | if (!AuthService.isValidSession()) { 84 | return ( 85 |
91 |

Please login again !

92 |
93 | ) 94 | } 95 | else 96 | { 97 | console.log("loading streaming component ! ") 98 | if(this.state.serverMessage !=null) 99 | { 100 | return ( 101 |
107 |

Signalling Endpoint is ! {this.state.serverMessage}

108 | 117 |
118 | ) 119 | } 120 | else 121 | { 122 | return ( 123 |
129 |

Lets start streaming ! {this.props.loggedUser} ----- {this.state.serverState}

130 | 131 | 132 |
133 | ) 134 | } 135 | 136 | } 137 | 138 | 139 | 140 | } 141 | } -------------------------------------------------------------------------------- /Frontend/implementations/react/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Pixel Streaming 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Frontend/implementations/react/src/index.tsx: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | import React from 'react'; 3 | import { createRoot } from 'react-dom/client'; 4 | import { App } from './components/App'; 5 | import Login from './components/login.component'; 6 | 7 | document.body.onload = function () { 8 | // Attach the React app root component to document.body 9 | createRoot(document.body).render(); 10 | }; 11 | -------------------------------------------------------------------------------- /Frontend/implementations/react/src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { Buffer } from "buffer"; 3 | 4 | const cognito_domain = process.env.cognito_domain 5 | const client_id_cog=process.env.client_id_cog 6 | const client_secret_cog=process.env.client_secret_cog 7 | const callback_uri_cog=process.env.callback_uri_cog 8 | 9 | const encode = (str: string):string => Buffer.from(str, 'binary').toString('base64'); 10 | 11 | 12 | class AuthService { 13 | 14 | 15 | logout() { 16 | sessionStorage.removeItem("user"); 17 | } 18 | 19 | getSecretToken() 20 | { 21 | return process.env.sec_token 22 | } 23 | 24 | 25 | isValidSession() 26 | { 27 | if(sessionStorage.getItem("user")) 28 | { 29 | return true 30 | } 31 | else 32 | { 33 | return false 34 | } 35 | } 36 | 37 | async getBearerToken(authcode: string) 38 | { 39 | var authorization =encode(client_id_cog+":"+client_secret_cog) 40 | const headers = { 41 | headers: { Authorization: "Basic "+authorization, 42 | 'Content-Type': 'application/x-www-form-urlencoded' 43 | } 44 | }; 45 | const bodyParameters = { 46 | grant_type: "authorization_code", 47 | code: authcode, 48 | redirect_uri: callback_uri_cog, 49 | client_id:client_id_cog 50 | }; 51 | 52 | var access_token='' 53 | const body = `grant_type=authorization_code&client_id=${client_id_cog}&code=${authcode}&redirect_uri=${callback_uri_cog}` 54 | try { 55 | const response=await axios.post( 56 | cognito_domain+'/oauth2/token', 57 | body, 58 | headers 59 | ) 60 | access_token=response.data.access_token 61 | 62 | }catch (err) { 63 | console.log("Unable to get access token "+err.response.data.error) 64 | 65 | } 66 | return access_token 67 | } 68 | 69 | 70 | 71 | async getCurrentUser(authcode: string) { 72 | 73 | 74 | var access_token= await this.getBearerToken(authcode) 75 | const config_token = { 76 | headers: { 77 | Authorization: `Bearer ${access_token}` 78 | } 79 | } 80 | const response = await axios.get(`${cognito_domain}/oauth2/userInfo`, config_token) 81 | const user = response.data.email 82 | 83 | console.log("found user"+ user) 84 | sessionStorage.setItem("user",user) 85 | return user; 86 | } 87 | } 88 | 89 | export default new AuthService(); -------------------------------------------------------------------------------- /Frontend/implementations/react/webpack.common.js: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | const path = require('path'); 4 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 5 | const webpack = require('webpack'); 6 | const fs = require('fs'); 7 | 8 | 9 | const pages = fs.readdirSync('./src', { withFileTypes: true }) 10 | .filter(item => !item.isDirectory()) 11 | .filter(item => path.parse(item.name).ext === '.html') 12 | .map(htmlFile => path.parse(htmlFile.name).name); 13 | 14 | module.exports = { 15 | entry: pages.reduce((config, page) => { 16 | config[page] = `./src/${page}.tsx`; 17 | return config; 18 | }, {}), 19 | 20 | plugins: [].concat(pages.map((page) => new HtmlWebpackPlugin({ 21 | title: `${page}`, 22 | template: `./src/${page}.html`, 23 | filename: `${page}.html`, 24 | chunks: [page], 25 | }) 26 | )), 27 | 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.tsx?$/, 32 | loader: 'ts-loader', 33 | exclude: [ 34 | /node_modules/, 35 | ], 36 | }, 37 | { 38 | test: /\.html$/i, 39 | use: 'html-loader' 40 | }, 41 | { 42 | test: /\.css$/, 43 | type: 'asset/resource', 44 | generator: { 45 | filename: 'css/[name][ext]' 46 | } 47 | }, 48 | { 49 | test: /\.(png|svg)$/i, 50 | type: 'asset/resource', 51 | generator: { 52 | filename: 'images/[name][ext]' 53 | } 54 | }, 55 | ], 56 | }, 57 | resolve: { 58 | extensions: ['.tsx', '.ts', '.js', '.svg', '.json'], 59 | }, 60 | output: { 61 | filename: '[name].js', 62 | library: 'epicgames-react-frontend', 63 | libraryTarget: 'umd', 64 | path: path.resolve(__dirname, '../../../SignallingWebServer/Public'), 65 | clean: true, 66 | globalObject: 'this', 67 | hashFunction: 'xxhash64', 68 | }, 69 | experiments: { 70 | futureDefaults: true 71 | }, 72 | devServer: { 73 | static: { 74 | directory: path.join(__dirname, '../../../SignallingWebServer/Public'), 75 | }, 76 | //Start : AWS - allowed origin from AWS 77 | allowedHosts: [ 78 | '.amazonaws.com', 79 | '.cloudfront.net' 80 | ] 81 | //End: AWS - allowed origin from AWS 82 | }, 83 | } -------------------------------------------------------------------------------- /Frontend/implementations/react/webpack.dev.js: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | const { merge } = require('webpack-merge'); 4 | const common = require('./webpack.common.js'); 5 | const path = require('path'); 6 | const webpack = require("webpack"); 7 | //Start : AWS - allowed usage of environment variables 8 | require('dotenv').config(); 9 | //End : AWS - allowed usage of environment variables 10 | 11 | module.exports = merge(common, { 12 | //Start : AWS - allowed usage of environment variables 13 | plugins: [ 14 | new webpack.DefinePlugin({ 15 | 16 | // Refer cloudformation output : Client ID for Cognito (CognitoClientID) 17 | 'process.env.client_id_cog': JSON.stringify(''), 18 | // Refer cloudformation output : Domain URL for Cognito (CognitoDomainURL) 19 | 'process.env.cognito_domain': JSON.stringify(''), 20 | // This information is not available in CloudFromation. Please navigate to AWS Console->Cognito. Click on user pool 'ueauthenticationpool' 21 | // Click on App Integration tab and scroll down to App client list. Click on App Client Name 22 | // You should be able to retrieve the client secret from the App client information widget by toggling the 23 | // show client secret button 24 | 'process.env.client_secret_cog': JSON.stringify(''), 25 | // Refer cloudformation output : Callback URL for Cognito (CognitoCallBackURL) 26 | 'process.env.callback_uri_cog': JSON.stringify(''), 27 | // Refer cloudformation output : Web socket endpoint for api server (APIGatewayWSAPI) 28 | 'process.env.api_ws': JSON.stringify(''), 29 | // Refer cloudformation output : Web socket endpoint for signalling server (SignallingServerWSAPI) 30 | 'process.env.sig_ws': JSON.stringify(''), 31 | // No need to change this. If you modify, make sure you also update clientSecret environment variable for requestSession lambda 32 | 'process.env.sec_token': JSON.stringify(''), 33 | 34 | 35 | }) 36 | ], 37 | //End : AWS - allowed usage of environment variables 38 | mode: 'development', 39 | devtool: 'inline-source-map' 40 | }); 41 | -------------------------------------------------------------------------------- /Frontend/implementations/react/webpack.prod.js: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | const { merge } = require('webpack-merge'); 4 | const common = require('./webpack.common.js'); 5 | 6 | module.exports = merge(common, { 7 | mode: 'production', 8 | optimization: { 9 | usedExports: true, 10 | minimize: true 11 | }, 12 | stats: 'errors-only', 13 | performance: { 14 | hints: false 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /Frontend/startFE.sh: -------------------------------------------------------------------------------- 1 | cd /usr/customapps/pixelstreaming/Frontend/implementations/react 2 | npm run serve -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /Lambda/authorizeClient.py: -------------------------------------------------------------------------------- 1 | # this function is used to authenticate websocket connection from client to API Gateway 2 | # the web socket connection is used for requesting streaming sessions 3 | import json 4 | 5 | def lambda_handler(event, context): 6 | 7 | print(event) 8 | 9 | if("abcd" == event["queryStringParameters"]["tokenId"]): 10 | return { 11 | 'statusCode': 200, 12 | 'body': json.dumps('Web socket connection valid !') 13 | } 14 | else: 15 | return { 16 | 'statusCode': 401, 17 | 'body': json.dumps('Web socket connection invalid !') 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Lambda/createInstances.py: -------------------------------------------------------------------------------- 1 | # this function is used to create new signalling instances. The function can either be triggered on schedule 2 | # in which case, multiple signalling servers are created. This is identified when the event contains startAllServers 3 | # as true. Refer eventbridge rule ScheduledStartSignallingServer in infra scripts 4 | # this function can also be triggered as a response to a session request in which case only one server is created 5 | # note that in both cases an userdata is passed which starts the signalling server and takes the matchmaker ip for 6 | # integration 7 | 8 | import json 9 | import boto3 10 | import os 11 | import random 12 | from boto3.dynamodb.conditions import Attr 13 | 14 | 15 | def lambda_handler(event, context): 16 | ssm = boto3.client('ssm') 17 | parameter = ssm.get_parameter(Name='concurrencyLimit') 18 | # this is used to determine the maximum number of Signalling instances that can be created 19 | concurrencyLimit = parameter['Parameter']['Value'] 20 | 21 | # this is used to chose a subnet randomly while generating the instance 22 | allSubnets=['SubnetIdPublicA','SubnetIdPublicB'] 23 | 24 | matchMakerPrivateIP='' 25 | ec2MatchMaker = boto3.client('ec2') 26 | response = ec2MatchMaker.describe_instances(Filters=[{'Name': 'tag:aws:cloudformation:logical-id', 'Values': ['MatchMakingInstance']}]) 27 | if(len(response['Reservations'])==1): 28 | print(response['Reservations'][0]['Instances'][0]['PrivateIpAddress']) 29 | # this is used to retrieve the private ip of Matchmaker which allows Signalling instance to interface with it 30 | matchMakerPrivateIP=response['Reservations'][0]['Instances'][0]['PrivateIpAddress'] 31 | 32 | # this is the startup script for Signalling instance. The execut.sh is defined in the infra scripts 33 | # the shutdown halt allows the instance to shutdown automatically after 20 minutes. This can be modified 34 | # depending on the length of the streaming session 35 | userData='''#!/bin/bash 36 | sudo su 37 | sudo shutdown --halt +20 38 | cd /usr/customapps/ 39 | ./startSS.sh {}'''.format(matchMakerPrivateIP) 40 | 41 | print(event) 42 | 43 | if("startAllServers" in event): 44 | if(event["startAllServers"]): 45 | print("We will start all instances !") 46 | ec2 = boto3.client('ec2') 47 | response = ec2.run_instances( 48 | ImageId=os.environ["ImageId"], 49 | SubnetId=os.environ[random.choice(allSubnets)], 50 | LaunchTemplate={'LaunchTemplateName': os.environ["LaunchTemplateName"]}, 51 | UserData=userData, 52 | MinCount=int(concurrencyLimit), 53 | MaxCount=int(concurrencyLimit) 54 | ) 55 | return { 56 | 'statusCode': 200, 57 | 'body': json.dumps('All instances created ') 58 | } 59 | else: 60 | dynamodb = boto3.resource('dynamodb') 61 | # query all items based on a filter 62 | table = dynamodb.Table('instanceMapping') 63 | response = table.scan( 64 | FilterExpression=Attr('InstanceID').eq('') 65 | ) 66 | # the dynamoDB table keeps a track of all Signalling instances and their mapping to target groups 67 | # this is used later to retrieve the query string for starting a streaming session 68 | # the absence of any row in the dynamoDB with a blank instanceID would indicate that all target groups are being used 69 | # and the create instance request would need to skipped 70 | if(len(response['Items'])==0): 71 | return { 72 | 'statusCode': 400, 73 | 'body': json.dumps('Instance pool at capacity ! Could not create new instance') 74 | } 75 | else: 76 | ec2 = boto3.client('ec2') 77 | response = ec2.run_instances( 78 | ImageId=os.environ["ImageId"], 79 | SubnetId=os.environ[random.choice(allSubnets)], 80 | LaunchTemplate={'LaunchTemplateName': os.environ["LaunchTemplateName"]}, 81 | UserData=userData, 82 | MinCount=1, 83 | MaxCount=1 84 | ) 85 | print(response['Instances'][0]['InstanceId']) 86 | # get instance id 87 | instanceId = response['Instances'][0]['InstanceId'] 88 | 89 | return { 90 | 'statusCode': 200, 91 | 'body': json.dumps('New instance created '+instanceId) 92 | } 93 | 94 | 95 | -------------------------------------------------------------------------------- /Lambda/keepConnectionAlive.py: -------------------------------------------------------------------------------- 1 | # this function is used to keep the web socket connection between browser and API gateway alive, till a session is available 2 | import boto3 3 | import json 4 | import os 5 | import logging 6 | 7 | 8 | logger = logging.getLogger() 9 | logger.setLevel(logging.INFO) 10 | 11 | 12 | def lambda_handler(event, context): 13 | 14 | 15 | print(event) 16 | inputParams = { 17 | "status" : 'waiting for session!' 18 | } 19 | apigateway=boto3.client('apigatewaymanagementapi' ,endpoint_url=os.environ["ApiGatewayUrl"]) 20 | apigateway.post_to_connection(ConnectionId=event["connectionId"], Data=json.dumps(inputParams)) 21 | 22 | logger.info("Sent server details to frontend ! ") 23 | return { 24 | 'statusCode': 200, 25 | 'body': json.dumps('Completed sending server details to backend') 26 | } 27 | -------------------------------------------------------------------------------- /Lambda/poller.py: -------------------------------------------------------------------------------- 1 | # this function is used to poll the SQS queue for new session request and then check if 2 | # a Signalling instance is already available to service the nequest or needs to be created 3 | # this function should be run on a schedule 4 | import boto3 5 | import json 6 | import os 7 | import json 8 | import logging 9 | import urllib.request 10 | import urllib.error 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | def lambda_handler(event, context): 16 | 17 | lambdaFunc=boto3.client('lambda') 18 | 19 | lambdaArnSendSesionDetails='' 20 | lambdaArnCreateInstances='' 21 | 22 | response= lambdaFunc.get_function( 23 | FunctionName='sendSessionDetails' 24 | ) 25 | lambdaArnSendSesionDetails=response['Configuration']['FunctionArn'] 26 | response= lambdaFunc.get_function( 27 | FunctionName='createInstances' 28 | ) 29 | lambdaArnCreateInstances=response['Configuration']['FunctionArn'] 30 | response= lambdaFunc.get_function( 31 | FunctionName='keepConnectionAlive' 32 | ) 33 | lambdaArnKeepAlive=response['Configuration']['FunctionArn'] 34 | 35 | ssm = boto3.client('ssm') 36 | parameter = ssm.get_parameter(Name='matchmakerclientsecret') 37 | matchmakersecret = parameter['Parameter']['Value'] 38 | 39 | sqs = boto3.resource('sqs') 40 | queue = sqs.get_queue_by_name(QueueName=os.environ["SQSName"]) 41 | for message in queue.receive_messages(): 42 | logger.info(message.body) 43 | 44 | payload=json.loads(message.body) 45 | 46 | # send a keep alive message to frontend 47 | lambdaFunc.invoke( 48 | FunctionName = lambdaArnKeepAlive, 49 | InvocationType = 'RequestResponse', 50 | Payload = json.dumps(payload) 51 | ) 52 | 53 | 54 | logger.info("Connection id is "+payload["connectionId"]) 55 | logger.info("Endpoints id is "+os.environ["MatchMakerURL"] +". "+matchmakersecret) 56 | # we make a call to matchmaker endpoint to check if a Signalling instance is available to service the request 57 | try: 58 | response = urllib.request.urlopen(urllib.request.Request( 59 | url=os.environ["MatchMakerURL"], 60 | headers={"clientsecret":matchmakersecret}, 61 | method='GET'), 62 | timeout=5) 63 | 64 | if(response.status==200): 65 | responsePayload=response.read() 66 | JSON_object = json.loads(responsePayload.decode("utf-8")) 67 | payload.update(JSON_object) 68 | 69 | # a 200 response from Matchmaker indicates a Signalling server is available to to service request. 70 | # the matchmaker returns the Signalling server instanceID which is forwarded to sendSessionDetails 71 | response = lambdaFunc.invoke( 72 | FunctionName = lambdaArnSendSesionDetails, 73 | InvocationType = 'RequestResponse', 74 | Payload = json.dumps(payload) 75 | ) 76 | 77 | message.delete() 78 | logger.info("Found server to service request "+responsePayload.decode("utf-8")) 79 | #break 80 | except urllib.error.HTTPError as err: 81 | if(err.code==400): 82 | logger.info("No server to service request ") 83 | inputParams = { 84 | "Key" : "value" 85 | } 86 | # since no servers were found to service the request, we make a call to createInstance to create a new 87 | # Signalling server in case we are not running on capacity 88 | lambdaFunc.invoke( 89 | FunctionName = lambdaArnCreateInstances, 90 | InvocationType = 'Event', 91 | Payload = json.dumps(inputParams) 92 | ) 93 | # noticed the message is not deleted here since we could not service it with a Signalling server instanceID 94 | else: 95 | raise err 96 | 97 | return { 98 | 'statusCode': 200, 99 | 'body': json.dumps('Completed scanning for incoming requests') 100 | } 101 | -------------------------------------------------------------------------------- /Lambda/registerInstances.py: -------------------------------------------------------------------------------- 1 | # this function is triggered when a new Signalling instance is created and is used to register the instance 2 | # in the signalling target group and keep a mapping of its query string in dynamoDB table 3 | import boto3 4 | import json 5 | from boto3.dynamodb.conditions import Attr 6 | 7 | def lambda_handler(event, context): 8 | ssm = boto3.client('ssm') 9 | parameter = ssm.get_parameter(Name='concurrencyLimit') 10 | concurrencyLimit = parameter['Parameter']['Value'] 11 | instanceId=event["detail"]["instance-id"] 12 | ec2 = boto3.client('ec2') 13 | 14 | response = ec2.describe_instances(InstanceIds=[instanceId],Filters=[{'Name': 'tag:type', 'Values': ['signalling']}]) 15 | if(len(response['Reservations'])==1): 16 | dynamodb = boto3.resource('dynamodb') 17 | 18 | table = dynamodb.Table('instanceMapping') 19 | response = table.scan( 20 | FilterExpression=Attr('InstanceID').eq('') 21 | ) 22 | # it is unlikely that an instanceID was created and the pool is at capacity. However in case it happens we do not 23 | # register it in the DynamoDB table. A further enhancement can be created in the form of a CW Alarm which can check 24 | # for such unused instances and delete them automatically based on certain metrics 25 | if(len(response['Items'])==0): 26 | return { 27 | 'statusCode': 400, 28 | 'body': json.dumps('Instance pool at capacity ! Could not create new instance') 29 | } 30 | else: 31 | # the dynamoDB item is updated with the instanceID 32 | table.update_item( 33 | Key={ 34 | 'TargetGroup': response['Items'][0]['TargetGroup'], 35 | }, 36 | UpdateExpression="set InstanceID = :i", 37 | ExpressionAttributeValues={ 38 | ':i': instanceId 39 | } 40 | ) 41 | # the Signalling server ALB target group is updated with the instance id. The arn for the same is retrieved from the 42 | # dynamoDB table which has a mapping for the same 43 | elbClient = boto3.client('elbv2') 44 | elbClient.register_targets( 45 | TargetGroupArn=response['Items'][0]['ARN'], 46 | Targets=[ 47 | { 48 | 'Id':instanceId , 49 | }, 50 | ] 51 | ) 52 | return { 53 | 'statusCode': 200, 54 | 'body': json.dumps(response['Items'][0]['QueryString']) 55 | } 56 | else : 57 | return { 58 | 'statusCode': 400, 59 | 'body': json.dumps('Instance not of type signalling ! skipped registration !') 60 | } 61 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /Lambda/requestSession.py: -------------------------------------------------------------------------------- 1 | # this function is used to request for a new streaming session. The function is triggered by the API gateway websocket 2 | # endpoint. The function validates the websocket message to check if the source is valid(by checking bearer) and then 3 | # pushes the session request to a FIFO queue. the request body also includes the socket connection id for further 4 | # connection with the browser 5 | 6 | import boto3 7 | import json 8 | import os 9 | import json 10 | import logging 11 | 12 | logger = logging.getLogger() 13 | logger.setLevel(logging.INFO) 14 | 15 | def lambda_handler(event, context): 16 | print(event) 17 | 18 | sqs = boto3.resource("sqs") 19 | logger.info(os.environ["SQSName"]) 20 | queue = sqs.get_queue_by_name(QueueName=os.environ["SQSName"]) 21 | 22 | messageReqId=event["requestContext"]["requestId"] 23 | messageConnId=event["requestContext"]["connectionId"] 24 | messageReqBody=event["body"] 25 | secretParam=(json.loads(messageReqBody))['bearer'] 26 | print("secretParam "+secretParam) 27 | uniqueId=str(event["requestContext"]["requestTimeEpoch"]) 28 | 29 | # validate the websocket message to check if it originated from a valid source 30 | if(secretParam==os.environ["clientSecret"]): 31 | payload = json.dumps({'requestId': messageReqId, 'connectionId': messageConnId,'body':json.loads(messageReqBody) }) 32 | logger.info(payload) 33 | logger.info("Sending message to SQS") 34 | queue.send_message(MessageBody=payload,MessageGroupId=messageReqId,MessageDeduplicationId=uniqueId) 35 | else : 36 | logger.info('Invalid client !') 37 | 38 | return { 39 | 'statusCode': 200, 40 | 'body': json.dumps('Message posted to Q !') 41 | } 42 | -------------------------------------------------------------------------------- /Lambda/sendSessionDetails.py: -------------------------------------------------------------------------------- 1 | # this function is used to send session details back to browser when a signalling server is available to process it 2 | # the response includes the query string for the signalling server and it uses the websocket connection id to interface 3 | # back with the client 4 | import boto3 5 | import json 6 | import os 7 | import logging 8 | 9 | 10 | logger = logging.getLogger() 11 | logger.setLevel(logging.INFO) 12 | 13 | 14 | def lambda_handler(event, context): 15 | 16 | 17 | print(event) 18 | inputParams = { 19 | "signallingServer" : event["signallingServer"] 20 | } 21 | apigateway=boto3.client('apigatewaymanagementapi' ,endpoint_url=os.environ["ApiGatewayUrl"]) 22 | apigateway.post_to_connection(ConnectionId=event["connectionId"], Data=json.dumps(inputParams)) 23 | 24 | logger.info("Sent server details to frontend ! ") 25 | return { 26 | 'statusCode': 200, 27 | 'body': json.dumps('Completed sending server details to backend') 28 | } 29 | -------------------------------------------------------------------------------- /Lambda/terminateInstance.py: -------------------------------------------------------------------------------- 1 | # this function is used to terminate Signalling server instances. The user data script for Signalling server includes a logic 2 | # to stop the instance after a predefined period like 20 minutes. Once instance moved to stopped state, an Event bridge rule 3 | # triggers this function to terminate the stopped instance 4 | # a varriation of the Event Bridge rule also triggers ths function on schedule, passing the paramater stopAllServers=true in event 5 | # causing all Signalling servers to be terminated at the same time 6 | 7 | import boto3 8 | import json 9 | import os 10 | import json 11 | from boto3.dynamodb.conditions import Attr 12 | 13 | def lambda_handler(event, context): 14 | 15 | # get all TG to instance mapping from dynamoDB to remove mapping later during termination 16 | dynamodb = boto3.resource('dynamodb') 17 | table = dynamodb.Table('instanceMapping') 18 | response = table.scan(FilterExpression=Attr('InstanceID').ne('')) 19 | instanceMapping={} 20 | for item in response['Items']: 21 | instanceMapping[item['InstanceID']]=item['TargetGroup'] 22 | print(json.dumps(instanceMapping)) 23 | 24 | if("stopAllServers" in event): 25 | if(event["stopAllServers"]): 26 | ec2 = boto3.client('ec2') 27 | allInstances=[] 28 | response = ec2.describe_instances(Filters=[{'Name': 'tag:type', 'Values': ['signalling']}]) 29 | for reservation in response['Reservations']: 30 | instanceID=reservation['Instances'][0]['InstanceId'] 31 | print('Found instance id '+instanceID) 32 | allInstances.append(instanceID) 33 | # remove instance mapping from dynamoDb table 34 | table.update_item( 35 | Key={ 36 | 'TargetGroup': instanceMapping[instanceID], 37 | }, 38 | UpdateExpression="set InstanceID = :i", 39 | ExpressionAttributeValues={ 40 | ':i': '' 41 | } 42 | ) 43 | print('will terminate all signalling instances ') 44 | ec2.terminate_instances(InstanceIds=allInstances) 45 | else: 46 | instanceId=event["detail"]["instance-id"] 47 | ec2 = boto3.client('ec2') 48 | # describe instance based on instance id 49 | response = ec2.describe_instances(InstanceIds=[instanceId],Filters=[{'Name': 'tag:type', 'Values': ['signalling']}]) 50 | if(len(response['Reservations'])==1): 51 | print('will terminate instance '+instanceId) 52 | # remove instance mapping from dynamoDb table 53 | table.update_item( 54 | Key={ 55 | 'TargetGroup': instanceMapping[instanceId], 56 | }, 57 | UpdateExpression="set InstanceID = :i", 58 | ExpressionAttributeValues={ 59 | ':i': '' 60 | } 61 | ) 62 | ec2.terminate_instances(InstanceIds=[instanceId]) 63 | return { 64 | 'statusCode': 200, 65 | 'body': json.dumps('Instance was terminated successfully !') 66 | } 67 | -------------------------------------------------------------------------------- /Lambda/uploadToDDB.py: -------------------------------------------------------------------------------- 1 | import json 2 | import boto3 3 | import os 4 | 5 | def lambda_handler(event, context): 6 | # TODO implement 7 | 8 | client = boto3.client('elbv2') 9 | dynamodb = boto3.resource('dynamodb') 10 | table = dynamodb.Table(os.environ["DynamoDBName"]) 11 | 12 | #get listener arn associated with the signalling load balancer 13 | response = client.describe_load_balancers( 14 | Names=[os.environ["ALBName"]] 15 | ) 16 | loadbalancerarn=response['LoadBalancers'][0]['LoadBalancerArn'] 17 | 18 | response = client.describe_listeners( 19 | LoadBalancerArn=loadbalancerarn 20 | ) 21 | listenerarn=response['Listeners'][0]['ListenerArn'] 22 | 23 | response = client.describe_rules( 24 | ListenerArn=listenerarn 25 | ) 26 | 27 | #iterate through the list of rules available in signalling load balancer listener and populate DDB 28 | 29 | for rule in response['Rules']: 30 | if rule['Priority']!='default': 31 | qs=rule['Conditions'][0]['QueryStringConfig']['Values'][0]['Key']+"="+rule['Conditions'][0]['QueryStringConfig']['Values'][0]['Value'] 32 | table.put_item( 33 | 34 | Item={ 35 | 'TargetGroup': 'TG'+rule['Conditions'][0]['QueryStringConfig']['Values'][0]['Value'], 36 | 'ARN': rule['Actions'][0]['TargetGroupArn'], 37 | 'InstanceID': '', 38 | 'QueryString': qs 39 | } 40 | ) 41 | else : 42 | table.put_item( 43 | Item={ 44 | 'TargetGroup': 'TG01', 45 | 'ARN': rule['Actions'][0]['TargetGroupArn'], 46 | 'InstanceID': '', 47 | 'QueryString': '' 48 | } 49 | ) 50 | 51 | return { 52 | 'statusCode': 200, 53 | 'body': 'Populated dynamodb with required information !' 54 | } -------------------------------------------------------------------------------- /Matchmaker/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "HttpPort": 90, 3 | "UseHTTPS": false, 4 | "MatchmakerPort": 9999, 5 | "LogToFile": true, 6 | "AWSRegion": "" 7 | } -------------------------------------------------------------------------------- /Matchmaker/matchmaker.js: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | //Start : AWS - expose matchmaker only as api endpoint 4 | var enableRedirectionLinks = false; 5 | //End : AWS - expose matchmaker only as api endpoint 6 | var enableRESTAPI = true; 7 | 8 | const defaultConfig = { 9 | // The port clients connect to the matchmaking service over HTTP 10 | HttpPort: 80, 11 | UseHTTPS: false, 12 | // The matchmaking port the signaling service connects to the matchmaker 13 | MatchmakerPort: 9999, 14 | 15 | // Log to file 16 | LogToFile: true 17 | }; 18 | 19 | // Similar to the Signaling Server (SS) code, load in a config.json file for the MM parameters 20 | const argv = require('yargs').argv; 21 | 22 | var configFile = (typeof argv.configFile != 'undefined') ? argv.configFile.toString() : 'config.json'; 23 | console.log(`configFile ${configFile}`); 24 | const config = require('./modules/config.js').init(configFile, defaultConfig); 25 | console.log("Config: " + JSON.stringify(config, null, '\t')); 26 | 27 | const express = require('express'); 28 | var cors = require('cors'); 29 | const app = express(); 30 | const http = require('http').Server(app); 31 | const fs = require('fs'); 32 | const path = require('path'); 33 | const logging = require('./modules/logging.js'); 34 | //Start : AWS - Loaded aws sdk for iterfacing with dynamoDB and SSM paramater store 35 | var AWS = require('aws-sdk'); 36 | const { SSMClient, GetParameterCommand } = require("@aws-sdk/client-ssm"); 37 | // please change based on region 38 | AWS.config.update({region: config.AWSRegion}); 39 | var ddb = new AWS.DynamoDB({ apiVersion: "2012-08-10" }); 40 | //End : AWS - Loaded aws sdk for iterfacing with dynamoDB and SSM paramater store 41 | 42 | logging.RegisterConsoleLogger(); 43 | 44 | if (config.LogToFile) { 45 | logging.RegisterFileLogger('./logs'); 46 | } 47 | 48 | // A list of all the Cirrus server which are connected to the Matchmaker. 49 | var cirrusServers = new Map(); 50 | var theSSMSecret='' 51 | // 52 | // Parse command line. 53 | // 54 | 55 | if (typeof argv.HttpPort != 'undefined') { 56 | config.HttpPort = argv.HttpPort; 57 | } 58 | if (typeof argv.MatchmakerPort != 'undefined') { 59 | config.MatchmakerPort = argv.MatchmakerPort; 60 | } 61 | 62 | http.listen(config.HttpPort, () => { 63 | console.log('HTTP listening on *:' + config.HttpPort); 64 | }); 65 | 66 | 67 | if (config.UseHTTPS) { 68 | //HTTPS certificate details 69 | const options = { 70 | key: fs.readFileSync(path.join(__dirname, './certificates/client-key.pem')), 71 | cert: fs.readFileSync(path.join(__dirname, './certificates/client-cert.pem')) 72 | }; 73 | 74 | var https = require('https').Server(options, app); 75 | 76 | //Setup http -> https redirect 77 | console.log('Redirecting http->https'); 78 | app.use(function (req, res, next) { 79 | if (!req.secure) { 80 | if (req.get('Host')) { 81 | var hostAddressParts = req.get('Host').split(':'); 82 | var hostAddress = hostAddressParts[0]; 83 | if (httpsPort != 443) { 84 | hostAddress = `${hostAddress}:${httpsPort}`; 85 | } 86 | return res.redirect(['https://', hostAddress, req.originalUrl].join('')); 87 | } else { 88 | console.error(`unable to get host name from header. Requestor ${req.ip}, url path: '${req.originalUrl}', available headers ${JSON.stringify(req.headers)}`); 89 | return res.status(400).send('Bad Request'); 90 | } 91 | } 92 | next(); 93 | }); 94 | 95 | https.listen(443, function () { 96 | console.log('Https listening on 443'); 97 | }); 98 | } 99 | 100 | 101 | 102 | 103 | // No servers are available so send some simple JavaScript to the client to make 104 | // it retry after a short period of time. 105 | function sendRetryResponse(res) { 106 | res.send(`All ${cirrusServers.size} Cirrus servers are in use. Retrying in 3 seconds. 107 | `); 118 | } 119 | 120 | // Get a Cirrus server if there is one available which has no clients connected. 121 | function getAvailableCirrusServer() { 122 | for (cirrusServer of cirrusServers.values()) { 123 | if (cirrusServer.numConnectedClients === 0 && cirrusServer.ready === true) { 124 | 125 | // Check if we had at least 10 seconds since the last redirect, avoiding the 126 | // chance of redirecting 2+ users to the same SS before they click Play. 127 | // In other words, give the user 10 seconds to click play button the claim the server. 128 | if( cirrusServer.hasOwnProperty('lastRedirect')) { 129 | if( ((Date.now() - cirrusServer.lastRedirect) / 1000) < 10 ) 130 | continue; 131 | } 132 | cirrusServer.lastRedirect = Date.now(); 133 | 134 | return cirrusServer; 135 | } 136 | } 137 | 138 | console.log('WARNING: No empty Cirrus servers are available'); 139 | return undefined; 140 | } 141 | //Start : AWS - get client secret for validation from parameter store 142 | const getParameterInfo = async () => { 143 | const client = new SSMClient({ region: "ap-south-1" }); 144 | const input = { // GetParameterRequest 145 | Name: "matchmakerclientsecret" // required 146 | }; 147 | const paramSSMcommand = new GetParameterCommand(input); 148 | const paramSSMresponse= await client.send(paramSSMcommand); 149 | theSSMSecret=paramSSMresponse.Parameter.Value 150 | } 151 | //End : AWS - get client secret for validation from parameter store 152 | 153 | //Start : AWS - Get Query String from DynamoDB for Signalling Instance 154 | const getQueryStringFromDDB = async (instanceId) => { 155 | var qs='' 156 | console.log(instanceId) 157 | var params = { 158 | FilterExpression: "InstanceID = :t", 159 | ExpressionAttributeValues: { 160 | ":t": {S: instanceId} 161 | }, 162 | ProjectionExpression: "QueryString", 163 | TableName: "instanceMapping" 164 | }; 165 | 166 | const data =await ddb.scan(params).promise() 167 | qs=data.Items[0].QueryString.S 168 | console.log("Success", qs); 169 | return qs 170 | } 171 | //End : AWS - Get Query String from DynamoDB for Signalling Instance 172 | 173 | if(enableRESTAPI) { 174 | // Handle REST signalling server only request. 175 | app.options('/signallingserver', cors()) 176 | app.get('/signallingserver', cors(), async(req, res) => { 177 | //Start : AWS - check if a valid secret was provided in header to authenticate calls 178 | console.log(config.AWSRegion) 179 | await getParameterInfo() 180 | if(req.header("clientsecret") != undefined && req.header("clientsecret")==theSSMSecret) 181 | { 182 | cirrusServer = getAvailableCirrusServer(); 183 | if (cirrusServer != undefined) { 184 | var qs=await getQueryStringFromDDB(cirrusServer.instanceID); 185 | console.log("Received", qs); 186 | // The original function used to send the instance ip/port to allow connection to Signalling 187 | // We have modified the logic to send a query string which allows to connect to Signalling 188 | // via an external load balancer. The query string is in dynamoDB 189 | res.json({ signallingServer: qs}); 190 | console.log(`Returning ${cirrusServer.address}:${cirrusServer.port}`); 191 | } else { 192 | res.status(400).send('No signalling servers available'); 193 | } 194 | }else 195 | { 196 | res.status(401).send('Unauthorized'); 197 | } 198 | //End : AWS - check if a valid secret was provided in header to authenticate calls 199 | }); 200 | } 201 | 202 | if(enableRedirectionLinks) { 203 | // Handle standard URL. 204 | app.get('/', (req, res) => { 205 | cirrusServer = getAvailableCirrusServer(); 206 | if (cirrusServer != undefined) { 207 | res.redirect(`http://${cirrusServer.address}:${cirrusServer.port}/`); 208 | //console.log(req); 209 | console.log(`Redirect to ${cirrusServer.address}:${cirrusServer.port}`); 210 | } else { 211 | sendRetryResponse(res); 212 | } 213 | }); 214 | 215 | // Handle URL with custom HTML. 216 | app.get('/custom_html/:htmlFilename', (req, res) => { 217 | cirrusServer = getAvailableCirrusServer(); 218 | if (cirrusServer != undefined) { 219 | res.redirect(`http://${cirrusServer.address}:${cirrusServer.port}/custom_html/${req.params.htmlFilename}`); 220 | console.log(`Redirect to ${cirrusServer.address}:${cirrusServer.port}`); 221 | } else { 222 | sendRetryResponse(res); 223 | } 224 | }); 225 | } 226 | 227 | // 228 | // Connection to Cirrus. 229 | // 230 | 231 | const net = require('net'); 232 | 233 | function disconnect(connection) { 234 | console.log(`Ending connection to remote address ${connection.remoteAddress}`); 235 | connection.end(); 236 | } 237 | 238 | const matchmaker = net.createServer((connection) => { 239 | connection.on('data', (data) => { 240 | try { 241 | message = JSON.parse(data); 242 | 243 | if(message) 244 | console.log(`Message TYPE: ${message.type}`); 245 | } catch(e) { 246 | console.log(`ERROR (${e.toString()}): Failed to parse Cirrus information from data: ${data.toString()}`); 247 | disconnect(connection); 248 | return; 249 | } 250 | if (message.type === 'connect') { 251 | // A Cirrus server connects to this Matchmaker server. 252 | cirrusServer = { 253 | address: message.address, 254 | port: message.port, 255 | numConnectedClients: 0, 256 | lastPingReceived: Date.now(), 257 | instanceID:message.instanceId 258 | }; 259 | cirrusServer.ready = message.ready === true; 260 | 261 | // Handles disconnects between MM and SS to not add dupes with numConnectedClients = 0 and redirect users to same SS 262 | // Check if player is connected and doing a reconnect. message.playerConnected is a new variable sent from the SS to 263 | // help track whether or not a player is already connected when a 'connect' message is sent (i.e., reconnect). 264 | if(message.playerConnected == true) { 265 | cirrusServer.numConnectedClients = 1; 266 | } 267 | 268 | // Find if we already have a ciruss server address connected to (possibly a reconnect happening) 269 | let server = [...cirrusServers.entries()].find(([key, val]) => val.address === cirrusServer.address && val.port === cirrusServer.port); 270 | 271 | // if a duplicate server with the same address isn't found -- add it to the map as an available server to send users to. 272 | if (!server || server.size <= 0) { 273 | console.log(`Adding connection for ${cirrusServer.address.split(".")[0]} with playerConnected: ${message.playerConnected}`) 274 | cirrusServers.set(connection, cirrusServer); 275 | } else { 276 | console.log(`RECONNECT: cirrus server address ${cirrusServer.address.split(".")[0]} already found--replacing. playerConnected: ${message.playerConnected}`) 277 | var foundServer = cirrusServers.get(server[0]); 278 | 279 | // Make sure to retain the numConnectedClients from the last one before the reconnect to MM 280 | if (foundServer) { 281 | cirrusServers.set(connection, cirrusServer); 282 | console.log(`Replacing server with original with numConn: ${cirrusServer.numConnectedClients}`); 283 | cirrusServers.delete(server[0]); 284 | } else { 285 | cirrusServers.set(connection, cirrusServer); 286 | console.log("Connection not found in Map() -- adding a new one"); 287 | } 288 | } 289 | } else if (message.type === 'streamerConnected') { 290 | // The stream connects to a Cirrus server and so is ready to be used 291 | cirrusServer = cirrusServers.get(connection); 292 | if(cirrusServer) { 293 | cirrusServer.ready = true; 294 | console.log(`Cirrus server ${cirrusServer.address}:${cirrusServer.port} ready for use`); 295 | } else { 296 | disconnect(connection); 297 | } 298 | } else if (message.type === 'streamerDisconnected') { 299 | // The stream connects to a Cirrus server and so is ready to be used 300 | cirrusServer = cirrusServers.get(connection); 301 | if(cirrusServer) { 302 | cirrusServer.ready = false; 303 | console.log(`Cirrus server ${cirrusServer.address}:${cirrusServer.port} no longer ready for use`); 304 | } else { 305 | disconnect(connection); 306 | } 307 | } else if (message.type === 'clientConnected') { 308 | // A client connects to a Cirrus server. 309 | cirrusServer = cirrusServers.get(connection); 310 | if(cirrusServer) { 311 | cirrusServer.numConnectedClients++; 312 | console.log(`Client connected to Cirrus server ${cirrusServer.address}:${cirrusServer.port}`); 313 | } else { 314 | disconnect(connection); 315 | } 316 | } else if (message.type === 'clientDisconnected') { 317 | // A client disconnects from a Cirrus server. 318 | cirrusServer = cirrusServers.get(connection); 319 | if(cirrusServer) { 320 | cirrusServer.numConnectedClients--; 321 | console.log(`Client disconnected from Cirrus server ${cirrusServer.address}:${cirrusServer.port}`); 322 | if(cirrusServer.numConnectedClients === 0) { 323 | // this make this server immediately available for a new client 324 | cirrusServer.lastRedirect = 0; 325 | } 326 | } else { 327 | disconnect(connection); 328 | } 329 | } else if (message.type === 'ping') { 330 | cirrusServer = cirrusServers.get(connection); 331 | if(cirrusServer) { 332 | cirrusServer.lastPingReceived = Date.now(); 333 | } else { 334 | disconnect(connection); 335 | } 336 | } else { 337 | console.log('ERROR: Unknown data: ' + JSON.stringify(message)); 338 | disconnect(connection); 339 | } 340 | }); 341 | 342 | // A Cirrus server disconnects from this Matchmaker server. 343 | connection.on('error', () => { 344 | cirrusServer = cirrusServers.get(connection); 345 | if(cirrusServer) { 346 | cirrusServers.delete(connection); 347 | console.log(`Cirrus server ${cirrusServer.address}:${cirrusServer.port} disconnected from Matchmaker`); 348 | } else { 349 | console.log(`Disconnected machine that wasn't a registered cirrus server, remote address: ${connection.remoteAddress}`); 350 | } 351 | }); 352 | }); 353 | 354 | matchmaker.listen(config.MatchmakerPort, () => { 355 | console.log('Matchmaker listening on *:' + config.MatchmakerPort); 356 | }); 357 | -------------------------------------------------------------------------------- /Matchmaker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@epicgames-ps/cirrus-matchmaker", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "Cirrus servers connect to the Matchmaker which redirects a browser to the next available Cirrus server", 6 | "dependencies": { 7 | "@aws-sdk/client-ssm": "^3.362.0", 8 | "aws-sdk": "^2.1408.0", 9 | "cors": "^2.8.5", 10 | "express": "^4.18.2", 11 | "socket.io": "4.6.0", 12 | "yargs": "17.3.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Deploying Unreal Engine on AWS at scale ## 2 | 3 | This repository provides a reference framework to deploy Pixel Streaming on Unreal Engine at scale and manage streaming sessions across multiple instances of signalling servers. The repository is based on the 5.2 version of Unreal Engine's Pixel Streaming infrastructure which can be found in this [link](https://github.com/EpicGames/PixelStreamingInfrastructure/tree/UE5.2) 4 | 5 | 6 | 7 | ## Solution Architecture ## 8 | 9 | ![Solution Design](SolutionDesign.jpg) 10 | 11 | ## Dependecies ## 12 | 13 | 1. You have a functional Unreal Engine application configured with Pixel Streaming. 14 | 2. You have an AWS account which administrative privileges. 15 | 3. The [SignallingWebServer](SignallingWebServer) and [Matchmaker](Matchmaker) repositories only contain the files that have been changed to support this framework. For deploying them, you would need to merge the changes with the existing files in Unreal Engine's Pixel Streaming [repository](https://github.com/EpicGames/PixelStreamingInfrastructure/tree/UE5.2) 16 | 4. Security group for SignallingWebServer may need additional routes depending on your setup. Please update the same in [create.yml](infra/create.yaml). 17 | 18 | 19 | ## Deploying the framework ## 20 | 21 | 1. The Pixel Streaming Infrastructure contains reference implementations for all the components needed to run a pixel streaming application. Deploy [SignallingWebServer](SignallingWebServer) and [Matchmaker](Matchmaker) applications on EC2 instances of their own and [create an AMI.](https://docs.aws.amazon.com/toolkit-for-visual-studio/latest/user-guide/tkv-create-ami-from-instance.html) . For steps associated with deploymenty,refer [documentation](https://github.com/EpicGames/PixelStreamingInfrastructure/blob/UE5.2/README.md). 22 | 2. Create and EC2 instance and clone the [Frontend](Frontend) repository. Register the application as a service, running on port 8080. Once completed, please use directions from step #1 to create an AMI 23 | 3. At this step, you should have 3 AMI - SignallingWebServer, Matchmaker and Frontend applications. 24 | 4. Please upload the script [create.yml](infra/create.yaml) to cloudformation and provide the AMIs' created as input. The script would create the required infrastructure as per the solution diagram 25 | 4. Please replace the code of the lambda functions created with the code defined in [Lambda](Lambda/) 26 | 6. Please connect to the Frontend server and navigate to '/usr/customapps/pixelstreaming/Frontend/implementations/react' .Please switch to 'su' and replace the environment variables in [webpack.dev.js](Frontend/implementations/react/webpack.dev.js) and restart Frontend service.Please ensure that the frontend service shows as 'Running' with no errors 27 | 7. Please run the lambda function [uploadToDDB](infra/uploadToDDB) from your AWS console to populate DynamoDB with the required information 28 | 29 | ## Getting started ## 30 | 31 | Please retrieve the Cognito application endpoint URL from within Cognito. Below are the steps to retrieve the same 32 | - Go to the Amazon Cognito console 33 | - Choose User Pool 34 | - Choose the existing user pool from the list - ueauthenticationpool 35 | - Select the App integration tab. 36 | - Select the existing app client from the app client list 37 | - Click on the 'View Hosted UI' button to get started 38 | 39 | Please sign up with a new user .[Here](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-hosted-ui-user-sign-up.html) are the steps for the same 40 | 41 | 42 | ## Additional Considerations ## 43 | 44 | In order for the solution to run end to end, the ALB created for Matchmaker and SignallingWebServer would need to expose https endpoints. Please upload/create required SSL certificates in ACM and link them to the ALB listeners. 45 | 46 | ## Cleanup ## 47 | 48 | To cleanup, please remove the lambda code and then delete the cloudformation stack 49 | 50 | ## Disclaimer ## 51 | 52 | You should not use this AWS Content in your production accounts, or on production or other critical data. You are responsible for testing, securing, and optimizing the AWS Content, 53 | such as sample code, as appropriate for production grade use based on your specific quality control practices and standards. -------------------------------------------------------------------------------- /SignallingWebServer/cirrus.js: -------------------------------------------------------------------------------- 1 | // Copyright Epic Games, Inc. All Rights Reserved. 2 | 3 | //-- Server side logic. Serves pixel streaming WebRTC-based page, proxies data back to Streamer --// 4 | 5 | var express = require('express'); 6 | var app = express(); 7 | 8 | const fs = require('fs'); 9 | const path = require('path'); 10 | const querystring = require('querystring'); 11 | const bodyParser = require('body-parser'); 12 | const logging = require('./modules/logging.js'); 13 | logging.RegisterConsoleLogger(); 14 | 15 | // Command line argument --configFile needs to be checked before loading the config, all other command line arguments are dealt with through the config object 16 | 17 | const defaultConfig = { 18 | UseFrontend: false, 19 | UseMatchmaker: false, 20 | UseHTTPS: false, 21 | HTTPSCertFile: './certificates/client-cert.pem', 22 | HTTPSKeyFile: './certificates/client-key.pem', 23 | UseAuthentication: false, 24 | LogToFile: true, 25 | LogVerbose: true, 26 | HomepageFile: 'player.html', 27 | AdditionalRoutes: new Map(), 28 | EnableWebserver: true, 29 | MatchmakerAddress: "", 30 | MatchmakerPort: 9999, 31 | PublicIp: "localhost", 32 | HttpPort: 80, 33 | HttpsPort: 443, 34 | StreamerPort: 8888, 35 | SFUPort: 8889, 36 | MaxPlayerCount: -1, 37 | DisableSSLCert: true, 38 | // Start : AWS - InstanceID of signalling server. This is needed to get the query string later. 39 | AWSInstanceID:'' 40 | // End : AWS - InstanceID of signalling server. This is needed to get the query string later 41 | }; 42 | 43 | const argv = require('yargs').argv; 44 | var configFile = (typeof argv.configFile != 'undefined') ? argv.configFile.toString() : path.join(__dirname, 'config.json'); 45 | console.log(`configFile ${configFile}`); 46 | const config = require('./modules/config.js').init(configFile, defaultConfig); 47 | 48 | if (config.LogToFile) { 49 | logging.RegisterFileLogger('./logs/'); 50 | } 51 | 52 | console.log("Config: " + JSON.stringify(config, null, '\t')); 53 | 54 | var http = require('http').Server(app); 55 | 56 | if (config.UseHTTPS) { 57 | //HTTPS certificate details 58 | const options = { 59 | key: fs.readFileSync(path.join(__dirname, config.HTTPSKeyFile)), 60 | cert: fs.readFileSync(path.join(__dirname, config.HTTPSCertFile)) 61 | }; 62 | 63 | var https = require('https').Server(options, app); 64 | } 65 | 66 | //If not using authetication then just move on to the next function/middleware 67 | var isAuthenticated = redirectUrl => function (req, res, next) { return next(); } 68 | 69 | if (config.UseAuthentication && config.UseHTTPS) { 70 | var passport = require('passport'); 71 | require('./modules/authentication').init(app); 72 | // Replace the isAuthenticated with the one setup on passport module 73 | isAuthenticated = passport.authenticationMiddleware ? passport.authenticationMiddleware : isAuthenticated 74 | } else if (config.UseAuthentication && !config.UseHTTPS) { 75 | console.error('Trying to use authentication without using HTTPS, this is not allowed and so authentication will NOT be turned on, please turn on HTTPS to turn on authentication'); 76 | } 77 | 78 | const helmet = require('helmet'); 79 | var hsts = require('hsts'); 80 | var net = require('net'); 81 | 82 | var FRONTEND_WEBSERVER = 'https://localhost'; 83 | if (config.UseFrontend) { 84 | var httpPort = 3000; 85 | var httpsPort = 8000; 86 | 87 | if (config.UseHTTPS && config.DisableSSLCert) { 88 | //Required for self signed certs otherwise just get an error back when sending request to frontend see https://stackoverflow.com/a/35633993 89 | console.logColor(logging.Orange, 'WARNING: config.DisableSSLCert is true. Unauthorized SSL certificates will be allowed! This is convenient for local testing but please DO NOT SHIP THIS IN PRODUCTION. To remove this warning please set DisableSSLCert to false in your config.json.'); 90 | process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0" 91 | } 92 | 93 | const httpsClient = require('./modules/httpsClient.js'); 94 | var webRequest = new httpsClient(); 95 | } else { 96 | var httpPort = config.HttpPort; 97 | var httpsPort = config.HttpsPort; 98 | } 99 | 100 | var streamerPort = config.StreamerPort; // port to listen to Streamer connections 101 | var sfuPort = config.SFUPort; 102 | 103 | var matchmakerAddress = '127.0.0.1'; 104 | var matchmakerPort = 9999; 105 | var matchmakerRetryInterval = 5; 106 | var matchmakerKeepAliveInterval = 30; 107 | var maxPlayerCount = -1; 108 | 109 | var gameSessionId; 110 | var userSessionId; 111 | var serverPublicIp; 112 | var AWSInstanceID; 113 | 114 | // `clientConfig` is send to Streamer and Players 115 | // Example of STUN server setting 116 | // let clientConfig = {peerConnectionOptions: { 'iceServers': [{'urls': ['stun:34.250.222.95:19302']}] }}; 117 | var clientConfig = { type: 'config', peerConnectionOptions: {} }; 118 | 119 | // Parse public server address from command line 120 | // --publicIp 121 | try { 122 | if (typeof config.PublicIp != 'undefined') { 123 | serverPublicIp = config.PublicIp.toString(); 124 | } 125 | // Start : AWS - InstanceID of signalling server 126 | if (typeof config.AWSInstanceID != 'undefined') { 127 | AWSInstanceID = config.AWSInstanceID.toString(); 128 | } 129 | // End : AWS - InstanceID of signalling server 130 | 131 | if (typeof config.HttpPort != 'undefined') { 132 | httpPort = config.HttpPort; 133 | } 134 | 135 | if (typeof config.HttpsPort != 'undefined') { 136 | httpsPort = config.HttpsPort; 137 | } 138 | 139 | if (typeof config.StreamerPort != 'undefined') { 140 | streamerPort = config.StreamerPort; 141 | } 142 | 143 | if (typeof config.SFUPort != 'undefined') { 144 | sfuPort = config.SFUPort; 145 | } 146 | 147 | if (typeof config.FrontendUrl != 'undefined') { 148 | FRONTEND_WEBSERVER = config.FrontendUrl; 149 | } 150 | 151 | if (typeof config.peerConnectionOptions != 'undefined') { 152 | clientConfig.peerConnectionOptions = JSON.parse(config.peerConnectionOptions); 153 | console.log(`peerConnectionOptions = ${JSON.stringify(clientConfig.peerConnectionOptions)}`); 154 | } else { 155 | console.log("No peerConnectionConfig") 156 | } 157 | 158 | if (typeof config.MatchmakerAddress != 'undefined') { 159 | matchmakerAddress = config.MatchmakerAddress; 160 | } 161 | 162 | if (typeof config.MatchmakerPort != 'undefined') { 163 | matchmakerPort = config.MatchmakerPort; 164 | } 165 | 166 | if (typeof config.MatchmakerRetryInterval != 'undefined') { 167 | matchmakerRetryInterval = config.MatchmakerRetryInterval; 168 | } 169 | 170 | if (typeof config.MaxPlayerCount != 'undefined') { 171 | maxPlayerCount = config.MaxPlayerCount; 172 | } 173 | } catch (e) { 174 | console.error(e); 175 | process.exit(2); 176 | } 177 | 178 | if (config.UseHTTPS) { 179 | app.use(helmet()); 180 | 181 | app.use(hsts({ 182 | maxAge: 15552000 // 180 days in seconds 183 | })); 184 | 185 | //Setup http -> https redirect 186 | console.log('Redirecting http->https'); 187 | app.use(function (req, res, next) { 188 | if (!req.secure) { 189 | if (req.get('Host')) { 190 | var hostAddressParts = req.get('Host').split(':'); 191 | var hostAddress = hostAddressParts[0]; 192 | if (httpsPort != 443) { 193 | hostAddress = `${hostAddress}:${httpsPort}`; 194 | } 195 | return res.redirect(['https://', hostAddress, req.originalUrl].join('')); 196 | } else { 197 | console.error(`unable to get host name from header. Requestor ${req.ip}, url path: '${req.originalUrl}', available headers ${JSON.stringify(req.headers)}`); 198 | return res.status(400).send('Bad Request'); 199 | } 200 | } 201 | next(); 202 | }); 203 | } 204 | 205 | sendGameSessionData(); 206 | 207 | // set up rate limiter: maximum of five requests per minute 208 | var RateLimit = require('express-rate-limit'); 209 | var limiter = RateLimit({ 210 | windowMs: 1*60*1000, // 1 minute 211 | max: 60 212 | }); 213 | 214 | // apply rate limiter to all requests 215 | app.use(limiter); 216 | 217 | //Setup the login page if we are using authentication 218 | if(config.UseAuthentication){ 219 | if(config.EnableWebserver) { 220 | app.get('/login', function(req, res){ 221 | res.sendFile(path.join(__dirname, '/Public', '/login.html')); 222 | }); 223 | } 224 | 225 | // create application/x-www-form-urlencoded parser 226 | var urlencodedParser = bodyParser.urlencoded({ extended: false }) 227 | 228 | //login page form data is posted here 229 | app.post('/login', 230 | urlencodedParser, 231 | passport.authenticate('local', { failureRedirect: '/login' }), 232 | function(req, res){ 233 | //On success try to redirect to the page that they originally tired to get to, default to '/' if no redirect was found 234 | var redirectTo = req.session.redirectTo ? req.session.redirectTo : '/'; 235 | delete req.session.redirectTo; 236 | console.log(`Redirecting to: '${redirectTo}'`); 237 | res.redirect(redirectTo); 238 | } 239 | ); 240 | } 241 | 242 | if(config.EnableWebserver) { 243 | //Setup folders 244 | app.use(express.static(path.join(__dirname, '/Public'))) 245 | app.use('/images', express.static(path.join(__dirname, './images'))) 246 | app.use('/scripts', [isAuthenticated('/login'),express.static(path.join(__dirname, '/scripts'))]); 247 | app.use('/', [isAuthenticated('/login'), express.static(path.join(__dirname, '/custom_html'))]) 248 | } 249 | 250 | try { 251 | for (var property in config.AdditionalRoutes) { 252 | if (config.AdditionalRoutes.hasOwnProperty(property)) { 253 | console.log(`Adding additional routes "${property}" -> "${config.AdditionalRoutes[property]}"`) 254 | app.use(property, [isAuthenticated('/login'), express.static(path.join(__dirname, config.AdditionalRoutes[property]))]); 255 | } 256 | } 257 | } catch (err) { 258 | console.error(`reading config.AdditionalRoutes: ${err}`) 259 | } 260 | 261 | if(config.EnableWebserver) { 262 | 263 | // Request has been sent to site root, send the homepage file 264 | app.get('/', isAuthenticated('/login'), function (req, res) { 265 | homepageFile = (typeof config.HomepageFile != 'undefined' && config.HomepageFile != '') ? config.HomepageFile.toString() : defaultConfig.HomepageFile; 266 | 267 | let pathsToTry = [ path.join(__dirname, homepageFile), path.join(__dirname, '/Public', homepageFile), path.join(__dirname, '/custom_html', homepageFile), homepageFile ]; 268 | 269 | // Try a few paths, see if any resolve to a homepage file the user has set 270 | for(let pathToTry of pathsToTry){ 271 | if(fs.existsSync(pathToTry)){ 272 | // Send the file for browser to display it 273 | res.sendFile(pathToTry); 274 | return; 275 | } 276 | } 277 | 278 | // Catch file doesn't exist, and send back 404 if not 279 | console.error('Unable to locate file ' + homepageFile) 280 | res.status(404).send('Unable to locate file ' + homepageFile); 281 | return; 282 | }); 283 | } 284 | 285 | //Setup http and https servers 286 | http.listen(httpPort, function () { 287 | console.logColor(logging.Green, 'Http listening on *: ' + httpPort); 288 | }); 289 | 290 | if (config.UseHTTPS) { 291 | https.listen(httpsPort, function () { 292 | console.logColor(logging.Green, 'Https listening on *: ' + httpsPort); 293 | }); 294 | } 295 | 296 | console.logColor(logging.Cyan, `Running Cirrus - The Pixel Streaming reference implementation signalling server for Unreal Engine 5.2.`); 297 | 298 | let nextPlayerId = 1; 299 | 300 | const PlayerType = { Regular: 0, SFU: 1 }; 301 | 302 | class Player { 303 | constructor(id, ws, type, browserSendOffer) { 304 | this.id = id; 305 | this.ws = ws; 306 | this.type = type; 307 | this.browserSendOffer = browserSendOffer; 308 | } 309 | 310 | subscribe(streamerId) { 311 | if (!streamers.has(streamerId)) { 312 | console.error(`subscribe: Player ${this.id} tried to subscribe to a non-existent streamer ${streamerId}`); 313 | return; 314 | } 315 | this.streamerId = streamerId; 316 | const msg = { type: 'playerConnected', playerId: this.id, dataChannel: true, sfu: this.type == PlayerType.SFU, sendOffer: !this.browserSendOffer }; 317 | logOutgoing(this.streamerId, msg); 318 | this.sendFrom(msg); 319 | } 320 | 321 | unsubscribe() { 322 | if (this.streamerId && streamers.has(this.streamerId)) { 323 | const msg = { type: 'playerDisconnected', playerId: this.id }; 324 | logOutgoing(this.streamerId, msg); 325 | this.sendFrom(msg); 326 | } 327 | this.streamerId = null; 328 | } 329 | 330 | sendFrom(message) { 331 | if (!this.streamerId) { 332 | if(streamers.size > 0) { 333 | this.streamerId = streamers.entries().next().value[0]; 334 | console.logColor(logging.Orange, `Player ${this.id} attempted to send an outgoing message without having subscribed first. Defaulting to ${this.streamerId}`); 335 | } else { 336 | console.logColor(logging.Orange, `Player ${this.id} attempted to send an outgoing message without having subscribed first. No streamer connected so this message isn't going anywhere!`) 337 | return; 338 | } 339 | } 340 | 341 | // normally we want to indicate what player this message came from 342 | // but in some instances we might already have set this (streamerDataChannels) due to poor choices 343 | if (!message.playerId) { 344 | message.playerId = this.id; 345 | } 346 | const msgString = JSON.stringify(message); 347 | 348 | let streamer = streamers.get(this.streamerId); 349 | if (!streamer) { 350 | console.error(`sendFrom: Player ${this.id} subscribed to non-existent streamer: ${this.streamerId}`); 351 | } else { 352 | streamer.ws.send(msgString); 353 | } 354 | } 355 | 356 | sendTo(message) { 357 | const msgString = JSON.stringify(message); 358 | this.ws.send(msgString); 359 | } 360 | }; 361 | 362 | let streamers = new Map(); // streamerId <-> streamer socket 363 | let players = new Map(); // playerId <-> player, where player is either a web-browser or a native webrtc player 364 | const SFUPlayerId = "SFU"; 365 | const LegacyStreamerId = "__LEGACY__"; // old streamers that dont know how to ID will be assigned this id. 366 | 367 | function sfuIsConnected() { 368 | const sfuPlayer = players.get(SFUPlayerId); 369 | return sfuPlayer && sfuPlayer.ws && sfuPlayer.ws.readyState == 1; 370 | } 371 | 372 | function getSFU() { 373 | return players.get(SFUPlayerId); 374 | } 375 | 376 | function logIncoming(sourceName, msg) { 377 | if (config.LogVerbose) 378 | console.logColor(logging.Blue, "\x1b[37m%s ->\x1b[34m %s", sourceName, JSON.stringify(msg)); 379 | else 380 | console.logColor(logging.Blue, "\x1b[37m%s ->\x1b[34m %s", sourceName, msg.type); 381 | } 382 | 383 | function logOutgoing(destName, msg) { 384 | if (config.LogVerbose) 385 | console.logColor(logging.Green, "\x1b[37m%s <-\x1b[32m %s", destName, JSON.stringify(msg)); 386 | else 387 | console.logColor(logging.Green, "\x1b[37m%s <-\x1b[32m %s", destName, msg.type); 388 | } 389 | 390 | function logForward(srcName, destName, msg) { 391 | if (config.LogVerbose) 392 | console.logColor(logging.Cyan, "\x1b[37m%s -> %s\x1b[36m %s", srcName, destName, JSON.stringify(msg)); 393 | else 394 | console.logColor(logging.Cyan, "\x1b[37m%s -> %s\x1b[36m %s", srcName, destName, msg.type); 395 | } 396 | 397 | let WebSocket = require('ws'); 398 | 399 | let sfuMessageHandlers = new Map(); 400 | let playerMessageHandlers = new Map(); 401 | 402 | function sanitizePlayerId(playerId) { 403 | if (playerId && typeof playerId === 'number') { 404 | playerId = playerId.toString(); 405 | } 406 | return playerId; 407 | } 408 | 409 | function getPlayerIdFromMessage(msg) { 410 | return sanitizePlayerId(msg.playerId); 411 | } 412 | 413 | function registerStreamer(id, streamer) { 414 | streamer.id = id; 415 | streamers.set(streamer.id, streamer); 416 | } 417 | 418 | function onStreamerDisconnected(streamer) { 419 | if (!streamer.id) { 420 | return; 421 | } 422 | 423 | if (!streamers.has(streamer.id)) { 424 | console.error(`Disconnecting streamer ${streamer.id} does not exist.`); 425 | } else { 426 | sendStreamerDisconnectedToMatchmaker(); 427 | let sfuPlayer = getSFU(); 428 | if (sfuPlayer) { 429 | const msg = { type: "streamerDisconnected" }; 430 | logOutgoing(sfuPlayer.id, msg); 431 | sfuPlayer.sendTo(msg); 432 | disconnectAllPlayers(sfuPlayer.id); 433 | } 434 | disconnectAllPlayers(streamer.id); 435 | streamers.delete(streamer.id); 436 | } 437 | } 438 | 439 | function onStreamerMessageId(streamer, msg) { 440 | logIncoming(streamer.id, msg); 441 | 442 | let streamerId = msg.id; 443 | registerStreamer(streamerId, streamer); 444 | 445 | // subscribe any sfu to the latest connected streamer 446 | const sfuPlayer = getSFU(); 447 | if (sfuPlayer) { 448 | sfuPlayer.subscribe(streamer.id); 449 | } 450 | 451 | // if any streamer id's assume the legacy streamer is not needed. 452 | streamers.delete(LegacyStreamerId); 453 | } 454 | 455 | function onStreamerMessagePing(streamer, msg) { 456 | logIncoming(streamer.id, msg); 457 | 458 | const pongMsg = JSON.stringify({ type: "pong", time: msg.time}); 459 | streamer.ws.send(pongMsg); 460 | } 461 | 462 | function onStreamerMessageDisconnectPlayer(streamer, msg) { 463 | logIncoming(streamer.id, msg); 464 | 465 | const playerId = getPlayerIdFromMessage(msg); 466 | const player = players.get(playerId); 467 | if (player) { 468 | player.ws.close(1011 /* internal error */, msg.reason); 469 | } 470 | } 471 | 472 | function onStreamerMessageLayerPreference(streamer, msg) { 473 | let sfuPlayer = getSFU(); 474 | if (sfuPlayer) { 475 | logOutgoing(sfuPlayer.id, msg); 476 | sfuPlayer.sendTo(msg); 477 | } 478 | } 479 | 480 | function forwardStreamerMessageToPlayer(streamer, msg) { 481 | const playerId = getPlayerIdFromMessage(msg); 482 | const player = players.get(playerId); 483 | if (player) { 484 | delete msg.playerId; 485 | logForward(streamer.id, playerId, msg); 486 | player.sendTo(msg); 487 | } else { 488 | console.warning("No playerId specified, cannot forward message: %s", msg); 489 | } 490 | } 491 | 492 | let streamerMessageHandlers = new Map(); 493 | streamerMessageHandlers.set('endpointId', onStreamerMessageId); 494 | streamerMessageHandlers.set('ping', onStreamerMessagePing); 495 | streamerMessageHandlers.set('offer', forwardStreamerMessageToPlayer); 496 | streamerMessageHandlers.set('answer', forwardStreamerMessageToPlayer); 497 | streamerMessageHandlers.set('iceCandidate', forwardStreamerMessageToPlayer); 498 | streamerMessageHandlers.set('disconnectPlayer', onStreamerMessageDisconnectPlayer); 499 | streamerMessageHandlers.set('layerPreference', onStreamerMessageLayerPreference); 500 | 501 | console.logColor(logging.Green, `WebSocket listening for Streamer connections on :${streamerPort}`) 502 | let streamerServer = new WebSocket.Server({ port: streamerPort, backlog: 1 }); 503 | streamerServer.on('connection', function (ws, req) { 504 | console.logColor(logging.Green, `Streamer connected: ${req.connection.remoteAddress}`); 505 | sendStreamerConnectedToMatchmaker(); 506 | 507 | let streamer = { ws: ws }; 508 | 509 | ws.on('message', (msgRaw) => { 510 | var msg; 511 | try { 512 | msg = JSON.parse(msgRaw); 513 | } catch(err) { 514 | console.error(`Cannot parse Streamer message: ${msgRaw}\nError: ${err}`); 515 | ws.close(1008, 'Cannot parse'); 516 | return; 517 | } 518 | 519 | let handler = streamerMessageHandlers.get(msg.type); 520 | if (!handler || (typeof handler != 'function')) { 521 | if (config.LogVerbose) { 522 | console.logColor(logging.White, "\x1b[37m-> %s\x1b[34m: %s", streamer.id, msgRaw); 523 | } 524 | console.error(`unsupported Streamer message type: ${msg.type}`); 525 | ws.close(1008, 'Unsupported message type'); 526 | return; 527 | } 528 | handler(streamer, msg); 529 | }); 530 | 531 | ws.on('close', function(code, reason) { 532 | console.error(`streamer ${streamer.id} disconnected: ${code} - ${reason}`); 533 | onStreamerDisconnected(streamer); 534 | }); 535 | 536 | ws.on('error', function(error) { 537 | console.error(`streamer ${streamer.id} connection error: ${error}`); 538 | onStreamerDisconnected(streamer); 539 | try { 540 | ws.close(1006 /* abnormal closure */, error); 541 | } catch(err) { 542 | console.error(`ERROR: ws.on error: ${err.message}`); 543 | } 544 | }); 545 | 546 | ws.send(JSON.stringify(clientConfig)); 547 | 548 | // request id 549 | const msg = { type: "identify" }; 550 | logOutgoing("unknown", msg); 551 | ws.send(JSON.stringify(msg)); 552 | 553 | registerStreamer(LegacyStreamerId, streamer); 554 | }); 555 | 556 | function forwardSFUMessageToPlayer(msg) { 557 | const playerId = getPlayerIdFromMessage(msg); 558 | const player = players.get(playerId); 559 | if (player) { 560 | logForward(SFUPlayerId, playerId, msg); 561 | player.sendTo(msg); 562 | } 563 | } 564 | 565 | function forwardSFUMessageToStreamer(msg) { 566 | const sfuPlayer = getSFU(); 567 | if (sfuPlayer) { 568 | logForward(SFUPlayerId, sfuPlayer.streamerId, msg); 569 | msg.sfuId = SFUPlayerId; 570 | sfuPlayer.sendFrom(msg); 571 | } 572 | } 573 | 574 | function onPeerDataChannelsSFUMessage(msg) { 575 | // sfu is telling a peer what stream id to use for a data channel 576 | const playerId = getPlayerIdFromMessage(msg); 577 | const player = players.get(playerId); 578 | if (player) { 579 | logForward(SFUPlayerId, playerId, msg); 580 | player.sendTo(msg); 581 | player.datachannel = true; 582 | } 583 | } 584 | 585 | function onSFUDisconnected() { 586 | console.log("disconnecting SFU from streamer"); 587 | disconnectAllPlayers(SFUPlayerId); 588 | const sfuPlayer = getSFU(); 589 | if (sfuPlayer) { 590 | sfuPlayer.unsubscribe(); 591 | sfuPlayer.ws.close(4000, "SFU Disconnected"); 592 | } 593 | players.delete(SFUPlayerId); 594 | streamers.delete(SFUPlayerId); 595 | } 596 | 597 | sfuMessageHandlers.set('offer', forwardSFUMessageToPlayer); 598 | sfuMessageHandlers.set('answer', forwardSFUMessageToStreamer); 599 | sfuMessageHandlers.set('streamerDataChannels', forwardSFUMessageToStreamer); 600 | sfuMessageHandlers.set('peerDataChannels', onPeerDataChannelsSFUMessage); 601 | 602 | console.logColor(logging.Green, `WebSocket listening for SFU connections on :${sfuPort}`); 603 | let sfuServer = new WebSocket.Server({ port: sfuPort }); 604 | sfuServer.on('connection', function (ws, req) { 605 | // reject if we already have an sfu 606 | if (sfuIsConnected()) { 607 | ws.close(1013, 'Already have an SFU'); 608 | return; 609 | } 610 | 611 | ws.on('message', (msgRaw) => { 612 | var msg; 613 | try { 614 | msg = JSON.parse(msgRaw); 615 | } catch (err) { 616 | console.error(`Cannot parse SFU message: ${msgRaw}\nError: ${err}`); 617 | ws.close(1008, 'Cannot parse'); 618 | return; 619 | } 620 | 621 | let handler = sfuMessageHandlers.get(msg.type); 622 | if (!handler || (typeof handler != 'function')) { 623 | if (config.LogVerbose) { 624 | console.logColor(logging.White, "\x1b[37m-> %s\x1b[34m: %s", SFUPlayerId, msgRaw); 625 | } 626 | console.error(`unsupported SFU message type: ${msg.type}`); 627 | ws.close(1008, 'Unsupported message type'); 628 | return; 629 | } 630 | handler(msg); 631 | }); 632 | 633 | ws.on('close', function(code, reason) { 634 | console.error(`SFU disconnected: ${code} - ${reason}`); 635 | onSFUDisconnected(); 636 | }); 637 | 638 | ws.on('error', function(error) { 639 | console.error(`SFU connection error: ${error}`); 640 | onSFUDisconnected(); 641 | try { 642 | ws.close(1006 /* abnormal closure */, error); 643 | } catch(err) { 644 | console.error(`ERROR: ws.on error: ${err.message}`); 645 | } 646 | }); 647 | 648 | let sfuPlayer = new Player(SFUPlayerId, ws, PlayerType.SFU, false); 649 | players.set(SFUPlayerId, sfuPlayer); 650 | console.logColor(logging.Green, `SFU (${req.connection.remoteAddress}) connected `); 651 | 652 | // TODO subscribe it to one of any of the streamers for now 653 | for (let [streamerId, streamer] of streamers) { 654 | sfuPlayer.subscribe(streamerId); 655 | break; 656 | } 657 | 658 | // sfu also acts as a streamer 659 | registerStreamer(SFUPlayerId, { ws: ws }); 660 | }); 661 | 662 | let playerCount = 0; 663 | 664 | function sendPlayersCount() { 665 | const msg = { type: 'playerCount', count: players.size }; 666 | logOutgoing("[players]", msg); 667 | for (let player of players.values()) { 668 | player.sendTo(msg); 669 | } 670 | } 671 | 672 | function onPlayerMessageSubscribe(player, msg) { 673 | logIncoming(player.id, msg); 674 | player.subscribe(msg.streamerId); 675 | } 676 | 677 | function onPlayerMessageUnsubscribe(player, msg) { 678 | logIncoming(player.id, msg); 679 | player.unsubscribe(); 680 | } 681 | 682 | function onPlayerMessageStats(player, msg) { 683 | console.log(`player ${playerId}: stats\n${msg.data}`); 684 | } 685 | 686 | function onPlayerMessageListStreamers(player, msg) { 687 | logIncoming(player.id, msg); 688 | 689 | let reply = { type: 'streamerList', ids: [] }; 690 | for (let [streamerId, streamer] of streamers) { 691 | reply.ids.push(streamerId); 692 | } 693 | 694 | logOutgoing(player.id, reply); 695 | player.sendTo(reply); 696 | } 697 | 698 | function forwardPlayerMessage(player, msg) { 699 | logForward(player.id, player.streamerId, msg); 700 | player.sendFrom(msg); 701 | } 702 | 703 | function onPlayerDisconnected(playerId) { 704 | const player = players.get(playerId); 705 | player.unsubscribe(); 706 | players.delete(playerId); 707 | --playerCount; 708 | sendPlayersCount(); 709 | sendPlayerDisconnectedToFrontend(); 710 | sendPlayerDisconnectedToMatchmaker(); 711 | } 712 | 713 | playerMessageHandlers.set('subscribe', onPlayerMessageSubscribe); 714 | playerMessageHandlers.set('unsubscribe', onPlayerMessageUnsubscribe); 715 | playerMessageHandlers.set('stats', onPlayerMessageStats); 716 | playerMessageHandlers.set('offer', forwardPlayerMessage); 717 | playerMessageHandlers.set('answer', forwardPlayerMessage); 718 | playerMessageHandlers.set('iceCandidate', forwardPlayerMessage); 719 | playerMessageHandlers.set('listStreamers', onPlayerMessageListStreamers); 720 | // sfu related messages 721 | playerMessageHandlers.set('dataChannelRequest', forwardPlayerMessage); 722 | playerMessageHandlers.set('peerDataChannelsReady', forwardPlayerMessage); 723 | 724 | console.logColor(logging.Green, `WebSocket listening for Players connections on :${httpPort}`); 725 | let playerServer = new WebSocket.Server({ server: config.UseHTTPS ? https : http}); 726 | playerServer.on('connection', function (ws, req) { 727 | var url = require('url'); 728 | const parsedUrl = url.parse(req.url); 729 | const urlParams = new URLSearchParams(parsedUrl.search); 730 | const browserSendOffer = urlParams.has('OfferToReceive') && urlParams.get('OfferToReceive') !== 'false'; 731 | 732 | if (playerCount + 1 > maxPlayerCount && maxPlayerCount !== -1) 733 | { 734 | console.logColor(logging.Red, `new connection would exceed number of allowed concurrent connections. Max: ${maxPlayerCount}, Current ${playerCount}`); 735 | ws.close(1013, `too many connections. max: ${maxPlayerCount}, current: ${playerCount}`); 736 | return; 737 | } 738 | 739 | ++playerCount; 740 | let playerId = sanitizePlayerId(nextPlayerId++); 741 | console.logColor(logging.Green, `player ${playerId} (${req.connection.remoteAddress}) connected`); 742 | let player = new Player(playerId, ws, PlayerType.Regular, browserSendOffer); 743 | players.set(playerId, player); 744 | 745 | ws.on('message', (msgRaw) =>{ 746 | var msg; 747 | try { 748 | msg = JSON.parse(msgRaw); 749 | } catch (err) { 750 | console.error(`Cannot parse player ${playerId} message: ${msgRaw}\nError: ${err}`); 751 | ws.close(1008, 'Cannot parse'); 752 | return; 753 | } 754 | 755 | let player = players.get(playerId); 756 | if (!player) { 757 | console.error(`Received a message from a player not in the player list ${playerId}`); 758 | ws.close(1001, 'Broken'); 759 | return; 760 | } 761 | 762 | let handler = playerMessageHandlers.get(msg.type); 763 | if (!handler || (typeof handler != 'function')) { 764 | if (config.LogVerbose) { 765 | console.logColor(logging.White, "\x1b[37m-> %s\x1b[34m: %s", playerId, msgRaw); 766 | } 767 | console.error(`unsupported player message type: ${msg.type}`); 768 | ws.close(1008, 'Unsupported message type'); 769 | return; 770 | } 771 | handler(player, msg); 772 | }); 773 | 774 | ws.on('close', function(code, reason) { 775 | console.logColor(logging.Yellow, `player ${playerId} connection closed: ${code} - ${reason}`); 776 | onPlayerDisconnected(playerId); 777 | }); 778 | 779 | ws.on('error', function(error) { 780 | console.error(`player ${playerId} connection error: ${error}`); 781 | ws.close(1006 /* abnormal closure */, error); 782 | onPlayerDisconnected(playerId); 783 | 784 | console.logColor(logging.Red, `Trying to reconnect...`); 785 | reconnect(); 786 | }); 787 | 788 | sendPlayerConnectedToFrontend(); 789 | sendPlayerConnectedToMatchmaker(); 790 | player.ws.send(JSON.stringify(clientConfig)); 791 | sendPlayersCount(); 792 | }); 793 | 794 | function disconnectAllPlayers(streamerId) { 795 | console.log(`unsubscribing all players on ${streamerId}`); 796 | let clone = new Map(players); 797 | for (let player of clone.values()) { 798 | if (player.streamerId == streamerId) { 799 | // disconnect players but just unsubscribe the SFU 800 | if (player.id == SFUPlayerId) { 801 | // because we're working on a clone here we have to access directly 802 | getSFU().unsubscribe(); 803 | } else { 804 | player.ws.close(); 805 | } 806 | } 807 | } 808 | } 809 | 810 | /** 811 | * Function that handles the connection to the matchmaker. 812 | */ 813 | 814 | if (config.UseMatchmaker) { 815 | var matchmaker = new net.Socket(); 816 | 817 | matchmaker.on('connect', function() { 818 | console.log(`Cirrus connected to Matchmaker ${matchmakerAddress}:${matchmakerPort}`); 819 | 820 | // message.playerConnected is a new variable sent from the SS to help track whether or not a player 821 | // is already connected when a 'connect' message is sent (i.e., reconnect). This happens when the MM 822 | // and the SS get disconnected unexpectedly (was happening often at scale for some reason). 823 | var playerConnected = false; 824 | 825 | // Set the playerConnected flag to tell the MM if there is already a player active (i.e., don't send a new one here) 826 | if( players && players.size > 0) { 827 | playerConnected = true; 828 | } 829 | 830 | // Add the new playerConnected flag to the message body to the MM 831 | message = { 832 | type: 'connect', 833 | address: typeof serverPublicIp === 'undefined' ? '127.0.0.1' : serverPublicIp, 834 | port: config.UseHTTPS ? httpsPort : httpPort, 835 | //ready: streamers.size > 0, 836 | // Start : AWS - Modified for testing, please comment this line in actual implemntation 837 | ready: true, 838 | // End : AWS - Modified for testing, please comment this line in actual implemntation 839 | playerConnected: playerConnected, 840 | // Start : AWS - InstanceID of signalling server is passed to MatchMaker 841 | // this is used in Matchmaker to get the querystring from dynamoDB 842 | instanceId:AWSInstanceID 843 | // End : AWS - InstanceID of signalling server is passed to Matchmaker 844 | }; 845 | 846 | matchmaker.write(JSON.stringify(message)); 847 | }); 848 | 849 | matchmaker.on('error', (err) => { 850 | console.log(`Matchmaker connection error ${JSON.stringify(err)}`); 851 | }); 852 | 853 | matchmaker.on('end', () => { 854 | console.log('Matchmaker connection ended'); 855 | }); 856 | 857 | matchmaker.on('close', (hadError) => { 858 | console.logColor(logging.Blue, 'Setting Keep Alive to true'); 859 | matchmaker.setKeepAlive(true, 60000); // Keeps it alive for 60 seconds 860 | 861 | console.log(`Matchmaker connection closed (hadError=${hadError})`); 862 | 863 | reconnect(); 864 | }); 865 | 866 | // Attempt to connect to the Matchmaker 867 | function connect() { 868 | matchmaker.connect(matchmakerPort, matchmakerAddress); 869 | } 870 | 871 | // Try to reconnect to the Matchmaker after a given period of time 872 | function reconnect() { 873 | console.log(`Try reconnect to Matchmaker in ${matchmakerRetryInterval} seconds`) 874 | setTimeout(function() { 875 | connect(); 876 | }, matchmakerRetryInterval * 1000); 877 | } 878 | 879 | function registerMMKeepAlive() { 880 | setInterval(function() { 881 | message = { 882 | type: 'ping' 883 | }; 884 | matchmaker.write(JSON.stringify(message)); 885 | }, matchmakerKeepAliveInterval * 1000); 886 | } 887 | 888 | connect(); 889 | registerMMKeepAlive(); 890 | } 891 | 892 | //Keep trying to send gameSessionId in case the server isn't ready yet 893 | function sendGameSessionData() { 894 | //If we are not using the frontend web server don't try and make requests to it 895 | if (!config.UseFrontend) 896 | return; 897 | webRequest.get(`${FRONTEND_WEBSERVER}/server/requestSessionId`, 898 | function (response, body) { 899 | if (response.statusCode === 200) { 900 | gameSessionId = body; 901 | console.log('SessionId: ' + gameSessionId); 902 | } 903 | else { 904 | console.error('Status code: ' + response.statusCode); 905 | console.error(body); 906 | } 907 | }, 908 | function (err) { 909 | //Repeatedly try in cases where the connection timed out or never connected 910 | if (err.code === "ECONNRESET") { 911 | //timeout 912 | sendGameSessionData(); 913 | } else if (err.code === 'ECONNREFUSED') { 914 | console.error('Frontend server not running, unable to setup game session'); 915 | } else { 916 | console.error(err); 917 | } 918 | }); 919 | } 920 | 921 | function sendUserSessionData(serverPort) { 922 | //If we are not using the frontend web server don't try and make requests to it 923 | if (!config.UseFrontend) 924 | return; 925 | webRequest.get(`${FRONTEND_WEBSERVER}/server/requestUserSessionId?gameSessionId=${gameSessionId}&serverPort=${serverPort}&appName=${querystring.escape(clientConfig.AppName)}&appDescription=${querystring.escape(clientConfig.AppDescription)}${(typeof serverPublicIp === 'undefined' ? '' : '&serverHost=' + serverPublicIp)}`, 926 | function (response, body) { 927 | if (response.statusCode === 410) { 928 | sendUserSessionData(serverPort); 929 | } else if (response.statusCode === 200) { 930 | userSessionId = body; 931 | console.log('UserSessionId: ' + userSessionId); 932 | } else { 933 | console.error('Status code: ' + response.statusCode); 934 | console.error(body); 935 | } 936 | }, 937 | function (err) { 938 | //Repeatedly try in cases where the connection timed out or never connected 939 | if (err.code === "ECONNRESET") { 940 | //timeout 941 | sendUserSessionData(serverPort); 942 | } else if (err.code === 'ECONNREFUSED') { 943 | console.error('Frontend server not running, unable to setup user session'); 944 | } else { 945 | console.error(err); 946 | } 947 | }); 948 | } 949 | 950 | function sendServerDisconnect() { 951 | //If we are not using the frontend web server don't try and make requests to it 952 | if (!config.UseFrontend) 953 | return; 954 | try { 955 | webRequest.get(`${FRONTEND_WEBSERVER}/server/serverDisconnected?gameSessionId=${gameSessionId}&appName=${querystring.escape(clientConfig.AppName)}`, 956 | function (response, body) { 957 | if (response.statusCode === 200) { 958 | console.log('serverDisconnected acknowledged by Frontend'); 959 | } else { 960 | console.error('Status code: ' + response.statusCode); 961 | console.error(body); 962 | } 963 | }, 964 | function (err) { 965 | //Repeatedly try in cases where the connection timed out or never connected 966 | if (err.code === "ECONNRESET") { 967 | //timeout 968 | sendServerDisconnect(); 969 | } else if (err.code === 'ECONNREFUSED') { 970 | console.error('Frontend server not running, unable to setup user session'); 971 | } else { 972 | console.error(err); 973 | } 974 | }); 975 | } catch(err) { 976 | console.logColor(logging.Red, `ERROR::: sendServerDisconnect error: ${err.message}`); 977 | } 978 | } 979 | 980 | function sendPlayerConnectedToFrontend() { 981 | //If we are not using the frontend web server don't try and make requests to it 982 | if (!config.UseFrontend) 983 | return; 984 | try { 985 | webRequest.get(`${FRONTEND_WEBSERVER}/server/clientConnected?gameSessionId=${gameSessionId}&appName=${querystring.escape(clientConfig.AppName)}`, 986 | function (response, body) { 987 | if (response.statusCode === 200) { 988 | console.log('clientConnected acknowledged by Frontend'); 989 | } else { 990 | console.error('Status code: ' + response.statusCode); 991 | console.error(body); 992 | } 993 | }, 994 | function (err) { 995 | //Repeatedly try in cases where the connection timed out or never connected 996 | if (err.code === "ECONNRESET") { 997 | //timeout 998 | sendPlayerConnectedToFrontend(); 999 | } else if (err.code === 'ECONNREFUSED') { 1000 | console.error('Frontend server not running, unable to setup game session'); 1001 | } else { 1002 | console.error(err); 1003 | } 1004 | }); 1005 | } catch(err) { 1006 | console.logColor(logging.Red, `ERROR::: sendPlayerConnectedToFrontend error: ${err.message}`); 1007 | } 1008 | } 1009 | 1010 | function sendPlayerDisconnectedToFrontend() { 1011 | //If we are not using the frontend web server don't try and make requests to it 1012 | if (!config.UseFrontend) 1013 | return; 1014 | try { 1015 | webRequest.get(`${FRONTEND_WEBSERVER}/server/clientDisconnected?gameSessionId=${gameSessionId}&appName=${querystring.escape(clientConfig.AppName)}`, 1016 | function (response, body) { 1017 | if (response.statusCode === 200) { 1018 | console.log('clientDisconnected acknowledged by Frontend'); 1019 | } 1020 | else { 1021 | console.error('Status code: ' + response.statusCode); 1022 | console.error(body); 1023 | } 1024 | }, 1025 | function (err) { 1026 | //Repeatedly try in cases where the connection timed out or never connected 1027 | if (err.code === "ECONNRESET") { 1028 | //timeout 1029 | sendPlayerDisconnectedToFrontend(); 1030 | } else if (err.code === 'ECONNREFUSED') { 1031 | console.error('Frontend server not running, unable to setup game session'); 1032 | } else { 1033 | console.error(err); 1034 | } 1035 | }); 1036 | } catch(err) { 1037 | console.logColor(logging.Red, `ERROR::: sendPlayerDisconnectedToFrontend error: ${err.message}`); 1038 | } 1039 | } 1040 | 1041 | function sendStreamerConnectedToMatchmaker() { 1042 | if (!config.UseMatchmaker) 1043 | return; 1044 | try { 1045 | message = { 1046 | type: 'streamerConnected' 1047 | }; 1048 | matchmaker.write(JSON.stringify(message)); 1049 | } catch (err) { 1050 | console.logColor(logging.Red, `ERROR sending streamerConnected: ${err.message}`); 1051 | } 1052 | } 1053 | 1054 | function sendStreamerDisconnectedToMatchmaker() { 1055 | if (!config.UseMatchmaker) 1056 | return; 1057 | try { 1058 | message = { 1059 | type: 'streamerDisconnected' 1060 | }; 1061 | matchmaker.write(JSON.stringify(message)); 1062 | } catch (err) { 1063 | console.logColor(logging.Red, `ERROR sending streamerDisconnected: ${err.message}`); 1064 | } 1065 | } 1066 | 1067 | // The Matchmaker will not re-direct clients to this Cirrus server if any client 1068 | // is connected. 1069 | function sendPlayerConnectedToMatchmaker() { 1070 | if (!config.UseMatchmaker) 1071 | return; 1072 | try { 1073 | message = { 1074 | type: 'clientConnected' 1075 | }; 1076 | matchmaker.write(JSON.stringify(message)); 1077 | } catch (err) { 1078 | console.logColor(logging.Red, `ERROR sending clientConnected: ${err.message}`); 1079 | } 1080 | } 1081 | 1082 | // The Matchmaker is interested when nobody is connected to a Cirrus server 1083 | // because then it can re-direct clients to this re-cycled Cirrus server. 1084 | function sendPlayerDisconnectedToMatchmaker() { 1085 | if (!config.UseMatchmaker) 1086 | return; 1087 | try { 1088 | message = { 1089 | type: 'clientDisconnected' 1090 | }; 1091 | matchmaker.write(JSON.stringify(message)); 1092 | } catch (err) { 1093 | console.logColor(logging.Red, `ERROR sending clientDisconnected: ${err.message}`); 1094 | } 1095 | } 1096 | -------------------------------------------------------------------------------- /SignallingWebServer/startSS.sh: -------------------------------------------------------------------------------- 1 | cd /usr/customapps 2 | 3 | echo 'shutdown configured !' 4 | 5 | cd pixelstreaming 6 | 7 | git pull https://git-codecommit.ap-south-1.amazonaws.com/v1/repos/pixelstreaming 8 | 9 | TOKEN=`curl -X PUT "http://169.254.169.254/latest/api/token" -H "X-aws-ec2-metadata-token-ttl-seconds: 21600"` 10 | INSTANCE_ID=`curl -H "X-aws-ec2-metadata-token: $TOKEN" -v http://169.254.169.254/latest/meta-data/instance-id` 11 | 12 | cd /usr/customapps/pixelstreaming/SignallingWebServer/platform_scripts/bash 13 | chmod +x Start_WithTURN_SignallingServer.sh 14 | ./Start_WithTURN_SignallingServer.sh --UseMatchmaker=true --MatchmakerAddress=$1 --AWSInstanceID=$INSTANCE_ID -------------------------------------------------------------------------------- /SolutionDesign.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aws-samples/pixel-streaming-at-scale/13f1ef08da8ad5efc839b3ff8930a7552587a160/SolutionDesign.jpg -------------------------------------------------------------------------------- /infra/create.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | AWSTemplateFormatVersion: "2010-09-09" 3 | 4 | Description: "Creates all the components needed for hosting UE5 Pixel Streaming on AWS" 5 | Parameters: 6 | VPCName: 7 | Description: The name of the VPC being created. 8 | Type: String 9 | Default: "metaverse" 10 | VPCCIDR: 11 | Description: The CIDR of the VPC being created. 12 | Type: String 13 | Default: "10.0.0.0/16" 14 | Public0CIDR: 15 | Description: The CIDR of the first public subnet being created. 16 | Type: String 17 | Default: "10.0.0.0/24" 18 | Public1CIDR: 19 | Description: The CIDR of the second public subnet being created. 20 | Type: String 21 | Default: "10.0.1.0/24" 22 | Private0CIDR: 23 | Description: The CIDR of the first private subnet being created. 24 | Type: String 25 | Default: "10.0.2.0/24" 26 | Private1CIDR: 27 | Description: The CIDR of the second private subnet being created. 28 | Type: String 29 | Default: "10.0.3.0/24" 30 | MatchmakerAMI: 31 | Description: The AMI for Matchmaker server 32 | Type: String 33 | Default: "ami-0c284ed6bd6a72b4a" 34 | MatchmakerInstanceType: 35 | Description: The instance type for Matchmaker server 36 | Type: String 37 | Default: "t2.micro" 38 | FrontEndAMI: 39 | Description: The AMI for Frontend server 40 | Type: String 41 | Default: "ami-05422fc3670401f9a" 42 | SignallingServerAMI: 43 | Description: The AMI for Signalling server 44 | Type: String 45 | Default: "ami-014fefbaf7bdafab3" 46 | FrontEndInstanceType: 47 | Description: The instance type for Frontend server 48 | Type: String 49 | Default: "t2.micro" 50 | SignallingInstanceType: 51 | Description: The instance type for Signalling server 52 | Type: String 53 | Default: "t2.micro" 54 | 55 | Mappings: 56 | AZRegions: 57 | ap-south-1: 58 | AZs: ["a", "b"] 59 | us-east-1: 60 | AZs: ["a", "b"] 61 | 62 | Resources: 63 | 64 | VPC: 65 | Type: "AWS::EC2::VPC" 66 | Properties: 67 | EnableDnsSupport: "true" 68 | EnableDnsHostnames: "true" 69 | CidrBlock: !Ref 'VPCCIDR' 70 | Tags: 71 | - 72 | Key: "Application" 73 | Value: 74 | Ref: "AWS::StackName" 75 | - 76 | Key: "Network" 77 | Value: "Public" 78 | - 79 | Key: "Name" 80 | Value: !Ref 'VPCName' 81 | 82 | PublicSubnet0: 83 | Type: "AWS::EC2::Subnet" 84 | Properties: 85 | VpcId: 86 | Ref: "VPC" 87 | AvailabilityZone: 88 | Fn::Sub: 89 | - "${AWS::Region}${AZ}" 90 | - AZ: !Select [ 0, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 91 | CidrBlock: !Ref 'Public0CIDR' 92 | MapPublicIpOnLaunch: "true" 93 | Tags: 94 | - 95 | Key: "Application" 96 | Value: 97 | Ref: "AWS::StackName" 98 | - 99 | Key: "Network" 100 | Value: "Public" 101 | - 102 | Key: "Name" 103 | Value: !Join 104 | - '' 105 | - - !Ref "VPCName" 106 | - '-public-' 107 | - !Select [ 0, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 108 | 109 | PublicSubnet1: 110 | Type: "AWS::EC2::Subnet" 111 | Properties: 112 | VpcId: 113 | Ref: "VPC" 114 | AvailabilityZone: 115 | Fn::Sub: 116 | - "${AWS::Region}${AZ}" 117 | - AZ: !Select [ 1, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 118 | CidrBlock: !Ref 'Public1CIDR' 119 | MapPublicIpOnLaunch: "true" 120 | Tags: 121 | - 122 | Key: "Application" 123 | Value: 124 | Ref: "AWS::StackName" 125 | - 126 | Key: "Network" 127 | Value: "Public" 128 | - 129 | Key: "Name" 130 | Value: !Join 131 | - '' 132 | - - !Ref "VPCName" 133 | - '-public-' 134 | - !Select [ 1, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 135 | 136 | PrivateSubnet0: 137 | Type: "AWS::EC2::Subnet" 138 | Properties: 139 | VpcId: 140 | Ref: "VPC" 141 | AvailabilityZone: 142 | Fn::Sub: 143 | - "${AWS::Region}${AZ}" 144 | - AZ: !Select [ 0, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 145 | CidrBlock: !Ref 'Private0CIDR' 146 | Tags: 147 | - 148 | Key: "Application" 149 | Value: 150 | Ref: "AWS::StackName" 151 | - 152 | Key: "Network" 153 | Value: "Private" 154 | - 155 | Key: "Name" 156 | Value: !Join 157 | - '' 158 | - - !Ref "VPCName" 159 | - '-private-' 160 | - !Select [ 0, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 161 | 162 | PrivateSubnet1: 163 | Type: "AWS::EC2::Subnet" 164 | Properties: 165 | VpcId: 166 | Ref: "VPC" 167 | AvailabilityZone: 168 | Fn::Sub: 169 | - "${AWS::Region}${AZ}" 170 | - AZ: !Select [ 1, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 171 | CidrBlock: !Ref 'Private1CIDR' 172 | Tags: 173 | - 174 | Key: "Application" 175 | Value: 176 | Ref: "AWS::StackName" 177 | - 178 | Key: "Network" 179 | Value: "Private" 180 | - 181 | Key: "Name" 182 | Value: !Join 183 | - '' 184 | - - !Ref "VPCName" 185 | - '-private-' 186 | - !Select [ 1, !FindInMap [ "AZRegions", !Ref "AWS::Region", "AZs" ] ] 187 | 188 | InternetGateway: 189 | Type: "AWS::EC2::InternetGateway" 190 | Properties: 191 | Tags: 192 | - 193 | Key: "Application" 194 | Value: 195 | Ref: "AWS::StackName" 196 | - 197 | Key: "Network" 198 | Value: "Public" 199 | - 200 | Key: "Name" 201 | Value: !Join 202 | - '' 203 | - - !Ref "VPCName" 204 | - '-IGW' 205 | 206 | 207 | GatewayToInternet: 208 | Type: "AWS::EC2::VPCGatewayAttachment" 209 | Properties: 210 | VpcId: 211 | Ref: "VPC" 212 | InternetGatewayId: 213 | Ref: "InternetGateway" 214 | 215 | PublicRouteTable: 216 | Type: "AWS::EC2::RouteTable" 217 | Properties: 218 | VpcId: 219 | Ref: "VPC" 220 | Tags: 221 | - 222 | Key: "Application" 223 | Value: 224 | Ref: "AWS::StackName" 225 | - 226 | Key: "Network" 227 | Value: "Public" 228 | - 229 | Key: "Name" 230 | Value: !Join 231 | - '' 232 | - - !Ref "VPCName" 233 | - '-public-route-table' 234 | 235 | 236 | PublicRoute: 237 | Type: "AWS::EC2::Route" 238 | DependsOn: "GatewayToInternet" 239 | Properties: 240 | RouteTableId: 241 | Ref: "PublicRouteTable" 242 | DestinationCidrBlock: "0.0.0.0/0" 243 | GatewayId: 244 | Ref: "InternetGateway" 245 | 246 | PublicSubnetRouteTableAssociation0: 247 | Type: "AWS::EC2::SubnetRouteTableAssociation" 248 | Properties: 249 | SubnetId: 250 | Ref: "PublicSubnet0" 251 | RouteTableId: 252 | Ref: "PublicRouteTable" 253 | 254 | PublicSubnetRouteTableAssociation1: 255 | Type: "AWS::EC2::SubnetRouteTableAssociation" 256 | Properties: 257 | SubnetId: 258 | Ref: "PublicSubnet1" 259 | RouteTableId: 260 | Ref: "PublicRouteTable" 261 | 262 | PublicNetworkAcl: 263 | Type: "AWS::EC2::NetworkAcl" 264 | Properties: 265 | VpcId: 266 | Ref: "VPC" 267 | Tags: 268 | - 269 | Key: "Application" 270 | Value: 271 | Ref: "AWS::StackName" 272 | - 273 | Key: "Network" 274 | Value: "Public" 275 | - 276 | Key: "Name" 277 | Value: !Join 278 | - '' 279 | - - !Ref "VPCName" 280 | - '-public-nacl' 281 | 282 | 283 | 284 | InboundHTTPPublicNetworkAclEntry: 285 | Type: "AWS::EC2::NetworkAclEntry" 286 | Properties: 287 | NetworkAclId: 288 | Ref: "PublicNetworkAcl" 289 | RuleNumber: "100" 290 | Protocol: "-1" 291 | RuleAction: "allow" 292 | Egress: "false" 293 | CidrBlock: "0.0.0.0/0" 294 | PortRange: 295 | From: "0" 296 | To: "65535" 297 | 298 | OutboundPublicNetworkAclEntry: 299 | Type: "AWS::EC2::NetworkAclEntry" 300 | Properties: 301 | NetworkAclId: 302 | Ref: "PublicNetworkAcl" 303 | RuleNumber: "100" 304 | Protocol: "-1" 305 | RuleAction: "allow" 306 | Egress: "true" 307 | CidrBlock: "0.0.0.0/0" 308 | PortRange: 309 | From: "0" 310 | To: "65535" 311 | 312 | PublicSubnetNetworkAclAssociation0: 313 | Type: "AWS::EC2::SubnetNetworkAclAssociation" 314 | Properties: 315 | SubnetId: 316 | Ref: "PublicSubnet0" 317 | NetworkAclId: 318 | Ref: "PublicNetworkAcl" 319 | 320 | PublicSubnetNetworkAclAssociation1: 321 | Type: "AWS::EC2::SubnetNetworkAclAssociation" 322 | Properties: 323 | SubnetId: 324 | Ref: "PublicSubnet1" 325 | NetworkAclId: 326 | Ref: "PublicNetworkAcl" 327 | 328 | ElasticIP0: 329 | Type: "AWS::EC2::EIP" 330 | Properties: 331 | Domain: "vpc" 332 | 333 | NATGateway0: 334 | Type: "AWS::EC2::NatGateway" 335 | Properties: 336 | AllocationId: 337 | Fn::GetAtt: 338 | - "ElasticIP0" 339 | - "AllocationId" 340 | SubnetId: 341 | Ref: "PublicSubnet0" 342 | 343 | 344 | 345 | PrivateRouteTable0: 346 | Type: "AWS::EC2::RouteTable" 347 | Properties: 348 | VpcId: 349 | Ref: "VPC" 350 | Tags: 351 | - 352 | Key: "Name" 353 | Value: !Join 354 | - '' 355 | - - !Ref "VPCName" 356 | - '-private-route-table-0' 357 | 358 | PrivateRouteTable1: 359 | Type: "AWS::EC2::RouteTable" 360 | Properties: 361 | VpcId: 362 | Ref: "VPC" 363 | Tags: 364 | - 365 | Key: "Name" 366 | Value: !Join 367 | - '' 368 | - - !Ref "VPCName" 369 | - '-private-route-table-1' 370 | 371 | PrivateRouteToInternet0: 372 | Type: "AWS::EC2::Route" 373 | Properties: 374 | RouteTableId: 375 | Ref: "PrivateRouteTable0" 376 | DestinationCidrBlock: "0.0.0.0/0" 377 | NatGatewayId: 378 | Ref: "NATGateway0" 379 | 380 | PrivateRouteToInternet1: 381 | Type: "AWS::EC2::Route" 382 | Properties: 383 | RouteTableId: 384 | Ref: "PrivateRouteTable1" 385 | DestinationCidrBlock: "0.0.0.0/0" 386 | NatGatewayId: 387 | Ref: "NATGateway0" 388 | 389 | PrivateSubnetRouteTableAssociation0: 390 | Type: "AWS::EC2::SubnetRouteTableAssociation" 391 | Properties: 392 | SubnetId: 393 | Ref: "PrivateSubnet0" 394 | RouteTableId: 395 | Ref: "PrivateRouteTable0" 396 | 397 | PrivateSubnetRouteTableAssociation1: 398 | Type: "AWS::EC2::SubnetRouteTableAssociation" 399 | Properties: 400 | SubnetId: 401 | Ref: "PrivateSubnet1" 402 | RouteTableId: 403 | Ref: "PrivateRouteTable1" 404 | 405 | MatchmakerALBSecurityGroup: 406 | Type: AWS::EC2::SecurityGroup 407 | Properties: 408 | GroupDescription: "Security group for matchmaker ALB" 409 | GroupName: "MatchmakerALBSecurityGroup" 410 | SecurityGroupIngress: 411 | - CidrIp: "0.0.0.0/0" 412 | IpProtocol: "TCP" 413 | FromPort: 443 414 | ToPort: 443 415 | - CidrIp: "0.0.0.0/0" 416 | IpProtocol: "TCP" 417 | FromPort: 90 418 | ToPort: 90 419 | VpcId: 420 | Ref: "VPC" 421 | 422 | FrontEndALBSecurityGroup: 423 | Type: AWS::EC2::SecurityGroup 424 | Properties: 425 | GroupDescription: "Security group for Frontend ALB" 426 | GroupName: "FrontendALBSecurityGroup" 427 | SecurityGroupIngress: 428 | - CidrIp: "0.0.0.0/0" 429 | IpProtocol: "TCP" 430 | FromPort: 443 431 | ToPort: 443 432 | - CidrIp: "0.0.0.0/0" 433 | IpProtocol: "TCP" 434 | FromPort: 80 435 | ToPort: 80 436 | VpcId: 437 | Ref: "VPC" 438 | 439 | SignallingALBSecurityGroup: 440 | Type: AWS::EC2::SecurityGroup 441 | Properties: 442 | GroupDescription: "Security group for signalling ALB" 443 | GroupName: "SignallingALBSecurityGroup" 444 | SecurityGroupIngress: 445 | - CidrIp: "0.0.0.0/0" 446 | IpProtocol: "TCP" 447 | FromPort: 443 448 | ToPort: 443 449 | - CidrIp: "0.0.0.0/0" 450 | IpProtocol: "TCP" 451 | FromPort: 80 452 | ToPort: 80 453 | VpcId: 454 | Ref: "VPC" 455 | 456 | MatchmakerInstanceSecurityGroup: 457 | Type: AWS::EC2::SecurityGroup 458 | Properties: 459 | GroupDescription: "Security group for matchmaker instance" 460 | GroupName: "MatchmakerInstanceSecurityGroup" 461 | SecurityGroupIngress: 462 | - SourceSecurityGroupId: 463 | Ref: "MatchmakerALBSecurityGroup" 464 | IpProtocol: "TCP" 465 | FromPort: 443 466 | ToPort: 443 467 | - SourceSecurityGroupId: 468 | Ref: "MatchmakerALBSecurityGroup" 469 | IpProtocol: "TCP" 470 | FromPort: 90 471 | ToPort: 90 472 | - SourceSecurityGroupId: 473 | Ref: "SignallingInstanceSecurityGroup" 474 | IpProtocol: "TCP" 475 | FromPort: 9999 476 | ToPort: 9999 477 | VpcId: 478 | Ref: "VPC" 479 | 480 | FrontEndInstanceSecurityGroup: 481 | Type: AWS::EC2::SecurityGroup 482 | Properties: 483 | GroupDescription: "Security group for Frontend instance" 484 | GroupName: "FrontendInstanceSecurityGroup" 485 | SecurityGroupIngress: 486 | - SourceSecurityGroupId: 487 | Ref: "FrontEndALBSecurityGroup" 488 | IpProtocol: "TCP" 489 | FromPort: 443 490 | ToPort: 443 491 | - SourceSecurityGroupId: 492 | Ref: "FrontEndALBSecurityGroup" 493 | IpProtocol: "TCP" 494 | FromPort: 8080 495 | ToPort: 8080 496 | VpcId: 497 | Ref: "VPC" 498 | 499 | SignallingInstanceSecurityGroup: 500 | Type: AWS::EC2::SecurityGroup 501 | Properties: 502 | GroupDescription: "Security group for signalling instance" 503 | GroupName: "SignallingInstanceSecurityGroup" 504 | SecurityGroupIngress: 505 | - SourceSecurityGroupId: 506 | Ref: "SignallingALBSecurityGroup" 507 | IpProtocol: "TCP" 508 | FromPort: 443 509 | ToPort: 443 510 | - SourceSecurityGroupId: 511 | Ref: "SignallingALBSecurityGroup" 512 | IpProtocol: "TCP" 513 | FromPort: 80 514 | ToPort: 80 515 | VpcId: 516 | Ref: "VPC" 517 | 518 | MatchMakerServerALB: 519 | Type: AWS::ElasticLoadBalancingV2::LoadBalancer 520 | Properties: 521 | Name: "MatchMakerPublicALB" 522 | Scheme: "internet-facing" 523 | SecurityGroups: 524 | - Ref: "MatchmakerALBSecurityGroup" 525 | Subnets: 526 | - Ref: "PublicSubnet0" 527 | - Ref: "PublicSubnet1" 528 | Type: "application" 529 | 530 | MatchMakerTG: 531 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 532 | DependsOn: 533 | - MatchMakingInstance 534 | Properties: 535 | Name: "MatchMakerTargetGroup" 536 | Port: 90 537 | Protocol: "HTTP" 538 | TargetType: 'instance' 539 | Targets: 540 | - Id: !Ref 'MatchMakingInstance' 541 | VpcId: 542 | Ref: "VPC" 543 | 544 | MatchMakerALBListener: 545 | Type: AWS::ElasticLoadBalancingV2::Listener 546 | Properties: 547 | DefaultActions: 548 | - TargetGroupArn: 549 | Ref: "MatchMakerTG" 550 | Type: "forward" 551 | LoadBalancerArn: 552 | Ref: "MatchMakerServerALB" 553 | Port: 90 554 | Protocol: "HTTP" 555 | 556 | FrontEndServerALB: 557 | Type: AWS::ElasticLoadBalancingV2::LoadBalancer 558 | Properties: 559 | Name: "frontend" 560 | Scheme: "internet-facing" 561 | SecurityGroups: 562 | - Ref: "FrontEndALBSecurityGroup" 563 | Subnets: 564 | - Ref: "PublicSubnet0" 565 | - Ref: "PublicSubnet1" 566 | Type: "application" 567 | 568 | FrontEndTG: 569 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 570 | DependsOn: 571 | - FrontEndInstance 572 | Properties: 573 | Name: "FrontEndTargetGroup" 574 | Port: 8080 575 | Protocol: "HTTP" 576 | TargetType: 'instance' 577 | Targets: 578 | - Id: !Ref 'FrontEndInstance' 579 | VpcId: 580 | Ref: "VPC" 581 | 582 | FrontEndALBListener: 583 | Type: AWS::ElasticLoadBalancingV2::Listener 584 | Properties: 585 | DefaultActions: 586 | - TargetGroupArn: 587 | Ref: "FrontEndTG" 588 | Type: "forward" 589 | LoadBalancerArn: 590 | Ref: "FrontEndServerALB" 591 | Port: 80 592 | Protocol: "HTTP" 593 | 594 | SignallingServerALB: 595 | Type: AWS::ElasticLoadBalancingV2::LoadBalancer 596 | Properties: 597 | Name: "signalling" 598 | Scheme: "internet-facing" 599 | SecurityGroups: 600 | - Ref: "SignallingALBSecurityGroup" 601 | Subnets: 602 | - Ref: "PrivateSubnet0" 603 | - Ref: "PrivateSubnet1" 604 | Type: "application" 605 | 606 | SignallingTG: 607 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 608 | Properties: 609 | Name: "SignallingTargetGroup01" 610 | Port: 80 611 | Protocol: "HTTP" 612 | VpcId: 613 | Ref: "VPC" 614 | 615 | SignallingTG2: 616 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 617 | Properties: 618 | Name: "SignallingTargetGroup02" 619 | Port: 80 620 | Protocol: "HTTP" 621 | VpcId: 622 | Ref: "VPC" 623 | 624 | SignallingT3: 625 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 626 | Properties: 627 | Name: "SignallingTargetGroup03" 628 | Port: 80 629 | Protocol: "HTTP" 630 | VpcId: 631 | Ref: "VPC" 632 | 633 | SignallingTG4: 634 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 635 | Properties: 636 | Name: "SignallingTargetGroup04" 637 | Port: 80 638 | Protocol: "HTTP" 639 | VpcId: 640 | Ref: "VPC" 641 | 642 | SignallingTG5: 643 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 644 | Properties: 645 | Name: "SignallingTargetGroup05" 646 | Port: 80 647 | Protocol: "HTTP" 648 | VpcId: 649 | Ref: "VPC" 650 | 651 | SignallingT6: 652 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 653 | Properties: 654 | Name: "SignallingTargetGroup06" 655 | Port: 80 656 | Protocol: "HTTP" 657 | VpcId: 658 | Ref: "VPC" 659 | 660 | SignallingTG7: 661 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 662 | Properties: 663 | Name: "SignallingTargetGroup07" 664 | Port: 80 665 | Protocol: "HTTP" 666 | VpcId: 667 | Ref: "VPC" 668 | 669 | SignallingT8: 670 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 671 | Properties: 672 | Name: "SignallingTargetGroup08" 673 | Port: 80 674 | Protocol: "HTTP" 675 | VpcId: 676 | Ref: "VPC" 677 | 678 | SignallingTG9: 679 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 680 | Properties: 681 | Name: "SignallingTargetGroup09" 682 | Port: 80 683 | Protocol: "HTTP" 684 | VpcId: 685 | Ref: "VPC" 686 | 687 | SignallingTG10: 688 | Type: AWS::ElasticLoadBalancingV2::TargetGroup 689 | Properties: 690 | Name: "SignallingTargetGroup10" 691 | Port: 80 692 | Protocol: "HTTP" 693 | VpcId: 694 | Ref: "VPC" 695 | 696 | 697 | SignallingALBListener: 698 | Type: AWS::ElasticLoadBalancingV2::Listener 699 | Properties: 700 | DefaultActions: 701 | - TargetGroupArn: 702 | Ref: "SignallingTG" 703 | Type: "forward" 704 | LoadBalancerArn: 705 | Ref: "SignallingServerALB" 706 | Port: 80 707 | Protocol: "HTTP" 708 | 709 | SignallingALBListenerRule: 710 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 711 | Properties: 712 | Actions: 713 | - TargetGroupArn: 714 | Ref: "SignallingTG2" 715 | Type: "forward" 716 | Conditions: 717 | - Field: "query-string" 718 | QueryStringConfig: 719 | Values: 720 | - Key: "session" 721 | Value: "02" 722 | ListenerArn: 723 | Ref: "SignallingALBListener" 724 | Priority: 1 725 | 726 | SignallingALBListenerRule03: 727 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 728 | Properties: 729 | Actions: 730 | - TargetGroupArn: 731 | Ref: "SignallingT3" 732 | Type: "forward" 733 | Conditions: 734 | - Field: "query-string" 735 | QueryStringConfig: 736 | Values: 737 | - Key: "session" 738 | Value: "03" 739 | ListenerArn: 740 | Ref: "SignallingALBListener" 741 | Priority: 2 742 | 743 | SignallingALBListenerRule04: 744 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 745 | Properties: 746 | Actions: 747 | - TargetGroupArn: 748 | Ref: "SignallingTG4" 749 | Type: "forward" 750 | Conditions: 751 | - Field: "query-string" 752 | QueryStringConfig: 753 | Values: 754 | - Key: "session" 755 | Value: "04" 756 | ListenerArn: 757 | Ref: "SignallingALBListener" 758 | Priority: 3 759 | 760 | SignallingALBListenerRule05: 761 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 762 | Properties: 763 | Actions: 764 | - TargetGroupArn: 765 | Ref: "SignallingTG5" 766 | Type: "forward" 767 | Conditions: 768 | - Field: "query-string" 769 | QueryStringConfig: 770 | Values: 771 | - Key: "session" 772 | Value: "05" 773 | ListenerArn: 774 | Ref: "SignallingALBListener" 775 | Priority: 4 776 | 777 | SignallingALBListenerRule06: 778 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 779 | Properties: 780 | Actions: 781 | - TargetGroupArn: 782 | Ref: "SignallingT6" 783 | Type: "forward" 784 | Conditions: 785 | - Field: "query-string" 786 | QueryStringConfig: 787 | Values: 788 | - Key: "session" 789 | Value: "06" 790 | ListenerArn: 791 | Ref: "SignallingALBListener" 792 | Priority: 5 793 | 794 | SignallingALBListenerRule07: 795 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 796 | Properties: 797 | Actions: 798 | - TargetGroupArn: 799 | Ref: "SignallingTG7" 800 | Type: "forward" 801 | Conditions: 802 | - Field: "query-string" 803 | QueryStringConfig: 804 | Values: 805 | - Key: "session" 806 | Value: "07" 807 | ListenerArn: 808 | Ref: "SignallingALBListener" 809 | Priority: 6 810 | 811 | SignallingALBListenerRule08: 812 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 813 | Properties: 814 | Actions: 815 | - TargetGroupArn: 816 | Ref: "SignallingT8" 817 | Type: "forward" 818 | Conditions: 819 | - Field: "query-string" 820 | QueryStringConfig: 821 | Values: 822 | - Key: "session" 823 | Value: "08" 824 | ListenerArn: 825 | Ref: "SignallingALBListener" 826 | Priority: 7 827 | 828 | SignallingALBListenerRule09: 829 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 830 | Properties: 831 | Actions: 832 | - TargetGroupArn: 833 | Ref: "SignallingTG9" 834 | Type: "forward" 835 | Conditions: 836 | - Field: "query-string" 837 | QueryStringConfig: 838 | Values: 839 | - Key: "session" 840 | Value: "09" 841 | ListenerArn: 842 | Ref: "SignallingALBListener" 843 | Priority: 8 844 | 845 | SignallingALBListenerRule10: 846 | Type: AWS::ElasticLoadBalancingV2::ListenerRule 847 | Properties: 848 | Actions: 849 | - TargetGroupArn: 850 | Ref: "SignallingTG10" 851 | Type: "forward" 852 | Conditions: 853 | - Field: "query-string" 854 | QueryStringConfig: 855 | Values: 856 | - Key: "session" 857 | Value: "10" 858 | ListenerArn: 859 | Ref: "SignallingALBListener" 860 | Priority: 9 861 | 862 | MatchmakerLaunchTemplate: 863 | Type: AWS::EC2::LaunchTemplate 864 | Properties: 865 | LaunchTemplateData: 866 | ImageId: !Ref "MatchmakerAMI" 867 | TagSpecifications: 868 | - ResourceType: 'instance' 869 | Tags: 870 | - Key: 'Name' 871 | Value: 'Matchmaker' 872 | - Key: 'type' 873 | Value: 'matchmaker' 874 | InstanceType: !Ref "MatchmakerInstanceType" 875 | SecurityGroupIds: 876 | - !GetAtt MatchmakerInstanceSecurityGroup.GroupId 877 | LaunchTemplateName: "MatchmakerLaunchTemplate" 878 | 879 | FrontEndLaunchTemplate: 880 | Type: AWS::EC2::LaunchTemplate 881 | Properties: 882 | LaunchTemplateData: 883 | ImageId: !Ref "FrontEndAMI" 884 | TagSpecifications: 885 | - ResourceType: 'instance' 886 | Tags: 887 | - Key: 'Name' 888 | Value: 'Frontend' 889 | - Key: 'type' 890 | Value: 'frontend' 891 | InstanceType: !Ref "FrontEndInstanceType" 892 | SecurityGroupIds: 893 | - !GetAtt FrontEndInstanceSecurityGroup.GroupId 894 | LaunchTemplateName: "FrontEndLaunchTemplate" 895 | 896 | SignallingLaunchTemplate: 897 | Type: AWS::EC2::LaunchTemplate 898 | DependsOn: 899 | - Ec2InstanceProfiles 900 | Properties: 901 | LaunchTemplateData: 902 | InstanceType: !Ref "SignallingInstanceType" 903 | IamInstanceProfile: 904 | Arn: !GetAtt 905 | - Ec2InstanceProfiles 906 | - Arn 907 | TagSpecifications: 908 | - ResourceType: 'instance' 909 | Tags: 910 | - Key: 'Name' 911 | Value: 'Signalling' 912 | - Key: 'type' 913 | Value: 'signalling' 914 | SecurityGroupIds: 915 | - !GetAtt SignallingInstanceSecurityGroup.GroupId 916 | LaunchTemplateName: "SignallingLaunchTemplate" 917 | 918 | MatchMakingInstance: 919 | Type: AWS::EC2::Instance 920 | DependsOn: 921 | - Ec2InstanceProfiles 922 | Properties: 923 | LaunchTemplate: 924 | LaunchTemplateId: !Ref "MatchmakerLaunchTemplate" 925 | Version: "1" 926 | SubnetId: !Ref "PrivateSubnet0" 927 | IamInstanceProfile: !Ref "Ec2InstanceProfiles" 928 | 929 | FrontEndInstance: 930 | Type: AWS::EC2::Instance 931 | DependsOn: 932 | - Ec2InstanceProfiles 933 | Properties: 934 | LaunchTemplate: 935 | LaunchTemplateId: !Ref "FrontEndLaunchTemplate" 936 | Version: "1" 937 | SubnetId: !Ref "PrivateSubnet0" 938 | IamInstanceProfile: !Ref "Ec2InstanceProfiles" 939 | 940 | Ec2InstanceProfiles: 941 | Type: AWS::IAM::InstanceProfile 942 | DependsOn: 943 | - EC2IAMRole 944 | Properties: 945 | InstanceProfileName: 'instanceProfileForUE' 946 | Roles: 947 | - !Ref "EC2IAMRole" 948 | 949 | 950 | EC2IAMRole: 951 | Type: AWS::IAM::Role 952 | Properties: 953 | AssumeRolePolicyDocument: 954 | Version: "2012-10-17" 955 | Statement: 956 | - Effect: Allow 957 | Principal: 958 | Service: 959 | - ec2.amazonaws.com 960 | Action: 961 | - 'sts:AssumeRole' 962 | Description: "Role for EC2 servers" 963 | ManagedPolicyArns: 964 | - 'arn:aws:iam::aws:policy/AmazonSSMFullAccess' 965 | Policies: 966 | - PolicyName: accessDynamoDB 967 | PolicyDocument: 968 | Version: "2012-10-17" 969 | Statement: 970 | - Sid: VisualEditor0 971 | Effect: Allow 972 | Action: 973 | - "dynamodb:Scan" 974 | - "dynamodb:Query" 975 | - "dynamodb:GetRecords" 976 | Resource: 977 | - !Sub 'arn:aws:dynamodb:*:${AWS::AccountId}:table/*/index/*' 978 | - !Sub 'arn:aws:dynamodb:*:${AWS::AccountId}:table/*/stream/*' 979 | - Sid: VisualEditor1 980 | Effect: Allow 981 | Action: 982 | - "dynamodb:DescribeTable" 983 | - "dynamodb:GetItem" 984 | - "dynamodb:Scan" 985 | - "dynamodb:Query" 986 | Resource: !Sub 'arn:aws:dynamodb:*:${AWS::AccountId}:table/*' 987 | - PolicyName: accessParamaterStore 988 | PolicyDocument: 989 | Version: "2012-10-17" 990 | Statement: 991 | - Sid: VisualEditor0 992 | Effect: Allow 993 | Action: 994 | - "ssm:GetParameterHistory" 995 | - "ssm:GetParametersByPath" 996 | - "ssm:GetParameters" 997 | - "ssm:GetParameter" 998 | Resource: 999 | - 'arn:aws:s3:::*' 1000 | - !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*' 1001 | - Sid: VisualEditor1 1002 | Effect: Allow 1003 | Action: 1004 | - "ssm:DescribeParameters" 1005 | Resource: '*' 1006 | - PolicyName: pullFromCodeCommit 1007 | PolicyDocument: 1008 | Version: "2012-10-17" 1009 | Statement: 1010 | - Effect: Allow 1011 | Action: 1012 | - "codecommit:TagResource" 1013 | - "codecommit:GetTree" 1014 | - "codecommit:GetBlob" 1015 | - "codecommit:GetReferences" 1016 | - "codecommit:ListRepositories" 1017 | - "codecommit:GetPullRequestApprovalStates" 1018 | - "codecommit:DescribeMergeConflicts" 1019 | - "codecommit:BatchDescribeMergeConflicts" 1020 | - "codecommit:GetCommentsForComparedCommit" 1021 | - "codecommit:GetCommentReactions" 1022 | - "codecommit:GetCommit" 1023 | - "codecommit:GetComment" 1024 | - "codecommit:GetCommitHistory" 1025 | - "codecommit:GetCommitsFromMergeBase" 1026 | - "codecommit:GetApprovalRuleTemplate" 1027 | - "codecommit:BatchGetCommits" 1028 | - "codecommit:DescribePullRequestEvents" 1029 | - "codecommit:GetPullRequest" 1030 | - "codecommit:ListBranches" 1031 | - "codecommit:GetPullRequestOverrideState" 1032 | - "codecommit:GetRepositoryTriggers" 1033 | - "codecommit:GitPull" 1034 | - "codecommit:BatchGetRepositories" 1035 | - "codecommit:GetCommentsForPullRequest" 1036 | - "codecommit:UntagResource" 1037 | - "codecommit:GetObjectIdentifier" 1038 | - "codecommit:CancelUploadArchive" 1039 | - "codecommit:GetFolder" 1040 | - "codecommit:BatchGetPullRequests" 1041 | - "codecommit:GetFile" 1042 | - "codecommit:GetUploadArchiveStatus" 1043 | - "codecommit:EvaluatePullRequestApprovalRules" 1044 | - "codecommit:GetDifferences" 1045 | - "codecommit:GetRepository" 1046 | - "codecommit:GetBranch" 1047 | - "codecommit:GetMergeConflicts" 1048 | - "codecommit:GetMergeCommit" 1049 | - "codecommit:GetMergeOptions" 1050 | Resource: !Sub 'arn:aws:codecommit:*:${AWS::AccountId}:*' 1051 | - PolicyName: registerInstancesToTG 1052 | PolicyDocument: 1053 | Version: "2012-10-17" 1054 | Statement: 1055 | - Effect: Allow 1056 | Action: 1057 | - "elasticloadbalancing:RegisterTargets" 1058 | Resource: !Sub 'arn:aws:elasticloadbalancing:*:${AWS::AccountId}:targetgroup/*/*' 1059 | RoleName: "EC2Role" 1060 | 1061 | 1062 | LambdaIAMRole: 1063 | Type: AWS::IAM::Role 1064 | DependsOn: 1065 | - EC2IAMRole 1066 | Properties: 1067 | AssumeRolePolicyDocument: 1068 | Version: "2012-10-17" 1069 | Statement: 1070 | - Effect: Allow 1071 | Principal: 1072 | Service: 1073 | - lambda.amazonaws.com 1074 | Action: 1075 | - 'sts:AssumeRole' 1076 | Description: "Role for lambda functions" 1077 | Policies: 1078 | - PolicyName: invokeLambda 1079 | PolicyDocument: 1080 | Version: "2012-10-17" 1081 | Statement: 1082 | - Effect: Allow 1083 | Action: 1084 | - "lambda:InvokeFunctionUrl" 1085 | - "lambda:InvokeFunction" 1086 | - "lambda:GetFunction" 1087 | - "lambda:InvokeAsync" 1088 | Resource: !Sub 'arn:aws:lambda:*:${AWS::AccountId}:function:*' 1089 | - PolicyName: dynamoDBPrivileges 1090 | PolicyDocument: 1091 | Version: "2012-10-17" 1092 | Statement: 1093 | - Sid: VisualEditor0 1094 | Effect: Allow 1095 | Action: 1096 | - "dynamodb:Scan" 1097 | - "dynamodb:Query" 1098 | - "dynamodb:GetRecords" 1099 | Resource: 1100 | - !Sub 'arn:aws:dynamodb:*:${AWS::AccountId}:table/*/index/*' 1101 | - !Sub 'arn:aws:dynamodb:*:${AWS::AccountId}:table/*/stream/*' 1102 | - Sid: VisualEditor1 1103 | Effect: Allow 1104 | Action: 1105 | - "dynamodb:BatchGetItem" 1106 | - "dynamodb:ConditionCheckItem" 1107 | - "dynamodb:PutItem" 1108 | - "dynamodb:DescribeTable" 1109 | - "dynamodb:GetItem" 1110 | - "dynamodb:Scan" 1111 | - "dynamodb:Query" 1112 | - "dynamodb:UpdateItem" 1113 | Resource: !Sub 'arn:aws:dynamodb:*:${AWS::AccountId}:table/*' 1114 | - Sid: VisualEditor2 1115 | Effect: Allow 1116 | Action: 1117 | - "dynamodb:ListTables" 1118 | Resource: '*' 1119 | - PolicyName: terminateEC2Intance 1120 | PolicyDocument: 1121 | Version: "2012-10-17" 1122 | Statement: 1123 | - Effect: Allow 1124 | Action: 1125 | - "ec2:TerminateInstances" 1126 | Resource: !Sub 'arn:aws:ec2:*:${AWS::AccountId}:instance/*' 1127 | - PolicyName: registerInstancesToTG 1128 | PolicyDocument: 1129 | Version: "2012-10-17" 1130 | Statement: 1131 | - Effect: Allow 1132 | Action: 1133 | - "elasticloadbalancing:RegisterTargets" 1134 | Resource: !Sub 'arn:aws:elasticloadbalancing:*:${AWS::AccountId}:targetgroup/*/*' 1135 | - PolicyName: getSSMParamater 1136 | PolicyDocument: 1137 | Version: "2012-10-17" 1138 | Statement: 1139 | - Effect: Allow 1140 | Action: 1141 | - "ssm:GetParametersByPath" 1142 | - "ssm:GetParameters" 1143 | - "ssm:GetParameter" 1144 | Resource: 1145 | - 'arn:aws:s3:::*' 1146 | - !Sub 'arn:aws:ssm:*:${AWS::AccountId}:parameter/*' 1147 | - PolicyName: describeLoadbalancer 1148 | PolicyDocument: 1149 | Version: "2012-10-17" 1150 | Statement: 1151 | - Effect: Allow 1152 | Action: 1153 | - "elasticloadbalancing:DescribeLoadBalancerAttributes" 1154 | - "elasticloadbalancing:DescribeLoadBalancers" 1155 | - "elasticloadbalancing:DescribeListeners" 1156 | - "elasticloadbalancing:DescribeTargetGroups" 1157 | - "elasticloadbalancing:DescribeRules" 1158 | Resource: '*' 1159 | - PolicyName: root 1160 | PolicyDocument: 1161 | Version: "2012-10-17" 1162 | Statement: 1163 | - Sid: VisualEditor0 1164 | Effect: Allow 1165 | Action: 1166 | - "iam:PassRole" 1167 | Resource: !GetAtt EC2IAMRole.Arn 1168 | - Sid: VisualEditor1 1169 | Effect: Allow 1170 | Action: 1171 | - "ec2:DescribeInstances" 1172 | - "cloudwatch:PutMetricData" 1173 | - "ec2:DeleteTags" 1174 | - "ec2:CreateTags" 1175 | - "ec2:DescribeInstanceAttribute" 1176 | - "ec2:RunInstances" 1177 | - "ec2:StopInstances" 1178 | - "elasticloadbalancing:CreateLoadBalancerListeners" 1179 | - "elasticloadbalancing:DescribeLoadBalancerAttributes" 1180 | - "elasticloadbalancing:DeleteLoadBalancerPolicy" 1181 | - "elasticloadbalancing:DescribeLoadBalancers" 1182 | - "ec2:StartInstances" 1183 | - "elasticloadbalancing:DescribeLoadBalancerPolicies" 1184 | - "elasticloadbalancing:DeleteLoadBalancerListeners" 1185 | - "elasticloadbalancing:CreateLoadBalancerPolicy" 1186 | - "ec2:DescribeInstanceStatus" 1187 | Resource: '*' 1188 | - Sid: VisualEditor2 1189 | Effect: Allow 1190 | Action: 1191 | - "logs:DeleteLogGroup" 1192 | - "logs:CreateLogGroup" 1193 | - "logs:PutLogEvents" 1194 | - "elasticloadbalancing:CreateLoadBalancerListeners" 1195 | - "elasticloadbalancing:DeleteLoadBalancerPolicy" 1196 | - "elasticloadbalancing:DeleteLoadBalancerListeners" 1197 | - "elasticloadbalancing:CreateLoadBalancerPolicy" 1198 | - "sqs:DeleteMessage" 1199 | - "sqs:GetQueueUrl" 1200 | - "sqs:ChangeMessageVisibility" 1201 | - "sqs:SendMessage" 1202 | - "sqs:ReceiveMessage" 1203 | - "execute-api:*" 1204 | Resource: 1205 | - !Sub 'arn:aws:sqs:*:${AWS::AccountId}:*' 1206 | - !Sub 'arn:aws:execute-api:*:${AWS::AccountId}:*/*/*/*' 1207 | - !Sub 'arn:aws:elasticloadbalancing:*:${AWS::AccountId}:loadbalancer/*' 1208 | - !Sub 'arn:aws:logs:*:${AWS::AccountId}:log-group:*:log-stream:*' 1209 | - Sid: VisualEditor3 1210 | Effect: Allow 1211 | Action: 1212 | - "logs:CreateLogStream" 1213 | - "logs:DeleteLogStream" 1214 | Resource: !Sub 'arn:aws:logs:*:${AWS::AccountId}:log-group:*' 1215 | - Sid: VisualEditor4 1216 | Effect: Allow 1217 | Action: 1218 | - "elasticloadbalancing:DeleteLoadBalancerPolicy" 1219 | - "elasticloadbalancing:DeleteLoadBalancerListeners" 1220 | - "elasticloadbalancing:CreateLoadBalancerPolicy" 1221 | - "elasticloadbalancing:CreateLoadBalancerListeners" 1222 | Resource: !Sub 'arn:aws:elasticloadbalancing:*:${AWS::AccountId}:loadbalancer/*' 1223 | RoleName: "LambdaRole" 1224 | 1225 | RegisterInstanceFunction: 1226 | Type: AWS::Lambda::Function 1227 | Properties: 1228 | Role: !GetAtt LambdaIAMRole.Arn 1229 | Code: 1230 | ZipFile: | 1231 | import boto3 1232 | import json 1233 | import os 1234 | import json 1235 | def lambda_handler(event, context): 1236 | return { 1237 | 'statusCode': 200, 1238 | 'body': json.dumps('This is default implementation! Please replace this !') 1239 | } 1240 | Description: "Register Signalling instance to a target group" 1241 | Environment: 1242 | Variables: 1243 | DynamoDBName: !Ref "InstanceMappingTable" 1244 | FunctionName: "registerInstances" 1245 | Handler: "index.lambda_handler" 1246 | Runtime: "python3.10" 1247 | Timeout: 900 1248 | 1249 | CreateInstanceFunction: 1250 | Type: AWS::Lambda::Function 1251 | Properties: 1252 | Role: !GetAtt LambdaIAMRole.Arn 1253 | Code: 1254 | ZipFile: | 1255 | import boto3 1256 | import json 1257 | import os 1258 | import json 1259 | def lambda_handler(event, context): 1260 | return { 1261 | 'statusCode': 200, 1262 | 'body': json.dumps('This is default implementation! Please replace this !') 1263 | } 1264 | Description: "Create Signalling instance on a trigger" 1265 | Environment: 1266 | Variables: 1267 | DynamoDBName: !Ref "InstanceMappingTable" 1268 | ImageId: !Ref "SignallingServerAMI" 1269 | LaunchTemplateName: "SignallingLaunchTemplate" 1270 | SubnetIdPublicA: !GetAtt PublicSubnet0.SubnetId 1271 | SubnetIdPublicB: !GetAtt PublicSubnet1.SubnetId 1272 | FunctionName: "createInstances" 1273 | Handler: "index.lambda_handler" 1274 | Runtime: "python3.10" 1275 | Timeout: 900 1276 | 1277 | RequestSessionFunction: 1278 | Type: AWS::Lambda::Function 1279 | Properties: 1280 | Role: !GetAtt LambdaIAMRole.Arn 1281 | Code: 1282 | ZipFile: | 1283 | import boto3 1284 | import json 1285 | import os 1286 | import json 1287 | def lambda_handler(event, context): 1288 | return { 1289 | 'statusCode': 200, 1290 | 'body': json.dumps('This is default implementation! Please replace this !') 1291 | } 1292 | Description: "Request a pixel streaming session" 1293 | Environment: 1294 | Variables: 1295 | DynamoDBName: !Ref "InstanceMappingTable" 1296 | SQSName: !GetAtt SessionQueue.QueueName 1297 | clientSecret: "somethingsecret" 1298 | FunctionName: "requestSession" 1299 | Handler: "index.lambda_handler" 1300 | Runtime: "python3.10" 1301 | Timeout: 900 1302 | 1303 | # Function permissions grant an AWS service or another account permission to use a function 1304 | 1305 | RequestSessionFunctionPermission: 1306 | Type: 'AWS::Lambda::Permission' 1307 | Properties: 1308 | Action: 'lambda:InvokeFunction' 1309 | Principal: apigateway.amazonaws.com 1310 | FunctionName: !Ref RequestSessionFunction 1311 | SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RequestSessionAPI}/*' 1312 | 1313 | AuthorizeClientFunction: 1314 | Type: AWS::Lambda::Function 1315 | Properties: 1316 | Role: !GetAtt LambdaIAMRole.Arn 1317 | Code: 1318 | ZipFile: | 1319 | import json 1320 | 1321 | def lambda_handler(event, context): 1322 | # TODO implement 1323 | print(event) 1324 | #if(event["headers"]["bearer"] == "abcd"): 1325 | if("abcd" == event["queryStringParameters"]["tokenId"]): 1326 | return { 1327 | 'statusCode': 200, 1328 | 'body': json.dumps('Web socket connection valid !') 1329 | } 1330 | else: 1331 | return { 1332 | 'statusCode': 401, 1333 | 'body': json.dumps('Web socket connection ivalid !') 1334 | } 1335 | Description: "Authorize client" 1336 | FunctionName: "authorizeClient" 1337 | Handler: "index.lambda_handler" 1338 | Runtime: "python3.10" 1339 | Timeout: 900 1340 | 1341 | # Function permissions grant an AWS service or another account permission to use a function 1342 | AuthorizeClientPermission: 1343 | Type: 'AWS::Lambda::Permission' 1344 | Properties: 1345 | Action: 'lambda:InvokeFunction' 1346 | Principal: apigateway.amazonaws.com 1347 | FunctionName: !Ref AuthorizeClientFunction 1348 | SourceArn: !Sub 'arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${RequestSessionAPI}/*' 1349 | 1350 | SendSessionDetailsFunction: 1351 | Type: AWS::Lambda::Function 1352 | Properties: 1353 | Role: !GetAtt LambdaIAMRole.Arn 1354 | Code: 1355 | ZipFile: | 1356 | import boto3 1357 | import json 1358 | import os 1359 | import json 1360 | def lambda_handler(event, context): 1361 | return { 1362 | 'statusCode': 200, 1363 | 'body': json.dumps('This is default implementation! Please replace this !') 1364 | } 1365 | Description: "Send pixel streaming session details to client" 1366 | Environment: 1367 | Variables: 1368 | DynamoDBName: !Ref "InstanceMappingTable" 1369 | SQSName: !GetAtt SessionQueue.QueueName 1370 | ApiGatewayUrl: !Sub "https://${RequestSessionAPI}.execute-api.${AWS::Region}.amazonaws.com/production" 1371 | MatchMakerURL: !Join ['',['http://',!GetAtt MatchMakerServerALB.DNSName,':90/signallingserver']] 1372 | FunctionName: "sendSessionDetails" 1373 | Handler: "index.lambda_handler" 1374 | Runtime: "python3.10" 1375 | Timeout: 900 1376 | 1377 | KeepConnectionAliveFunction: 1378 | Type: AWS::Lambda::Function 1379 | Properties: 1380 | Role: !GetAtt LambdaIAMRole.Arn 1381 | Code: 1382 | ZipFile: | 1383 | import boto3 1384 | import json 1385 | import os 1386 | import json 1387 | def lambda_handler(event, context): 1388 | return { 1389 | 'statusCode': 200, 1390 | 'body': json.dumps('This is default implementation! Please replace this !') 1391 | } 1392 | Description: "Send keep alive messages to client" 1393 | Environment: 1394 | Variables: 1395 | ApiGatewayUrl: !Sub "https://${RequestSessionAPI}.execute-api.${AWS::Region}.amazonaws.com/production" 1396 | FunctionName: "keepConnectionAlive" 1397 | Handler: "index.lambda_handler" 1398 | Runtime: "python3.10" 1399 | Timeout: 900 1400 | 1401 | TerminateInstanceFunction: 1402 | Type: AWS::Lambda::Function 1403 | Properties: 1404 | Role: !GetAtt LambdaIAMRole.Arn 1405 | Code: 1406 | ZipFile: | 1407 | import boto3 1408 | import json 1409 | import os 1410 | import json 1411 | def lambda_handler(event, context): 1412 | return { 1413 | 'statusCode': 200, 1414 | 'body': json.dumps('This is default implementation! Please replace this !') 1415 | } 1416 | Description: "Terminate all stopped Signalling server instances" 1417 | Environment: 1418 | Variables: 1419 | DynamoDBName: !Ref "InstanceMappingTable" 1420 | FunctionName: "terminateInstance" 1421 | Handler: "index.lambda_handler" 1422 | Runtime: "python3.10" 1423 | Timeout: 900 1424 | 1425 | PollerFunction: 1426 | Type: AWS::Lambda::Function 1427 | Properties: 1428 | Role: !GetAtt LambdaIAMRole.Arn 1429 | Code: 1430 | ZipFile: | 1431 | import boto3 1432 | import json 1433 | import os 1434 | import json 1435 | def lambda_handler(event, context): 1436 | return { 1437 | 'statusCode': 200, 1438 | 'body': json.dumps('This is default implementation! Please replace this !') 1439 | } 1440 | Description: "Polls for incoming session request and services them" 1441 | Environment: 1442 | Variables: 1443 | MatchMakerURL: !Join ['',['http://',!GetAtt MatchMakerServerALB.DNSName,':90/signallingserver']] 1444 | SQSName: !GetAtt SessionQueue.QueueName 1445 | FunctionName: "poller" 1446 | Handler: "index.lambda_handler" 1447 | Runtime: "python3.10" 1448 | Timeout: 900 1449 | 1450 | AuthorizeClientFunction: 1451 | Type: AWS::Lambda::Function 1452 | Properties: 1453 | Role: !GetAtt LambdaIAMRole.Arn 1454 | Code: 1455 | ZipFile: | 1456 | import boto3 1457 | import json 1458 | import os 1459 | import json 1460 | def lambda_handler(event, context): 1461 | return { 1462 | 'statusCode': 200, 1463 | 'body': json.dumps('This is default implementation! Please replace this !') 1464 | } 1465 | Description: "Authorize web socket connections from client" 1466 | FunctionName: "authorizeClient" 1467 | Handler: "index.lambda_handler" 1468 | Runtime: "python3.10" 1469 | Timeout: 900 1470 | 1471 | UploadToDDBFunction: 1472 | Type: AWS::Lambda::Function 1473 | Properties: 1474 | Role: !GetAtt LambdaIAMRole.Arn 1475 | Code: 1476 | ZipFile: | 1477 | import boto3 1478 | import json 1479 | import os 1480 | import json 1481 | def lambda_handler(event, context): 1482 | return { 1483 | 'statusCode': 200, 1484 | 'body': json.dumps('This is default implementation! Please replace this !') 1485 | } 1486 | Description: "Uploads signalling server alb query string mapping to DynamoDB" 1487 | Environment: 1488 | Variables: 1489 | DynamoDBName: !Ref "InstanceMappingTable" 1490 | ALBName: !GetAtt SignallingServerALB.LoadBalancerName 1491 | FunctionName: "uploadToDDB" 1492 | Handler: "index.lambda_handler" 1493 | Runtime: "python3.10" 1494 | Timeout: 30 1495 | 1496 | SessionQueue: 1497 | Type: AWS::SQS::Queue 1498 | Properties: 1499 | DeduplicationScope: "queue" 1500 | FifoQueue: true 1501 | FifoThroughputLimit: "perQueue" 1502 | QueueName: "sessions.fifo" 1503 | 1504 | ConcurencyParameter: 1505 | Type: AWS::SSM::Parameter 1506 | Properties: 1507 | Name: "concurrencyLimit" 1508 | Type: "String" 1509 | Value: "10" 1510 | Description: "Maximum number of Signalling instances running in parallel" 1511 | 1512 | MatchMakerServerSecret: 1513 | Type: AWS::SSM::Parameter 1514 | Properties: 1515 | Name: "matchmakerclientsecret" 1516 | Type: "String" 1517 | Value: "somesecretstring" 1518 | Description: "client secret for validation by Matchmaker" 1519 | 1520 | 1521 | InstanceMappingTable: 1522 | Type: AWS::DynamoDB::Table 1523 | Properties: 1524 | AttributeDefinitions: 1525 | - AttributeName: "TargetGroup" 1526 | AttributeType: "S" 1527 | KeySchema: 1528 | - AttributeName: "TargetGroup" 1529 | KeyType: "HASH" 1530 | ProvisionedThroughput: 1531 | ReadCapacityUnits: "5" 1532 | WriteCapacityUnits: "5" 1533 | TableName: "instanceMapping" 1534 | 1535 | 1536 | PollerTriggerRule: 1537 | Type: AWS::Events::Rule 1538 | Properties: 1539 | Name: "PollForIncomingRequest" 1540 | Description: "Poll for incoming request(Running every 5 minutes)" 1541 | ScheduleExpression: "cron(0/5 * * * ? *)" 1542 | State: "ENABLED" 1543 | Targets: 1544 | - 1545 | Arn: !GetAtt PollerFunction.Arn 1546 | Id: "TargetFunctionV1" 1547 | 1548 | PermissionForPollerTriggerEventToInvokeLambda: 1549 | Type: AWS::Lambda::Permission 1550 | Properties: 1551 | FunctionName: !Ref "PollerFunction" 1552 | Action: "lambda:InvokeFunction" 1553 | Principal: "events.amazonaws.com" 1554 | SourceArn: !GetAtt PollerTriggerRule.Arn 1555 | 1556 | ScheduledStartRule: 1557 | Type: AWS::Events::Rule 1558 | Properties: 1559 | Name: "ScheduledStartSignallingServer" 1560 | Description: "Start Signalling Servers at 10 AM everyday" 1561 | ScheduleExpression: "cron(0 10 * * ? *)" 1562 | State: "ENABLED" 1563 | Targets: 1564 | - 1565 | Arn: !GetAtt CreateInstanceFunction.Arn 1566 | Id: "TargetFunctionV1" 1567 | Input: '{"startAllServers":false}' 1568 | 1569 | PermissionForScheduleStartEventsToInvokeLambda: 1570 | Type: AWS::Lambda::Permission 1571 | Properties: 1572 | FunctionName: !Ref "CreateInstanceFunction" 1573 | Action: "lambda:InvokeFunction" 1574 | Principal: "events.amazonaws.com" 1575 | SourceArn: !GetAtt ScheduledStartRule.Arn 1576 | 1577 | RegisterInstanceRule: 1578 | Type: AWS::Events::Rule 1579 | Properties: 1580 | Description: "Register all running signalling servers" 1581 | EventPattern: 1582 | source: 1583 | - "aws.ec2" 1584 | detail-type: 1585 | - "EC2 Instance State-change Notification" 1586 | detail: 1587 | state: 1588 | - "running" 1589 | Name: "registerSignallingServer" 1590 | State: "ENABLED" 1591 | Targets: 1592 | - Id: "TargetFunctionV1" 1593 | Arn: !GetAtt RegisterInstanceFunction.Arn 1594 | 1595 | PermissionForRegisterInstanceEventsToInvokeLambda: 1596 | Type: AWS::Lambda::Permission 1597 | Properties: 1598 | FunctionName: !Ref "RegisterInstanceFunction" 1599 | Action: "lambda:InvokeFunction" 1600 | Principal: "events.amazonaws.com" 1601 | SourceArn: !GetAtt RegisterInstanceRule.Arn 1602 | 1603 | TerminateInstanceRule: 1604 | Type: AWS::Events::Rule 1605 | Properties: 1606 | Description: "Terminate all stopped signalling servers" 1607 | EventPattern: 1608 | source: 1609 | - "aws.ec2" 1610 | detail-type: 1611 | - "EC2 Instance State-change Notification" 1612 | detail: 1613 | state: 1614 | - "stopped" 1615 | Name: "terminateSignallingServer" 1616 | State: "ENABLED" 1617 | Targets: 1618 | - Id: "TargetFunctionV1" 1619 | Arn: !GetAtt TerminateInstanceFunction.Arn 1620 | 1621 | 1622 | PermissionForTerminateInstanceEventsToInvokeLambda: 1623 | Type: AWS::Lambda::Permission 1624 | Properties: 1625 | FunctionName: !Ref "TerminateInstanceFunction" 1626 | Action: "lambda:InvokeFunction" 1627 | Principal: "events.amazonaws.com" 1628 | SourceArn: !GetAtt TerminateInstanceRule.Arn 1629 | 1630 | ScheduledStopRule: 1631 | Type: AWS::Events::Rule 1632 | Properties: 1633 | Name: "ScheduledStopAllSignallingServer" 1634 | Description: "Stop Signalling Servers at 6:15 PM everyday" 1635 | ScheduleExpression: "cron(15 18 * * ? *)" 1636 | State: "ENABLED" 1637 | Targets: 1638 | - 1639 | Arn: !GetAtt TerminateInstanceFunction.Arn 1640 | Id: "TargetFunctionV1" 1641 | Input: '{"stopAllServers":true}' 1642 | 1643 | 1644 | PermissionForScheduleStopEventsToInvokeLambda: 1645 | Type: AWS::Lambda::Permission 1646 | Properties: 1647 | FunctionName: !Ref "TerminateInstanceFunction" 1648 | Action: "lambda:InvokeFunction" 1649 | Principal: "events.amazonaws.com" 1650 | SourceArn: !GetAtt ScheduledStopRule.Arn 1651 | 1652 | RequestSessionAPI: 1653 | Type: 'AWS::ApiGatewayV2::Api' 1654 | Properties: 1655 | Name: requestSessionTest 1656 | ProtocolType: WEBSOCKET 1657 | RouteSelectionExpression: $request.body.action 1658 | ApiKeySelectionExpression: $request.header.x-api-key 1659 | 1660 | OnConnectIntegration: 1661 | Type: AWS::ApiGatewayV2::Integration 1662 | Properties: 1663 | ApiId: !Ref RequestSessionAPI 1664 | Description: OnConnect Integration 1665 | IntegrationType: AWS_PROXY 1666 | IntegrationUri: 1667 | Fn::Sub: 1668 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthorizeClientFunction.Arn}/invocations 1669 | 1670 | OnConnectRoute: 1671 | Type: AWS::ApiGatewayV2::Route 1672 | Properties: 1673 | ApiId: !Ref RequestSessionAPI 1674 | RouteKey: $connect 1675 | AuthorizationType: NONE 1676 | OperationName: OnConnectRoute 1677 | RouteResponseSelectionExpression: $default 1678 | Target: !Join 1679 | - '/' 1680 | - - 'integrations' 1681 | - !Ref OnConnectIntegration 1682 | 1683 | ReqSessionIntegration: 1684 | Type: AWS::ApiGatewayV2::Integration 1685 | Properties: 1686 | ApiId: !Ref RequestSessionAPI 1687 | Description: reqSession Integration 1688 | IntegrationType: AWS_PROXY 1689 | IntegrationUri: 1690 | Fn::Sub: 1691 | arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${RequestSessionFunction.Arn}/invocations 1692 | 1693 | ReqSessionRouteDef: 1694 | Type: AWS::ApiGatewayV2::Route 1695 | Properties: 1696 | ApiId: !Ref RequestSessionAPI 1697 | RouteKey: reqSession 1698 | AuthorizationType: NONE 1699 | OperationName: reqSessionRoute 1700 | Target: !Join 1701 | - '/' 1702 | - - 'integrations' 1703 | - !Ref ReqSessionIntegration 1704 | 1705 | Deployment: 1706 | Type: AWS::ApiGatewayV2::Deployment 1707 | DependsOn: 1708 | - ReqSessionRouteDef 1709 | Properties: 1710 | ApiId: !Ref RequestSessionAPI 1711 | 1712 | Stage: 1713 | Type: AWS::ApiGatewayV2::Stage 1714 | Properties: 1715 | StageName: production 1716 | Description: Prod Stage 1717 | DeploymentId: !Ref Deployment 1718 | ApiId: !Ref RequestSessionAPI 1719 | 1720 | CloudFrontDistribution: 1721 | Type: 'AWS::CloudFront::Distribution' 1722 | DependsOn: 1723 | - FrontEndServerALB 1724 | #- LambdaEdgeFunction 1725 | Properties: 1726 | DistributionConfig: 1727 | Comment: 'Cloudfront Distribution pointing ALB Origin' 1728 | Origins: 1729 | - DomainName: !GetAtt FrontEndServerALB.DNSName 1730 | Id: 'alb' 1731 | CustomOriginConfig: 1732 | HTTPPort: '80' 1733 | HTTPSPort: '443' 1734 | OriginProtocolPolicy: 'http-only' 1735 | Enabled: true 1736 | HttpVersion: 'http2' 1737 | DefaultCacheBehavior: 1738 | CachePolicyId: '4135ea2d-6df8-44a3-9df3-4b5a84be39ad' 1739 | AllowedMethods: 1740 | - GET 1741 | - HEAD 1742 | - DELETE 1743 | - OPTIONS 1744 | - PATCH 1745 | - POST 1746 | - PUT 1747 | Compress: true 1748 | TargetOriginId: 'alb' 1749 | ViewerProtocolPolicy: 'https-only' 1750 | PriceClass: 'PriceClass_200' 1751 | IPV6Enabled: false 1752 | 1753 | cognitoUserPool: 1754 | Type: AWS::Cognito::UserPool 1755 | DependsOn: 1756 | - CloudFrontDistribution 1757 | Properties: 1758 | AccountRecoverySetting: 1759 | RecoveryMechanisms: 1760 | - Name: verified_email 1761 | Priority: 1 1762 | UsernameAttributes: 1763 | - email 1764 | AutoVerifiedAttributes: 1765 | - email 1766 | UserPoolName: 'ueauthenticationpool' 1767 | Schema: 1768 | - Name: email 1769 | AttributeDataType: String 1770 | Mutable: false 1771 | Required: true 1772 | 1773 | cognitoUserPoolclient: 1774 | Type: AWS::Cognito::UserPoolClient 1775 | Properties: 1776 | UserPoolId: !Ref cognitoUserPool 1777 | AllowedOAuthFlowsUserPoolClient: true 1778 | GenerateSecret: true 1779 | CallbackURLs: 1780 | - !Join ['',['https://',!GetAtt CloudFrontDistribution.DomainName]] 1781 | AllowedOAuthFlows: 1782 | - code 1783 | AllowedOAuthScopes: 1784 | - email 1785 | - openid 1786 | - aws.cognito.signin.user.admin 1787 | SupportedIdentityProviders: 1788 | - COGNITO 1789 | 1790 | cognitoUserPoolDomain: 1791 | Type: AWS::Cognito::UserPoolDomain 1792 | Properties: 1793 | Domain: 'mysampleappmvjd' 1794 | UserPoolId: !Ref cognitoUserPool 1795 | 1796 | 1797 | 1798 | 1799 | 1800 | Outputs: 1801 | 1802 | CognitoCallBackURL: 1803 | Description: "Callback URL for Cognito" 1804 | Value: !Join ['',['https://',!GetAtt CloudFrontDistribution.DomainName]] 1805 | 1806 | CognitoClientID: 1807 | Description: "Client ID for Cognito" 1808 | Value: !Ref 'cognitoUserPoolclient' 1809 | 1810 | CognitoDomainURL: 1811 | Description: "Domain URL for Cognito" 1812 | Value: !Sub "https://mysampleappmvjd.auth.${AWS::Region}.amazoncognito.com" 1813 | 1814 | SignallingServerWSAPI: 1815 | Description: "Web socket endpoint for signalling server" 1816 | Value: !Join ['',['wss://',!GetAtt SignallingServerALB.DNSName,'?']] 1817 | 1818 | APIGatewayWSAPI: 1819 | Description: "Web socket endpoint for api server" 1820 | Value: !Sub "wss://${RequestSessionAPI}.execute-api.${AWS::Region}.amazonaws.com/production?tokenId=abcd" 1821 | --------------------------------------------------------------------------------