├── .gitignore ├── .prettierignore ├── result.png ├── dist └── pagent.exe ├── .prettierrc.json ├── src ├── keyboard.ts └── index.ts ├── lib ├── keyboard.js └── index.js ├── .github └── workflows │ └── ssh-example-workflow.yml ├── tsconfig.json ├── package.json ├── LICENSE ├── action.yml └── readme.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | lib/ 3 | node_modules/ -------------------------------------------------------------------------------- /result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/github-action-ssh/master/result.png -------------------------------------------------------------------------------- /dist/pagent.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adrianbravo/github-action-ssh/master/dist/pagent.exe -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "none", 8 | "bracketSpacing": false, 9 | "arrowParens": "avoid", 10 | "parser": "typescript" 11 | } -------------------------------------------------------------------------------- /src/keyboard.ts: -------------------------------------------------------------------------------- 1 | export const keyboardFunction = password => ( 2 | name, 3 | instructions, 4 | instructionsLang, 5 | prompts, 6 | finish 7 | ) => { 8 | if ( 9 | prompts.length > 0 && 10 | prompts[0].prompt.toLowerCase().includes("password") 11 | ) { 12 | finish([password]); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /lib/keyboard.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | Object.defineProperty(exports, "__esModule", { value: true }); 3 | exports.keyboardFunction = password => (name, instructions, instructionsLang, prompts, finish) => { 4 | if (prompts.length > 0 && 5 | prompts[0].prompt.toLowerCase().includes("password")) { 6 | finish([password]); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /.github/workflows/ssh-example-workflow.yml: -------------------------------------------------------------------------------- 1 | name: Command via SSH 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - name: ls -a via OPEN SSH Private Key 11 | uses: garygrossgarten/github-action-ssh@release 12 | with: 13 | command: ls -a 14 | host: ${{ secrets.HOST }} 15 | username: garygrossgarten 16 | passphrase: ${{ secrets.PASSPHRASE }} 17 | privateKey: ${{ secrets.PRIVATE_KEY}} 18 | - name: this step should fail 19 | uses: garygrossgarten/github-action-ssh@release 20 | with: 21 | command: node fail.js 22 | host: ${{ secrets.HOST }} 23 | username: garygrossgarten 24 | passphrase: ${{ secrets.PASSPHRASE }} 25 | privateKey: ${{ secrets.PRIVATE_KEY}} 26 | 27 | env: 28 | CI: true 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 5 | "outDir": "./lib", /* Redirect output structure to the directory. */ 6 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 7 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 8 | }, 9 | "exclude": ["node_modules", "**/*.test.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@garygrossgarten/github-action-ssh", 3 | "version": "0.5.0", 4 | "description": "Run commands on a remote server via SSH.", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/garygrossgarten/github-action-ssh" 8 | }, 9 | "main": "lib/index.js", 10 | "scripts": { 11 | "build": "tsc", 12 | "format": "prettier --write **/*.ts", 13 | "format-check": "prettier --check **/*.ts", 14 | "pack": "ncc build" 15 | }, 16 | "bin": { 17 | "github-action-ssh": "dist/index.js" 18 | }, 19 | "preferGlobal": false, 20 | "keywords": [ 21 | "typescript", 22 | "github", 23 | "github-actions", 24 | "actions", 25 | "ssh" 26 | ], 27 | "author": "garygrossgarten", 28 | "license": "MIT", 29 | "dependencies": { 30 | "@actions/core": "^1.2.0", 31 | "node-ssh": "^8.0.0" 32 | }, 33 | "devDependencies": { 34 | "@types/node": "^12.7.12", 35 | "@zeit/ncc": "^0.20.5", 36 | "prettier": "^1.19.1", 37 | "typescript": "3.8.3" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 fivethree 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: Run SSH command 2 | author: garygrossgarten 3 | description: Github Action to run commands on a remote server using SSH 4 | inputs: 5 | command: 6 | description: "Command to execute on the remote server." 7 | required: true 8 | host: 9 | description: "Hostname or IP address of the server." 10 | required: false 11 | default: "localhost" 12 | username: 13 | description: "Username for authentication." 14 | required: false 15 | port: 16 | description: "Port number of the server." 17 | required: false 18 | default: "22" 19 | privateKey: 20 | description: "File Location or string that contains a private key for either key-based or hostbased user authentication (OpenSSH format)" 21 | required: false 22 | password: 23 | description: "Password for password-based user authentication." 24 | required: false 25 | passphrase: 26 | description: "For an encrypted private key, this is the passphrase used to decrypt it." 27 | required: false 28 | tryKeyboard: 29 | description: "Try keyboard-interactive user authentication if primary user authentication method fails." 30 | required: false 31 | runs: 32 | using: "node12" 33 | main: "dist/index.js" 34 | branding: 35 | color: "purple" 36 | icon: "lock" 37 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # GitHub Action SSH 2 | 3 | Simple GitHub Action to run a command on a remote server using SSH. This is working with the latest [GitHub Actions](https://github.com/features/actions). 4 | 5 | ## ✨ Example Usage 6 | 7 | **Example using OpenSSH encrypted private key** 8 | 9 | ```yml 10 | - name: ls -a via ssh 11 | uses: garygrossgarten/github-action-ssh@release 12 | with: 13 | command: ls -a 14 | host: ${{ secrets.HOST }} 15 | username: garygrossgarten 16 | passphrase: ${{ secrets.PASSPHRASE }} 17 | privateKey: ${{ secrets.PRIVATE_KEY}} 18 | ``` 19 | 20 | 🔐 Set your secrets here: `https://github.com/USERNAME/REPO/settings/secrets`. 21 | 22 | Check out [the workflow example](.github/workflows/ssh-example-workflow.yml) for a minimalistic yaml workflow in GitHub Actions. 23 | 24 | **Result** 25 | 26 | ![result of example ssh workflow](result.png) 27 | 28 | ## Options 29 | 30 | - **host** - _string_ - Hostname or IP address of the server. **Default:** `'localhost'` 31 | 32 | - **port** - _integer_ - Port number of the server. **Default:** `22` 33 | 34 | - **username** - _string_ - Username for authentication. **Default:** (none) 35 | 36 | - **password** - _string_ - Password for password-based user authentication. **Default:** (none) 37 | 38 | - **privateKey** - _mixed_ - _Buffer_ or _string_ that contains a private key for either key-based or hostbased user authentication (OpenSSH format). **Default:** (none) 39 | 40 | - **passphrase** - _string_ - For an encrypted private key, this is the passphrase used to decrypt it. **Default:** (none) 41 | 42 | - **tryKeyboard** - _boolean_ - Try keyboard-interactive user authentication if primary user authentication method fails. **Default:** `false` 43 | 44 | ## Development 45 | 46 | --- 47 | 48 | This thing is build using Typescript and 49 | [ssh2](https://github.com/mscdex/ssh2) (via [node-ssh](https://github.com/steelbrain/node-ssh)). 🚀 50 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as core from '@actions/core'; 2 | import node_ssh from 'node-ssh'; 3 | import {keyboardFunction} from './keyboard'; 4 | 5 | async function run() { 6 | const command: string = core.getInput('command'); 7 | const host: string = core.getInput('host') || 'localhost'; 8 | const username: string = core.getInput('username'); 9 | const port: number = +core.getInput('port') || 22; 10 | const privateKey: string = core.getInput('privateKey'); 11 | const password: string = core.getInput('password'); 12 | const passphrase: string = core.getInput('passphrase'); 13 | const tryKeyboard: boolean = !!core.getInput('tryKeyboard'); 14 | try { 15 | const ssh = await connect( 16 | host, 17 | username, 18 | port, 19 | privateKey, 20 | password, 21 | passphrase, 22 | tryKeyboard 23 | ); 24 | 25 | await executeCommand(ssh, command); 26 | 27 | ssh.dispose(); 28 | } catch (err) { 29 | core.setFailed(err); 30 | } 31 | 32 | } 33 | 34 | async function connect( 35 | host = 'localhost', 36 | username: string, 37 | port = 22, 38 | privateKey: string, 39 | password: string, 40 | passphrase: string, 41 | tryKeyboard: boolean 42 | ) { 43 | const ssh = new node_ssh(); 44 | console.log(`Establishing a SSH connection to ${host}.`); 45 | 46 | try { 47 | await ssh.connect({ 48 | host: host, 49 | port: port, 50 | username: username, 51 | password: password, 52 | passphrase: passphrase, 53 | privateKey: privateKey, 54 | tryKeyboard: tryKeyboard, 55 | onKeyboardInteractive: tryKeyboard ? keyboardFunction(password) : null 56 | }); 57 | console.log(`🤝 Connected to ${host}.`); 58 | } catch (err) { 59 | console.error(`⚠️ The GitHub Action couldn't connect to ${host}.`, err); 60 | core.setFailed(err.message); 61 | } 62 | 63 | return ssh; 64 | } 65 | 66 | async function executeCommand(ssh: node_ssh, command: string) { 67 | console.log(`Executing command: ${command}`); 68 | 69 | try { 70 | await ssh.exec(command, [], { 71 | stream: "both", 72 | onStdout(chunk) { 73 | console.log(chunk.toString("utf8")); 74 | }, 75 | onStderr(chunk) { 76 | console.log(chunk.toString("utf8")); 77 | } 78 | }); 79 | 80 | console.log("✅ SSH Action finished."); 81 | } catch (err) { 82 | console.error(`⚠️ An error happened executing command ${command}.`, err); 83 | core.setFailed(err.message); 84 | process.abort(); 85 | } 86 | } 87 | 88 | run(); 89 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 3 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 4 | return new (P || (P = Promise))(function (resolve, reject) { 5 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 6 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 7 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 8 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 9 | }); 10 | }; 11 | var __importStar = (this && this.__importStar) || function (mod) { 12 | if (mod && mod.__esModule) return mod; 13 | var result = {}; 14 | if (mod != null) for (var k in mod) if (Object.hasOwnProperty.call(mod, k)) result[k] = mod[k]; 15 | result["default"] = mod; 16 | return result; 17 | }; 18 | var __importDefault = (this && this.__importDefault) || function (mod) { 19 | return (mod && mod.__esModule) ? mod : { "default": mod }; 20 | }; 21 | Object.defineProperty(exports, "__esModule", { value: true }); 22 | const core = __importStar(require("@actions/core")); 23 | const node_ssh_1 = __importDefault(require("node-ssh")); 24 | const keyboard_1 = require("./keyboard"); 25 | function run() { 26 | return __awaiter(this, void 0, void 0, function* () { 27 | const command = core.getInput('command'); 28 | const host = core.getInput('host') || 'localhost'; 29 | const username = core.getInput('username'); 30 | const port = +core.getInput('port') || 22; 31 | const privateKey = core.getInput('privateKey'); 32 | const password = core.getInput('password'); 33 | const passphrase = core.getInput('passphrase'); 34 | const tryKeyboard = !!core.getInput('tryKeyboard'); 35 | try { 36 | const ssh = yield connect(host, username, port, privateKey, password, passphrase, tryKeyboard); 37 | yield executeCommand(ssh, command); 38 | ssh.dispose(); 39 | } 40 | catch (err) { 41 | core.setFailed(err); 42 | } 43 | }); 44 | } 45 | function connect(host = 'localhost', username, port = 22, privateKey, password, passphrase, tryKeyboard) { 46 | return __awaiter(this, void 0, void 0, function* () { 47 | const ssh = new node_ssh_1.default(); 48 | console.log(`Establishing a SSH connection to ${host}.`); 49 | try { 50 | yield ssh.connect({ 51 | host: host, 52 | port: port, 53 | username: username, 54 | password: password, 55 | passphrase: passphrase, 56 | privateKey: privateKey, 57 | tryKeyboard: tryKeyboard, 58 | onKeyboardInteractive: tryKeyboard ? keyboard_1.keyboardFunction(password) : null 59 | }); 60 | console.log(`🤝 Connected to ${host}.`); 61 | } 62 | catch (err) { 63 | console.error(`⚠️ The GitHub Action couldn't connect to ${host}.`, err); 64 | core.setFailed(err.message); 65 | } 66 | return ssh; 67 | }); 68 | } 69 | function executeCommand(ssh, command) { 70 | return __awaiter(this, void 0, void 0, function* () { 71 | console.log(`Executing command: ${command}`); 72 | try { 73 | yield ssh.exec(command, [], { 74 | stream: "both", 75 | onStdout(chunk) { 76 | console.log(chunk.toString("utf8")); 77 | }, 78 | onStderr(chunk) { 79 | console.log(chunk.toString("utf8")); 80 | } 81 | }); 82 | console.log("✅ SSH Action finished."); 83 | } 84 | catch (err) { 85 | console.error(`⚠️ An error happened executing command ${command}.`, err); 86 | core.setFailed(err.message); 87 | process.abort(); 88 | } 89 | }); 90 | } 91 | run(); 92 | --------------------------------------------------------------------------------