├── .dockerignore ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── dependabot_automerge.yml │ ├── e2e-tests.yml │ └── static-checks.yml ├── .gitignore ├── .node-version ├── .prettierignore ├── .prettierrc.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── docs ├── configuration.md └── tutorial.md ├── gui ├── .eslintrc.cjs ├── index.html ├── package.json ├── sass │ └── main.scss ├── src │ ├── api │ │ ├── actions.ts │ │ ├── client.ts │ │ └── reducer.ts │ ├── components │ │ ├── App.tsx │ │ ├── Forwarding.tsx │ │ ├── ForwardingCreate.tsx │ │ ├── ForwardingForm.tsx │ │ ├── Header.tsx │ │ ├── Host.tsx │ │ ├── HostCreate.tsx │ │ ├── HostForm.tsx │ │ └── SystemInfo.tsx │ ├── main.tsx │ ├── reducer.ts │ ├── store.ts │ └── types │ │ └── redux.ts ├── tsconfig.json ├── vite.config.ts └── yarn.lock ├── package.json ├── server ├── package.json ├── src │ ├── api │ │ ├── api-server.ts │ │ ├── api.ts │ │ ├── component.ts │ │ ├── constants.ts │ │ ├── gateway-server.ts │ │ ├── index.ts │ │ ├── simple-proxy-server.ts │ │ ├── socket-notify.ts │ │ ├── socket-server.ts │ │ ├── socket-setup.ts │ │ ├── static-server.ts │ │ └── utils.ts │ ├── autoconnect │ │ ├── actions.ts │ │ ├── component.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ ├── thunks │ │ │ ├── create-thunks.ts │ │ │ ├── index.ts │ │ │ └── retry-thunks.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── autoforward │ │ ├── actions.ts │ │ ├── component.ts │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ ├── thunks │ │ │ ├── create-thunks.ts │ │ │ ├── index.ts │ │ │ └── retry-thunks.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── cli.ts │ ├── config │ │ ├── actions.ts │ │ ├── component.ts │ │ ├── convert.ts │ │ ├── forwarding-spec.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ ├── schema.ts │ │ ├── serialize.ts │ │ └── types.ts │ ├── engine.ts │ ├── forward │ │ ├── actions.ts │ │ ├── component.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ ├── ssh.ts │ │ ├── thunks │ │ │ ├── connect-thunks.ts │ │ │ ├── create-thunks.ts │ │ │ └── index.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── host │ │ ├── actions.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ ├── ssh.ts │ │ ├── thunks │ │ │ ├── connect-thunks.ts │ │ │ ├── create-thunks.ts │ │ │ └── index.ts │ │ └── types.ts │ ├── log │ │ ├── index.ts │ │ └── middleware.ts │ ├── reducer.ts │ ├── store.ts │ ├── system │ │ ├── actions.ts │ │ ├── component.ts │ │ ├── index.ts │ │ ├── reducer.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── types │ │ └── redux.ts │ └── utils │ │ ├── disconnect-all-hosts.ts │ │ ├── error-with-code.ts │ │ ├── graceful-shutdown.ts │ │ ├── server-url.ts │ │ └── tmp.ts ├── tsconfig.json └── yarn.lock ├── test ├── .eslintrc.cjs ├── Makefile ├── docker │ ├── client │ │ ├── Dockerfile │ │ └── ssh │ │ │ ├── id_rsa │ │ │ ├── id_rsa.pub │ │ │ └── known_hosts │ └── server │ │ ├── Dockerfile │ │ └── ssh │ │ ├── authorized_keys │ │ ├── id_rsa │ │ └── id_rsa.pub ├── package.json ├── scenarios │ └── 01-basic │ │ ├── config.yml │ │ ├── docker-compose.yml │ │ └── test.ts ├── tsconfig.json └── yarn.lock ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | 3 | **/node_modules/ 4 | **/build/ 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | node: true, 4 | }, 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'prettier', 9 | ], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['@typescript-eslint', 'simple-import-sort'], 12 | root: true, 13 | rules: { 14 | '@typescript-eslint/consistent-type-imports': 'error', 15 | '@typescript-eslint/no-explicit-any': 'off', 16 | // disable parent imports 17 | 'no-restricted-imports': ['error', { patterns: ['[.][.]'] }], 18 | 'simple-import-sort/imports': 'error', 19 | }, 20 | } 21 | -------------------------------------------------------------------------------- /.github/workflows/dependabot_automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | 3 | on: pull_request 4 | 5 | permissions: 6 | contents: write 7 | pull-requests: write 8 | 9 | jobs: 10 | dependabot: 11 | runs-on: ubuntu-latest 12 | if: github.event.pull_request.user.login == 'dependabot[bot]' 13 | steps: 14 | - name: Dependabot metadata 15 | id: metadata 16 | uses: dependabot/fetch-metadata@d7267f607e9d3fb96fc2fbe83e0af444713e90b7 17 | with: 18 | github-token: '${{ secrets.GITHUB_TOKEN }}' 19 | - name: Enable auto-merge for Dependabot PRs 20 | run: gh pr merge --auto --squash "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /.github/workflows/e2e-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run end-to-end tests 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Run tests 11 | run: yarn test 12 | -------------------------------------------------------------------------------- /.github/workflows/static-checks.yml: -------------------------------------------------------------------------------- 1 | name: Run static checks 2 | 3 | on: push 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Install modules 11 | run: yarn 12 | - name: Build 13 | run: yarn build 14 | - name: Check types for test module 15 | run: | 16 | yarn --cwd test 17 | yarn --cwd test tsc --noEmit 18 | - name: Lint 19 | run: yarn lint 20 | - name: Check code style 21 | run: yarn prettier -c . 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | build/ 3 | dist/ -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 18.14.2 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | -------------------------------------------------------------------------------- /.prettierrc.yml: -------------------------------------------------------------------------------- 1 | semi: false 2 | singleQuote: true 3 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.5.0 4 | 5 | - Global: Upgrade dependencies 6 | - Global: Migrate to yarn 7 | - Global: Uprade to node 18 8 | 9 | ## 0.4.1 10 | 11 | - License: Add MIT license 12 | - Global: Upgrade dependencies 13 | - Test: Add linter 14 | - Gui: Rename SSHmon to SSHMon and set monospace font 15 | 16 | ## 0.4.0 17 | 18 | - Test: Add some tests 19 | - **BREAKING** Config: Store hosts and forwardings as arrays in config file 20 | - Log: Spawn a bunyan process to print logs 21 | - Engine: Set NODE_ENV to production by default 22 | 23 | ## 0.3.14 24 | 25 | - Initial release 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Hippolyte Pello 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SSHMon 2 | 3 | SSHMon is a program designed to manage and monitor ssh connections. 4 | It has been tested on Linux and OSX with SSH ≥ 6.7. 5 | 6 | ![gui-sample.png](https://s7.postimg.cc/elzqf92gp/gui-sample.png) 7 | 8 | ## How it works 9 | 10 | SSHMon builds on top of the SSH "Control Master" feature, that facilitates port forwarding setup. 11 | 12 | ## Disclaimer 13 | 14 | - SSHMon Features (e.g. SSH host definition and connection) are exposed through the GUI. As a consequence, extreme care should be taken to make sure it is only reachable by you. Use at your own risk. 15 | - SSHMon is still at an early stage of development, as a result some things might not work and we might introduce some breaking changes... Any feedback will be greatly appreciated! 16 | 17 | ## Features 18 | 19 | - Nice GUI 20 | - SSH port/socket forwarding management 21 | - Configuration with YAML file 22 | - Automatic start and retry of connection and port forwarding 23 | - HTTP forwarding 24 | 25 | ## Get started 26 | 27 | Download the [latest release](https://github.com/hpello/sshmon/releases/latest) of SSHMon for your system and unpack it. Run the `sshmon` binary: 28 | 29 | ```bash 30 | ./sshmon 31 | ``` 32 | 33 | Then you can access the web GUI at . 34 | 35 | Go and have a look at the [Tutorial](docs/tutorial.md) to set up your first host! 36 | 37 | ### Build from source 38 | 39 | ```bash 40 | yarn 41 | yarn build 42 | yarn start 43 | ``` 44 | 45 | ## Configure 46 | 47 | You can set up SSH connections through the GUI or with a configuration file. 48 | 49 | By default, SSHMon will create a config file located at `~/.sshmon/config.yml`. 50 | You also may specify your own configuration file on the command line. 51 | 52 | Head over to the [Configuration](docs/configuration.md) page for more details. 53 | 54 | ## Logging 55 | 56 | Logging is handled by the [bunyan](https://github.com/trentm/node-bunyan) library. A `bunyan` process is launched along with SSHMon and writes logs to stderr. 57 | 58 | By default, if stderr is a TTY, the logs are pretty-printed, else they are written in a JSON format. 59 | You may use the `BUNYAN_OPTS` environment variable to choose which args are passed to the `bunyan` process, e.g.: 60 | 61 | ```bash 62 | BUNYAN_OPTS='-l debug' ./sshmon 63 | ``` 64 | 65 | ## Test 66 | 67 | Docker and docker-compose are required. 68 | 69 | You may run the test suite with: 70 | 71 | ```bash 72 | yarn test 73 | ``` 74 | 75 | ## Troubleshooting 76 | 77 | - So far, only public/private key authentication is supported. 78 | - Before trying to connect to a host through SSHMon, make sure you can connect to it with SSH on the command line. 79 | 80 | ## Built with 81 | 82 | SSHMon was developped thanks to the following projects (this list is not exhaustive!): 83 | 84 | - [TypeScript](https://www.typescriptlang.org/) 85 | - [React](https://reactjs.org/) 86 | - [Redux](https://redux.js.org/) 87 | - [Bulma](https://bulma.io/) 88 | - [Bulmaswatch](https://jenil.github.io/bulmaswatch/) 89 | - [Socket.IO](https://socket.io/) 90 | - [Bunyan](https://github.com/trentm/node-bunyan) 91 | - [pkg](https://github.com/zeit/pkg) 92 | 93 | ## TODO 94 | 95 | - Allow to change GUI address 96 | - Offer multiple GUI themes 97 | - Allow custom global SSH config options 98 | - Use BatchMode for `ProxyJump` SSH hosts 99 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | You can set up SSH connections through the GUI or with a configuration file. 4 | 5 | ## Configuration file example 6 | 7 | ```yaml 8 | hosts: 9 | - host-1: 10 | label: My Favourite Host 11 | ssh: 12 | host: my.host.com 13 | config: 14 | ServerAliveInterval: 5 15 | ServerAliveCountMax: 3 16 | autostart: true 17 | autoretry: true 18 | forward: 19 | - forwarding-1: 20 | label: My HTTP server 21 | spec: L 1234 localhost:8080 22 | autostart: true 23 | autoretry: true 24 | - forwarding-2: R 8022 22 # forwarding shorthand syntax 25 | - sshmon: H 8377 # forwarding shorthand syntax 26 | 27 | - host-2: # host shorthand syntax 28 | 29 | config: 30 | autosave: true 31 | ``` 32 | 33 | ## Hosts 34 | 35 | SSHMon is not a replacement of your SSH config! 36 | 37 | Actually, it is recommended you set up first your host in your SSH config file (by default `~/.ssh/config`), before adding it to your SSHMon config. 38 | 39 | - Syntax 40 | 41 | ```yaml 42 | hosts: 43 | - host-id-1: 44 | # host config 45 | - host-id-2: 46 | # host config 47 | ... 48 | ``` 49 | 50 | - List of available options 51 | 52 | | Option | Type | Description | Required | Default | 53 | | ---------- | ------- | -------------------------------------------- | -------- | ------- | 54 | | label | string | Friendly name for the GUI | no | '' | 55 | | ssh.host | string | Host passed to SSH | no | host id | 56 | | ssh.config | object | Options passed to SSH as `-o key=value` | no | {} | 57 | | autostart | boolean | Try to connect to host at SSHMon startup | no | false | 58 | | autoretry | boolean | Try to reconnect to host on connection error | no | false | 59 | | forward | object | Forwardings for this host | no | {} | 60 | 61 | ## Forwardings 62 | 63 | SSHMon allows you to define port/socket forwarding on your SSH connections. Here are the possible forwardings: 64 | 65 | | Type | Bind | Target | 66 | | ------- | ---------------------------------------- | ---------------------------------------- | 67 | | Local | `[address:]port` or unix socket (local) | `[address:]port` or unix socket (remote) | 68 | | Remote | `[address:]port` or unix socket (remote) | `[address:]port` or unix socket (local) | 69 | | Dynamic | `[address:]port` (local) | | 70 | | HTTP | | `[address:]port` or unix socket (remote) | 71 | 72 | Please read the SSH documentation for local, remote and dynamic types. 73 | 74 | ### HTTP forwarding 75 | 76 | The HTTP forwarding type is specific to SSHMon. 77 | It establishes a local port forwarding to a unix socket managed by SSHMon, and allows access to the remote port/socket through the GUI. 78 | 79 | It was designed to allow easy access to a remote running SSHMon instance, but should also work for other HTTP services that can be mounted under an arbitrary HTTP path prefix. 80 | 81 | ### Default address values 82 | 83 | - For the `bind` parameter, if you do not specify an address, SSH has its own policy for the default interface it will bind to. 84 | - For the `target` parameter, SSH normally requires you to specify an interface. With SSHMon you may specify a single port value, the default interface being `localhost`. 85 | 86 | ### Forwarding config 87 | 88 | - Syntax 89 | 90 | ```yaml 91 | # inside host config: 92 | forward: 93 | - forwarding-1: 94 | # forwarding config 95 | - forwarding-2: 96 | # forwarding config 97 | ``` 98 | 99 | - List of available options for a forwarding 100 | 101 | | Option | Type | Description | Required | Default | 102 | | --------- | ------- | --------------------------------- | -------- | ------- | 103 | | label | string | Friendly name for the GUI | no | '' | 104 | | spec | string | Forwarding specifiation | yes | | 105 | | autostart | boolean | Try to forward at host connection | no | false | 106 | | autoretry | boolean | Retry to forward on error | no | false | 107 | 108 | - Spec 109 | 110 | Similarly to the SSH forwarding syntax, the spec syntax is: 111 | 112 | ```bash 113 | Letter [bind] [target] 114 | ``` 115 | 116 | Where the options are required following the given table: 117 | 118 | | Type | Letter | Bind | Target | 119 | | ------- | ------ | -------- | -------- | 120 | | Local | `L` | ✓ | ✓ | 121 | | Remote | `R` | ✓ | ✓ | 122 | | Dynamic | `D` | ✓ | | 123 | | HTTP | `H` | | ✓ | 124 | 125 | - Forwarding shorthand syntax 126 | 127 | You may replace the whole options object with the single spec string, e.g.: 128 | 129 | ```yaml 130 | forward: 131 | - forward-1: L localhost:1234 localhost:8080 132 | - forward-2: H 8377 133 | ``` 134 | 135 | ## Config 136 | 137 | - Syntax 138 | 139 | ```yaml 140 | config: 141 | autosave: true 142 | ``` 143 | 144 | - List of available options for config 145 | 146 | | Option | Type | Description | Required | Default | 147 | | -------- | ------- | ----------------------------------- | -------- | ------- | 148 | | autosave | boolean | Write to this file on config change | no | false | 149 | -------------------------------------------------------------------------------- /docs/tutorial.md: -------------------------------------------------------------------------------- 1 | # Tutorial 2 | 3 | This tutorial will guide you in the setup of your first host with SSHMon! 4 | 5 | ## Knowing your SSH connection 6 | 7 | Imagine you want to connect to an SSH server: 8 | 9 | - running on host `host.example.com` 10 | - listening on port `8022` 11 | - with user name `ubuntu` 12 | - using a private key file `/path/to/key` 13 | 14 | Then your SSH command line looks something like: 15 | 16 | ```bash 17 | ssh -i /path/to/key -p 8022 ubuntu@host.example.com 18 | ``` 19 | 20 | _Info:_ SSHMon does not support other authentication types than private key. 21 | 22 | ### The SSH config 23 | 24 | To make things easier, SSH allows you to define your own connections or _hosts_ in a config file, usually located at `~/.ssh/config`. All the available options are detailed in `man ssh_config`. 25 | 26 | In our case, imagine we want to name our connection `my-host`, you could create the following entry in your config file: 27 | 28 | ```bash 29 | Host my-host 30 | Hostname host.example.com 31 | Port 8022 32 | User ubuntu 33 | IdentityFile /path/to/key 34 | ``` 35 | 36 | And now you can connect with: 37 | 38 | ```bash 39 | ssh my-host 40 | 41 | # ^ this is equivalent to: 42 | # ssh -o Port=8022 -o User=ubuntu -o Identityfile=/path/to/key host.example.com 43 | ``` 44 | 45 | ## Add your host to SSHMon 46 | 47 | If you have declared a host in your SSH config like in the previous section, then your SSHMon setup is straightforward: in the SSHMon GUI, add a new Host with ID `my-host`. 48 | 49 | If you did not declare it in your SSH config: in the SSHMon GUI, add a new host with ID `my-host`, and set its SSH host to `host.example.com` and config to: 50 | 51 | - `Port`: `8022` 52 | - `User`: `ubuntu` 53 | - `IdentityFile`: `/path/to/key` 54 | 55 | _Tip:_ Your host ID is the default value for your SSH host. As a result, in the second case you may as well choose `host.example.com` as your host ID and omit the SSH host option. 56 | 57 | And you are all set! Now you can click on the Connect button to establish a connection to your remote host! 58 | 59 | ## Forward a port 60 | 61 | ### With SSH 62 | 63 | A very powerful feature of SSH is the availability to do port or UNIX socket forwarding. SSH offers 3 types of forwarding. Here is a summary of what you can read in `man ssh`: 64 | 65 | 1. Local (**L**) port forwarding `local_address:remote_address`: 66 | - Listens on `local_address` on local host 67 | - Forwards connections to `remote_address` on remote host 68 | - Use case: access a service running on remote server directly from your machine 69 | 1. Remote (**R**) port forwarding `remote_address:local_address`: 70 | - Listens on `remote_address` on remote host 71 | - Forwards connections to `local_address` on local host 72 | - Use case: deploy a service running on your machine to remote host 73 | 1. Dynamic (**D**) port forwarding `local_address`: 74 | - Listens on `local_address` on local host 75 | - Deploys a SOCKS proxy 76 | - Use case: use the remote server as a proxy for HTTP requests made on your machine 77 | 78 | #### Use case 79 | 80 | For example, imagine you have an HTTP server running on port 8080 on your remote server: 81 | 82 | ```bash 83 | # on remote server: 84 | cd /var/tmp 85 | echo hello > hi 86 | python3 -m http.server 8080 87 | # keep it running 88 | ``` 89 | 90 | Then you could establish a local port forwarding to your local address `localhost:1234`, using SSH: 91 | 92 | ```bash 93 | ssh -NT -L localhost:1234:localhost:8080 my-host 94 | # keep it running 95 | ``` 96 | 97 | And, from another shell on your machine, access the running server: 98 | 99 | ```bash 100 | curl http://localhost:1234/hi 101 | # => hello 102 | ``` 103 | 104 | _Tip:_ You can also set up forwardings with UNIX sockets! The previous example becomes: 105 | 106 | ```bash 107 | # StreamLocalBindUnlink=yes ensures the local socket is destroyed when SSH is killed 108 | ssh -NT -L /var/tmp/server.sock:localhost:8080 -o StreamLocalBindUnlink=yes my-host 109 | # keep it running 110 | ``` 111 | 112 | ```bash 113 | curl --unix-socket /var/tmp/server.sock http://server/hi 114 | # => hello 115 | ``` 116 | 117 | ### With SSHMon 118 | 119 | SSHMon facilitates the setup of your port forwardings, and saves them for later use. To set up a local forwarding like on our previous example, click on your new host on the GUI, then add a new forwarding: 120 | 121 | - Type: Local 122 | - Bind: `localhost:1234` (or just `1234`, see below) 123 | - Target: `localhost:8080` (or just `8080`, see below) 124 | 125 | And you are all set! 126 | 127 | _Tip:_ If you left the SSH config `GatewayPorts` option to its default value (`no`), SSH will use the loopback address as the bind address in your forwardings (see `man ssh_config` for more info). As a result, you may omit the host part in `localhost:1234`, and only set `1234`. 128 | 129 | _Tip:_ For local and remote forwarding, the target address is required by SSH. However, if you leave it empty in SSHMon, it defaults to `localhost`. 130 | -------------------------------------------------------------------------------- /gui/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../.eslintrc.cjs', 3 | env: { 4 | browser: true, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /gui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SSHMon 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /gui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshmon-gui", 3 | "version": "", 4 | "license": "MIT", 5 | "scripts": { 6 | "clean": "rm -rf dist", 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "build-watch": "vite build --watch" 11 | }, 12 | "dependencies": { 13 | "bulma": "^0.9.4", 14 | "react": "^18.2.0", 15 | "react-dom": "^18.2.0", 16 | "react-redux": "^8.0.5", 17 | "redux": "^4.2.1", 18 | "redux-logger": "^3.0.6", 19 | "socket.io-client": "^4.6.1" 20 | }, 21 | "devDependencies": { 22 | "@types/react": "^18.0.28", 23 | "@types/react-dom": "^18.0.11", 24 | "@types/react-redux": "^7.1.25", 25 | "@types/redux-logger": "^3.0.6", 26 | "@vitejs/plugin-react": "^4.0.0-beta.0", 27 | "bulmaswatch": "^0.8.1", 28 | "@fortawesome/fontawesome-free": "^6.4.0", 29 | "sass": "^1.62.0", 30 | "typescript": "^4.9.5", 31 | "vite": "^4.5.14", 32 | "vite-tsconfig-paths": "^4.2.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /gui/sass/main.scss: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | @import '../node_modules/@fortawesome/fontawesome-free/scss/fontawesome.scss'; 4 | 5 | $fa-font-path: '../node_modules/@fortawesome/fontawesome-free/webfonts'; 6 | @import '../node_modules/@fortawesome/fontawesome-free/scss/solid.scss'; 7 | @import '../node_modules/@fortawesome/fontawesome-free/scss/brands.scss'; 8 | 9 | @import '../node_modules/bulmaswatch/darkly/_variables'; 10 | 11 | // some fonts (e.g. Monaco) do not have the same size as the other monospace fonts 12 | $family-monospace: monospace; 13 | 14 | @import '../node_modules/bulma/bulma'; 15 | @import '../node_modules/bulmaswatch/darkly/_overrides'; 16 | 17 | // square icon buttons 18 | .button { 19 | height: auto; 20 | width: auto; 21 | } 22 | 23 | // darkly 24 | .panel-block { 25 | &:hover { 26 | color: inherit; 27 | } 28 | } 29 | .table { 30 | background-color: inherit; 31 | } 32 | 33 | // add border around tags to increase visibility (e.g. for darkly) 34 | .panel-block { 35 | .tag { 36 | box-shadow: 0 0 0 0.1em rgba(#eeeeee, 0.1) inset; 37 | } 38 | } 39 | 40 | // increase disabled button visibility (e.g. for darkly) 41 | .button[disabled] { 42 | background-color: transparent; 43 | border-color: transparent; 44 | box-shadow: none; 45 | } 46 | 47 | // recommended themes: default, litera, cyborg, flatly, superhero 48 | -------------------------------------------------------------------------------- /gui/src/api/actions.ts: -------------------------------------------------------------------------------- 1 | import type { State as APIState } from '@/server/types/redux' 2 | 3 | export enum types { 4 | API_STATE_CHANGE = 'API_STATE_CHANGE', 5 | API_STATUS_CHANGE = 'API_STATUS_CHANGE', 6 | } 7 | 8 | export type APIStatus = 'connected' | 'disconnected' 9 | 10 | export type Action = 11 | | { type: types.API_STATE_CHANGE; state: APIState } 12 | | { type: types.API_STATUS_CHANGE; status: APIStatus } 13 | 14 | export const actions = { 15 | apiStateChange: (state: APIState): Action => ({ 16 | type: types.API_STATE_CHANGE, 17 | state, 18 | }), 19 | apiStatusConnected: (): Action => ({ 20 | type: types.API_STATUS_CHANGE, 21 | status: 'connected', 22 | }), 23 | apiStatusDisconnected: (): Action => ({ 24 | type: types.API_STATUS_CHANGE, 25 | status: 'disconnected', 26 | }), 27 | } 28 | -------------------------------------------------------------------------------- /gui/src/api/client.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from 'socket.io-client' 2 | import { io } from 'socket.io-client' 3 | 4 | import type { Store } from '@/gui/types/redux' 5 | import type { APIEndpoint } from '@/server/api/api' 6 | import { apiKeys } from '@/server/api/api' 7 | import type { SocketMessageError } from '@/server/api/constants' 8 | import { SOCKET_PATH, socketTypes } from '@/server/api/constants' 9 | import type { AutoconnectConfig } from '@/server/autoconnect/types' 10 | import type { AutoforwardConfig } from '@/server/autoforward/types' 11 | import type { ForwardingConfig } from '@/server/forward/types' 12 | import type { HostConfig } from '@/server/host/types' 13 | import type { State as APIState } from '@/server/types/redux' 14 | 15 | import { actions } from './actions' 16 | 17 | const setup = (socket: Socket, store: Store) => { 18 | socket.on('connect', () => { 19 | console.log('connected to socket server') 20 | store.dispatch(actions.apiStatusConnected()) 21 | socket.emit(socketTypes.register) 22 | }) 23 | 24 | socket.on('disconnect', () => { 25 | console.log('disconnected from socket server') 26 | store.dispatch(actions.apiStatusDisconnected()) 27 | }) 28 | 29 | socket.on(socketTypes.state, (state: APIState) => { 30 | store.dispatch(actions.apiStateChange(state)) 31 | 32 | // FIXME hpello 33 | if (state.system.info) { 34 | document.title = `${state.system.info.hostName} | SSHMon` 35 | } 36 | }) 37 | } 38 | 39 | export class APIClient { 40 | store: Store 41 | socket: Socket 42 | 43 | constructor(store: Store) { 44 | this.store = store 45 | this.socket = io({ 46 | path: `${window.location.pathname}${SOCKET_PATH}`.replace(/\/{2,}/g, '/'), 47 | }) 48 | setup(this.socket, store) 49 | } 50 | 51 | makeAPICall(e: APIEndpoint) { 52 | this.socket.emit( 53 | socketTypes.apiCall, 54 | e, 55 | (err: SocketMessageError | null, result: any) => { 56 | if (err) { 57 | console.error(`API call failure: ${err.message}`, e) 58 | return 59 | } 60 | console.log('API call success:', e, { result }) 61 | } 62 | ) 63 | } 64 | 65 | hostCreate(args: { 66 | id: string 67 | config: HostConfig 68 | autoConfig: AutoconnectConfig 69 | }) { 70 | this.makeAPICall({ key: apiKeys.hostCreate, args }) 71 | } 72 | hostEdit(args: { 73 | id: string 74 | config: HostConfig 75 | autoConfig: AutoconnectConfig 76 | }) { 77 | this.makeAPICall({ key: apiKeys.hostEdit, args }) 78 | } 79 | hostDelete(args: { id: string }) { 80 | this.makeAPICall({ key: apiKeys.hostDelete, args }) 81 | } 82 | hostConnect(args: { id: string }) { 83 | this.makeAPICall({ key: apiKeys.hostConnect, args }) 84 | } 85 | hostDisconnect(args: { id: string }) { 86 | this.makeAPICall({ key: apiKeys.hostDisconnect, args }) 87 | } 88 | 89 | forwardingCreate(args: { 90 | id: string 91 | fwdId: string 92 | config: ForwardingConfig 93 | autoConfig: AutoforwardConfig 94 | }) { 95 | this.makeAPICall({ key: apiKeys.forwardingCreate, args }) 96 | } 97 | forwardingEdit(args: { 98 | id: string 99 | fwdId: string 100 | config: ForwardingConfig 101 | autoConfig: AutoforwardConfig 102 | }) { 103 | this.makeAPICall({ key: apiKeys.forwardingEdit, args }) 104 | } 105 | forwardingDelete(args: { id: string; fwdId: string }) { 106 | this.makeAPICall({ key: apiKeys.forwardingDelete, args }) 107 | } 108 | forwardingConnect(args: { id: string; fwdId: string }) { 109 | this.makeAPICall({ key: apiKeys.forwardingConnect, args }) 110 | } 111 | forwardingDisconnect(args: { id: string; fwdId: string }) { 112 | this.makeAPICall({ key: apiKeys.forwardingDisconnect, args }) 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /gui/src/api/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/gui/types/redux' 2 | import type { State as APIState } from '@/server/types/redux' 3 | 4 | import type { APIStatus } from './actions' 5 | import { types } from './actions' 6 | 7 | export type State = { 8 | state: APIState 9 | status: APIStatus 10 | } 11 | 12 | const initialState = (): State => ({ 13 | state: { 14 | hosts: [], 15 | forwardings: [], 16 | autoconnects: [], 17 | autoforwards: [], 18 | system: { 19 | info: null, 20 | stats: null, 21 | }, 22 | config: { 23 | autosave: false, 24 | }, 25 | }, 26 | status: 'disconnected', 27 | }) 28 | 29 | export const reducer = ( 30 | state: State = initialState(), 31 | action: Action 32 | ): State => { 33 | switch (action.type) { 34 | case types.API_STATE_CHANGE: 35 | return { ...state, state: action.state } 36 | case types.API_STATUS_CHANGE: 37 | return { ...state, status: action.status } 38 | default: 39 | return state 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /gui/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import type { APIClient } from '@/gui/api/client' 5 | import type { State as APIState } from '@/gui/api/reducer' 6 | import type { State } from '@/gui/types/redux' 7 | 8 | import Header from './Header' 9 | import Host from './Host' 10 | import HostCreate from './HostCreate' 11 | import SystemInfo from './SystemInfo' 12 | 13 | interface OwnProps { 14 | apiClient: APIClient 15 | } 16 | 17 | interface StateProps { 18 | apiState: APIState 19 | } 20 | 21 | interface Props extends StateProps, OwnProps {} 22 | 23 | const APIDisconnectedModal = (props: { active: boolean }) => ( 24 |
25 |
26 |
27 |
28 |
29 |

API unreachable

30 |
31 |
32 |

The SSHMon API appears to be down

33 |

Please wait for reconnection…

34 |
35 |
36 |
37 |
38 | ) 39 | 40 | class App extends React.Component { 41 | render() { 42 | const { info } = this.props.apiState.state.system 43 | return ( 44 |
45 |
49 | {info.user} @ {info.hostName} 50 | 51 | ) 52 | } 53 | /> 54 | 55 |
56 |
57 |
58 |
59 |

Hosts

60 | 61 | {this.props.apiState.state.hosts.map(({ id }) => { 62 | return ( 63 | 64 | ) 65 | })} 66 | 67 | 68 |
69 | 70 |
71 |

Info

72 | 73 |
74 |
75 |
76 |
77 | 78 | 81 |
82 | ) 83 | } 84 | } 85 | 86 | const mapStateToProps = (state: State, ownProps: OwnProps): Props => ({ 87 | apiClient: ownProps.apiClient, 88 | apiState: state.api, 89 | }) 90 | 91 | export default connect(mapStateToProps)(App) 92 | -------------------------------------------------------------------------------- /gui/src/components/Forwarding.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import type { APIClient } from '@/gui/api/client' 5 | import type { State } from '@/gui/types/redux' 6 | import { PROXY_PATH_PREFIX } from '@/server/api/constants' 7 | import type { AutoforwardState } from '@/server/autoforward/reducer' 8 | import type { ForwardingStatus } from '@/server/forward/actions' 9 | import type { 10 | ForwardingState, 11 | ForwardingSubState, 12 | } from '@/server/forward/reducer' 13 | import type { ForwardingConfig, ForwardingSpec } from '@/server/forward/types' 14 | import { fwdTypes } from '@/server/forward/types' 15 | 16 | import ForwardingForm from './ForwardingForm' 17 | 18 | interface OwnProps { 19 | id: string 20 | fwdId: string 21 | apiClient: APIClient 22 | } 23 | 24 | interface StateProps { 25 | state: ForwardingState | null 26 | autoforward: AutoforwardState | null 27 | hostIsUp: boolean 28 | } 29 | 30 | interface Props extends StateProps, OwnProps {} 31 | 32 | interface ComponentState { 33 | editIsActive: boolean 34 | } 35 | 36 | const makeColorClass = (status: ForwardingStatus): string => { 37 | switch (status) { 38 | case 'connecting': 39 | return 'has-text-info' 40 | case 'connected': 41 | return 'has-text-success' 42 | case 'disconnecting': 43 | return 'has-text-warning' 44 | case 'disconnected': 45 | return 'has-text-grey' 46 | case 'error': 47 | return 'has-text-danger' 48 | default: 49 | return '' 50 | } 51 | } 52 | 53 | const makeStatusText = (status: ForwardingStatus) => { 54 | switch (status) { 55 | case 'connecting': 56 | return 'activating' 57 | case 'connected': 58 | return 'active' 59 | case 'disconnecting': 60 | return 'canceling' 61 | case 'disconnected': 62 | return 'inactive' 63 | case 'error': 64 | return 'error' 65 | default: 66 | return '' 67 | } 68 | } 69 | 70 | const makeStatus = (state: ForwardingSubState) => { 71 | const colorClass = makeColorClass(state.status) 72 | const text = makeStatusText(state.status) 73 | 74 | return ( 75 |
76 |
77 | forwarding 78 | {/* force width of maximum contents */} 79 | 80 | {text} 81 | 82 |
83 |
84 | 85 | 86 | 87 | 88 | 89 |
90 |
91 | ) 92 | } 93 | 94 | const makeForwardingHref = (spec: ForwardingSpec): string => { 95 | switch (spec.type) { 96 | case fwdTypes.local: 97 | case fwdTypes.dynamic: 98 | if (spec.bind.includes('/')) { 99 | return `unix://${spec.bind}` 100 | } 101 | if (`${parseInt(spec.bind, 10)}` === spec.bind) { 102 | return `//localhost:${spec.bind}` 103 | } 104 | return `//${spec.bind}` 105 | default: 106 | return '' 107 | } 108 | } 109 | 110 | const makeCodeElement = (child: React.ReactNode): React.ReactNode => { 111 | return ( 112 | 116 | {/* min width to match 5 digit ports */} 117 | {child} 118 | 119 | ) 120 | } 121 | 122 | const makeForwardingDescription = ( 123 | config: ForwardingConfig, 124 | isConnected: boolean, 125 | id: string, 126 | fwdId: string 127 | ) => { 128 | const { spec } = config 129 | switch (spec.type) { 130 | case fwdTypes.dynamic: 131 | return ( 132 | 133 | 134 | {makeCodeElement( 135 | isConnected ? ( 136 | {spec.bind} 137 | ) : ( 138 | spec.bind 139 | ) 140 | )} 141 | 142 | 143 | ) 144 | case fwdTypes.local: 145 | return ( 146 | 147 | 148 | {makeCodeElement( 149 | isConnected ? ( 150 | {spec.bind} 151 | ) : ( 152 | spec.bind 153 | ) 154 | )} 155 | 156 | 157 | {makeCodeElement(spec.target)} 158 | 159 | 160 | ) 161 | case fwdTypes.remote: 162 | return ( 163 | 164 | 165 | {makeCodeElement(spec.bind)} 166 | 167 | 168 | {makeCodeElement(spec.target)} 169 | 170 | 171 | ) 172 | case fwdTypes.http: 173 | return ( 174 | 175 | 176 | {makeCodeElement( 177 | isConnected ? ( 178 | 183 | 184 | 185 | ) : ( 186 | 187 | 188 | 189 | ) 190 | )} 191 | 192 | 193 | {makeCodeElement(spec.target)} 194 | 195 | 196 | ) 197 | } 198 | } 199 | 200 | class Forwarding extends React.Component { 201 | state = { editIsActive: false } 202 | 203 | render() { 204 | if (!this.props.state || !this.props.autoforward) { 205 | return null 206 | } 207 | const { id, fwdId, config, state } = this.props.state 208 | const { apiClient } = this.props 209 | 210 | return ( 211 | <> 212 |
213 |
214 |
215 |
219 | {['disconnected', 'error'].includes(state.status) ? ( 220 | 229 | ) : ( 230 | 241 | )} 242 |
243 |
244 | 255 |
256 |
257 |
258 | 259 |
260 |
261 |
262 |
263 |
267 | 268 | {config.label || fwdId} 269 | 270 |
271 |
272 |
273 | {this.props.autoforward.config.start ? ( 274 |
275 | start 276 |
277 | ) : null} 278 | {this.props.autoforward.config.retry ? ( 279 |
280 | retry 281 |
282 | ) : null} 283 |
284 |
285 |
286 |
287 | 288 |
289 |
290 |
294 | 295 | dynamic 296 | {/* force width of maximum contents */} 297 | 304 | {config.spec.type} 305 | 306 | 307 |
308 |
312 | 317 | {config.spec.type.charAt(0)} 318 | 319 |
320 |
321 | {makeForwardingDescription( 322 | config, 323 | state.status === 'connected', 324 | id, 325 | fwdId 326 | )} 327 |
328 |
329 |
330 |
331 |
332 | 333 |
{makeStatus(state)}
334 |
335 | 336 | {!this.state.editIsActive ? null : ( 337 | 344 | this.setState({ ...this.state, editIsActive: false }) 345 | } 346 | /> 347 | )} 348 | 349 | ) 350 | } 351 | } 352 | 353 | const makeHostIsUp = (state: State, id: string): boolean => { 354 | const host = state.api.state.hosts.find((x) => x.id === id) 355 | return host ? host.state.status === 'connected' : false 356 | } 357 | 358 | const mapStateToProps = (state: State, ownProps: OwnProps): Props => { 359 | const hostIsUp = makeHostIsUp(state, ownProps.id) 360 | const forwarding = 361 | state.api.state.forwardings.find( 362 | (x) => x.id === ownProps.id && x.fwdId === ownProps.fwdId 363 | ) || null 364 | const autoforward = 365 | state.api.state.autoforwards.find( 366 | (x) => x.id === ownProps.id && x.fwdId === ownProps.fwdId 367 | ) || null 368 | return { 369 | ...ownProps, 370 | state: forwarding, 371 | autoforward, 372 | hostIsUp, 373 | } 374 | } 375 | 376 | export default connect(mapStateToProps)(Forwarding) 377 | -------------------------------------------------------------------------------- /gui/src/components/ForwardingCreate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import type { APIClient } from '@/gui/api/client' 4 | 5 | import ForwardingForm from './ForwardingForm' 6 | 7 | interface Props { 8 | id: string 9 | apiClient: APIClient 10 | } 11 | 12 | interface ComponentState { 13 | editIsActive: boolean 14 | } 15 | 16 | export default class ForwardingCreate extends React.Component< 17 | Props, 18 | ComponentState 19 | > { 20 | state = { editIsActive: false } 21 | 22 | render() { 23 | return ( 24 | <> 25 |
26 |
27 |
30 | this.setState({ ...this.state, editIsActive: true }) 31 | } 32 | > 33 | 34 | 35 | 36 |
37 |
38 |
Add forwarding
39 |
40 | 41 | {!this.state.editIsActive ? null : ( 42 | 49 | this.setState({ ...this.state, editIsActive: false }) 50 | } 51 | /> 52 | )} 53 | 54 | ) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /gui/src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | type Props = { 4 | subtitle: React.ReactNode | null 5 | } 6 | 7 | const Header = (props: Props) => ( 8 |
9 |
10 |
11 |

SSHMon

12 |

{props.subtitle}

13 |
14 |
15 |
16 | ) 17 | export default Header 18 | -------------------------------------------------------------------------------- /gui/src/components/Host.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import type { APIClient } from '@/gui/api/client' 5 | import type { State } from '@/gui/types/redux' 6 | import { CONNECT_REASON_AUTORETRY } from '@/server/autoconnect/constants' 7 | import type { 8 | AutoconnectState, 9 | AutoconnectSubState, 10 | } from '@/server/autoconnect/reducer' 11 | import type { ForwardingState } from '@/server/forward/reducer' 12 | import type { HostStatus } from '@/server/host/actions' 13 | import type { HostState, HostSubState } from '@/server/host/reducer' 14 | 15 | import Forwarding from './Forwarding' 16 | import ForwardingCreate from './ForwardingCreate' 17 | import HostForm from './HostForm' 18 | 19 | interface OwnProps { 20 | id: string 21 | apiClient: APIClient 22 | } 23 | 24 | interface StateProps { 25 | state: HostState | null 26 | forwardings: ForwardingState[] 27 | autoconnect: AutoconnectState | null 28 | } 29 | 30 | interface ComponentState { 31 | isCollapsed: boolean 32 | contentHeight: number 33 | editIsActive: boolean 34 | } 35 | 36 | interface Props extends StateProps, OwnProps {} 37 | 38 | const makeColorClass = (status: HostStatus): string => { 39 | switch (status) { 40 | case 'connecting': 41 | return 'has-text-info' 42 | case 'connected': 43 | return 'has-text-success' 44 | case 'disconnecting': 45 | return 'has-text-warning' 46 | case 'disconnected': 47 | return 'has-text-grey' 48 | case 'error': 49 | return 'has-text-danger' 50 | default: 51 | return '' 52 | } 53 | } 54 | 55 | const makeStatusText = ( 56 | state: HostSubState, 57 | autoconnect: AutoconnectSubState 58 | ) => { 59 | if (autoconnect.autoretryId && state.status !== 'connecting') { 60 | if (autoconnect.timeout === 0) { 61 | return 'Reconnecting' 62 | } 63 | return `Reconnecting in ${autoconnect.timeout / 1000}s…` 64 | } 65 | if ( 66 | state.status === 'connecting' && 67 | state.reason === CONNECT_REASON_AUTORETRY 68 | ) { 69 | return 'Reconnecting' 70 | } 71 | 72 | return {state.status} 73 | } 74 | 75 | const makeStatus = (state: HostSubState, autoconnect: AutoconnectSubState) => { 76 | const colorClass = 77 | state.status !== 'connecting' && autoconnect.autoretryId 78 | ? 'has-text-info' 79 | : makeColorClass(state.status) 80 | const text = makeStatusText(state, autoconnect) 81 | 82 | return ( 83 |
84 |
85 | {text} 86 |
87 |
88 | 89 | 90 | 91 | 92 | 93 |
94 |
95 | ) 96 | } 97 | 98 | const makePanelBlockStyle = (state: ComponentState) => { 99 | const style: React.CSSProperties = { 100 | overflow: 'hidden', 101 | transition: 'all 200ms ease-in-out', 102 | } 103 | 104 | if (state.isCollapsed) { 105 | style.maxHeight = 0 106 | style.borderBottomWidth = 0 107 | style.paddingBottom = 0 108 | style.paddingTop = 0 109 | style.marginBottom = 0 110 | style.marginTop = 0 111 | } else { 112 | style.maxHeight = state.contentHeight * 1.5 // add some more to leave space for padding/margin 113 | } 114 | 115 | return style 116 | } 117 | 118 | class Host extends React.Component { 119 | state = { isCollapsed: true, contentHeight: 0, editIsActive: false } 120 | panelBlock: HTMLDivElement | null = null 121 | 122 | computeContentHeight() { 123 | if (this.panelBlock === null) { 124 | return 125 | } 126 | if (this.state.isCollapsed) { 127 | return 128 | } 129 | 130 | const contentHeight = this.panelBlock.scrollHeight 131 | 132 | if (contentHeight !== this.state.contentHeight) { 133 | this.setState({ ...this.state, contentHeight }) 134 | } 135 | } 136 | 137 | componentDidMount() { 138 | this.computeContentHeight() 139 | } 140 | componentDidUpdate() { 141 | this.computeContentHeight() 142 | } 143 | 144 | onClick() { 145 | this.setState({ ...this.state, isCollapsed: !this.state.isCollapsed }) 146 | } 147 | 148 | render() { 149 | if (!this.props.state || !this.props.autoconnect) { 150 | return null 151 | } 152 | const { id, config, state } = this.props.state 153 | const { apiClient } = this.props 154 | 155 | return ( 156 | <> 157 |
158 |
170 |
171 |
175 | 182 | 183 | 184 |
185 |
186 |
187 |
191 |
192 | {config.label || id} 193 |
194 |
195 |
196 |
197 | {this.props.autoconnect.config.start ? ( 198 |
199 | start 200 |
201 | ) : null} 202 | {this.props.autoconnect.config.retry ? ( 203 |
204 | retry 205 |
206 | ) : null} 207 |
208 |
209 |
210 |
211 |
212 | {makeStatus(state, this.props.autoconnect.state)} 213 |
214 |
215 |
216 |
{ 220 | this.panelBlock = ref 221 | }} 222 | > 223 | 224 | 225 | {this.props.forwardings.map(({ fwdId }) => ( 226 | 227 | 234 | 235 | ))} 236 | 237 | 243 | 244 | 245 |
228 | 233 |
238 | 242 |
246 |
247 |
248 | {' '} 249 | {/* https://github.com/jgthms/bulma/issues/1563 */} 250 |
251 |
252 |
253 |
254 | 263 | {['disconnected', 'error'].includes(state.status) ? ( 264 | 270 | ) : ( 271 | 277 | )} 278 |
279 |
280 |
281 |
282 |
283 | 284 | {!this.state.editIsActive ? null : ( 285 | 291 | this.setState({ ...this.state, editIsActive: false }) 292 | } 293 | /> 294 | )} 295 | 296 | ) 297 | } 298 | } 299 | 300 | const mapStateToProps = (state: State, ownProps: OwnProps): Props => ({ 301 | ...ownProps, 302 | state: state.api.state.hosts.find((x) => x.id === ownProps.id) || null, 303 | forwardings: state.api.state.forwardings.filter((x) => x.id === ownProps.id), 304 | autoconnect: 305 | state.api.state.autoconnects.find((x) => x.id === ownProps.id) || null, 306 | }) 307 | 308 | export default connect(mapStateToProps)(Host) 309 | -------------------------------------------------------------------------------- /gui/src/components/HostCreate.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import type { APIClient } from '@/gui/api/client' 4 | 5 | import HostForm from './HostForm' 6 | 7 | interface Props { 8 | apiClient: APIClient 9 | } 10 | 11 | interface ComponentState { 12 | editIsActive: boolean 13 | } 14 | 15 | export default class HostCreate extends React.Component { 16 | state = { editIsActive: false } 17 | 18 | render() { 19 | return ( 20 | <> 21 |
22 |
this.setState({ ...this.state, editIsActive: true })} 25 | > 26 | Add host 27 |
28 |
29 | 30 | {!this.state.editIsActive ? null : ( 31 | 37 | this.setState({ ...this.state, editIsActive: false }) 38 | } 39 | /> 40 | )} 41 | 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /gui/src/components/HostForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import type { APIClient } from '@/gui/api/client' 5 | import type { State } from '@/gui/types/redux' 6 | import type { AutoconnectConfig } from '@/server/autoconnect/types' 7 | import type { HostState } from '@/server/host/reducer' 8 | import type { HostConfig } from '@/server/host/types' 9 | 10 | interface OwnProps { 11 | id: string | null 12 | config: HostConfig | null 13 | autoConfig: AutoconnectConfig | null 14 | apiClient: APIClient 15 | onClose: () => void 16 | } 17 | 18 | interface StateProps { 19 | hosts: HostState[] 20 | } 21 | 22 | interface Props extends StateProps, OwnProps {} 23 | 24 | interface ComponentState { 25 | id: string 26 | label: string 27 | autostart: boolean 28 | autoretry: boolean 29 | sshHost: string 30 | sshConfig: { key: string; value: string }[] 31 | } 32 | 33 | class HostForm extends React.Component { 34 | constructor(props: Props) { 35 | super(props) 36 | this.state = { 37 | id: props.id || '', 38 | label: props.config ? props.config.label : '', 39 | autostart: props.autoConfig ? props.autoConfig.start : false, 40 | autoretry: props.autoConfig ? props.autoConfig.retry : false, 41 | sshHost: 42 | props.config && props.config.ssh.host !== props.id 43 | ? props.config.ssh.host 44 | : '', 45 | sshConfig: props.config 46 | ? Object.entries(props.config.ssh.config).map((x) => ({ 47 | key: x[0], 48 | value: x[1], 49 | })) 50 | : [], 51 | } 52 | } 53 | 54 | isCreate(): boolean { 55 | return this.props.id === null 56 | } 57 | 58 | checkIsValidId(): string { 59 | if (!this.isCreate()) { 60 | return '' 61 | } 62 | if (this.state.id === '') { 63 | return 'ID is required.' 64 | } 65 | if (this.props.hosts.map((x) => x.id).includes(this.state.id)) { 66 | return 'ID is already taken.' 67 | } 68 | return '' 69 | } 70 | 71 | getErrors(): string[] { 72 | return [this.checkIsValidId()].filter((x) => x) 73 | } 74 | 75 | submit() { 76 | const { id, label, sshHost, sshConfig, autostart, autoretry } = this.state 77 | const config = { 78 | label, 79 | ssh: { 80 | host: sshHost || id, 81 | config: sshConfig.reduce((acc, val) => { 82 | acc[val.key] = val.value 83 | return acc 84 | }, {} as { [key: string]: string }), 85 | }, 86 | } 87 | const autoConfig = { start: autostart, retry: autoretry } 88 | if (this.isCreate()) { 89 | this.props.apiClient.hostCreate({ id, config, autoConfig }) 90 | } else { 91 | this.props.apiClient.hostEdit({ id, config, autoConfig }) 92 | } 93 | 94 | this.props.onClose() 95 | } 96 | 97 | delete() { 98 | if (this.props.id === null) { 99 | return 100 | } 101 | this.props.apiClient.hostDelete({ id: this.props.id }) 102 | 103 | this.props.onClose() 104 | } 105 | 106 | isValid(): boolean { 107 | return this.getErrors().length === 0 108 | } 109 | 110 | onChangeId(event: React.ChangeEvent<{ value: string }>) { 111 | this.setState({ ...this.state, id: event.target.value }) 112 | } 113 | onChangeLabel(event: React.ChangeEvent<{ value: string }>) { 114 | this.setState({ ...this.state, label: event.target.value }) 115 | } 116 | onChangeAutostart(event: React.ChangeEvent<{ value: string }>) { 117 | this.setState({ ...this.state, autostart: event.target.value === 'on' }) 118 | } 119 | onChangeAutoretry(event: React.ChangeEvent<{ value: string }>) { 120 | this.setState({ ...this.state, autoretry: event.target.value === 'on' }) 121 | } 122 | onChangeSSHHost(event: React.ChangeEvent<{ value: string }>) { 123 | this.setState({ ...this.state, sshHost: event.target.value }) 124 | } 125 | onChangeSSHConfigKey( 126 | index: number, 127 | event: React.ChangeEvent<{ value: string }> 128 | ) { 129 | const sshConfig = [...this.state.sshConfig, { key: '', value: '' }] 130 | .map((x, i) => 131 | i === index ? { key: event.target.value, value: x.value } : x 132 | ) 133 | .filter((x) => x.key || x.value) 134 | this.setState({ ...this.state, sshConfig }) 135 | } 136 | onChangeSSHConfigValue( 137 | index: number, 138 | event: React.ChangeEvent<{ value: string }> 139 | ) { 140 | const sshConfig = [...this.state.sshConfig, { key: '', value: '' }] 141 | .map((x, i) => 142 | i === index ? { key: x.key, value: event.target.value } : x 143 | ) 144 | .filter((x) => x.key || x.value) 145 | this.setState({ ...this.state, sshConfig }) 146 | } 147 | 148 | render() { 149 | return ( 150 |
151 |
152 |
153 |
154 |

155 | {this.isCreate() ? 'New' : 'Edit'} host 156 |

157 | 162 |
163 |
164 |
165 |
166 | 167 |
168 |
169 |
170 |
171 | 180 |
181 |
{this.checkIsValidId()}
182 |
183 |
184 |
185 | 186 |
187 |
188 | 189 |
190 |
191 |
192 |
193 | 200 |
201 |
202 |
203 |
204 | 205 |
206 |
207 | 208 |
209 |
210 |
211 |
212 | 222 | 232 |
233 |
234 |
235 |
236 | 237 |
238 |
239 | 240 |
241 |
242 |
243 |
244 | 254 | 264 |
265 |
266 |
267 |
268 | 269 |
270 | 271 |
SSH options
272 | 273 |
274 |
275 | 276 |
277 |
278 |
279 |
280 | 287 |
288 |
Defaults to ID if not specified.
289 |
290 |
291 |
292 | {[...this.state.sshConfig, { key: '', value: '' }].map((x, i) => ( 293 |
294 |
295 | 296 |
297 |
298 |
299 |
300 | this.onChangeSSHConfigKey(i, e)} 306 | /> 307 |
308 |
309 | this.onChangeSSHConfigValue(i, e)} 315 | /> 316 |
317 |
318 |
319 |
320 | ))} 321 | 322 | {this.isCreate() ? null : ( 323 | <> 324 |
325 | 326 |
Delete host
327 | 328 |
329 |
330 | 336 |
337 |
338 | This cannot be undone. 339 |
340 |
341 | 342 | )} 343 |
344 |
345 | {/* INFO hpello .buttons modifier is-right does not seem to work without block style */} 346 |
347 | 350 | 357 |
358 |
359 |
360 |
361 | ) 362 | } 363 | } 364 | 365 | const mapStateToProps = (state: State, ownProps: OwnProps): Props => ({ 366 | ...ownProps, 367 | hosts: state.api.state.hosts, 368 | }) 369 | 370 | export default connect(mapStateToProps)(HostForm) 371 | -------------------------------------------------------------------------------- /gui/src/components/SystemInfo.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { connect } from 'react-redux' 3 | 4 | import type { State } from '@/gui/types/redux' 5 | import type { SystemState } from '@/server/system/reducer' 6 | import type { SystemInfo as SystemInfoType } from '@/server/system/types' 7 | 8 | const largesp = 9 | const thinnbsp = 10 | 11 | const formatDuration = (secs: number): React.ReactNode => { 12 | if (secs < 60) { 13 | return ( 14 | <> 15 | <{thinnbsp}1{thinnbsp}min 16 | 17 | ) 18 | } 19 | 20 | let seconds = secs 21 | const weeks = Math.floor(seconds / (7 * 24 * 60 * 60)) 22 | if (weeks > 0) { 23 | seconds -= weeks * 7 * 24 * 60 * 60 24 | } 25 | 26 | const days = Math.floor(seconds / (24 * 60 * 60)) 27 | if (days > 0) { 28 | seconds -= days * 24 * 60 * 60 29 | } 30 | 31 | const hours = Math.floor(seconds / (60 * 60)) 32 | if (hours > 0) { 33 | seconds -= hours * 60 * 60 34 | } 35 | 36 | const minutes = Math.floor(seconds / 60) 37 | 38 | return ( 39 | <> 40 | {weeks > 0 ? ( 41 | <> 42 | {weeks} 43 | {thinnbsp}w{' '} 44 | 45 | ) : null} 46 | {days > 0 ? ( 47 | <> 48 | {days} 49 | {thinnbsp}d{' '} 50 | 51 | ) : null} 52 | {hours > 0 ? ( 53 | <> 54 | {hours} 55 | {thinnbsp}h{' '} 56 | 57 | ) : null} 58 | {minutes} 59 | {thinnbsp}min 60 | 61 | ) 62 | } 63 | 64 | const formatMemory = (bytes: number): React.ReactNode => { 65 | const g = bytes / 2 ** 30 66 | if (Math.floor(g * 10) > 0) { 67 | return ( 68 | <> 69 | {(bytes / 2 ** 30).toFixed(2)} 70 | {thinnbsp}GB 71 | 72 | ) 73 | } 74 | 75 | const m = bytes / 2 ** 20 76 | if (Math.floor(m * 10) > 0) { 77 | return ( 78 | <> 79 | {(bytes / 2 ** 20).toFixed(2)} 80 | {thinnbsp}MB 81 | 82 | ) 83 | } 84 | 85 | return ( 86 | <> 87 | {(bytes / 2 ** 10).toFixed(2)} 88 | {thinnbsp}kB 89 | 90 | ) 91 | } 92 | 93 | const formatPercentage = (value: number): React.ReactNode => { 94 | return ( 95 | <> 96 | {value.toFixed(2)} 97 | {thinnbsp}% 98 | 99 | ) 100 | } 101 | 102 | const makeSystemSummary = (info: SystemInfoType): React.ReactNode => { 103 | return ( 104 | <> 105 | {info.platform} ({info.arch}){largesp}|{largesp} 106 | {info.totalCPUs} 107 | {thinnbsp}CPU{info.totalCPUs > 1 ? 's' : ''} 108 | {largesp}|{largesp} 109 | {formatMemory(info.totalMemoryBytes)} 110 | 111 | ) 112 | } 113 | 114 | // eslint-disable-next-line @typescript-eslint/no-empty-interface -- left for clarity 115 | interface OwnProps {} 116 | 117 | interface StateProps { 118 | system: SystemState 119 | } 120 | 121 | interface Props extends StateProps, OwnProps {} 122 | 123 | class SystemInfo extends React.Component { 124 | render() { 125 | const { info, stats } = this.props.system 126 | 127 | return ( 128 |
129 |
130 | 134 | 135 | 136 | 137 | 140 | 141 | 142 | 143 | 153 | 154 | 155 | 156 | 159 | 160 | 161 | 162 | 165 | 166 | 167 | 168 | 171 | 172 | 173 |
System 138 | {info ? makeSystemSummary(info) : ''} 139 |
Version 144 | {info ? ( 145 | <> 146 | SSHMon {info.version} 147 | {largesp}|{largesp}Node.js {info.nodeVersion} 148 | 149 | ) : ( 150 | '' 151 | )} 152 |
Uptime 157 | {stats ? formatDuration(stats.uptimeSeconds) : ''} 158 |
CPU usage 163 | {stats ? formatPercentage(stats.cpuUsage) : ''} 164 |
Memory usage 169 | {stats ? formatMemory(stats.memoryUsageBytes) : ''} 170 |
174 |
175 |
176 | ) 177 | } 178 | } 179 | 180 | const mapStateToProps = (state: State, ownProps: OwnProps): Props => ({ 181 | ...ownProps, 182 | system: state.api.state.system, 183 | }) 184 | 185 | export default connect(mapStateToProps)(SystemInfo) 186 | -------------------------------------------------------------------------------- /gui/src/main.tsx: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-imports 2 | import '../sass/main.scss' 3 | 4 | import * as React from 'react' 5 | import { createRoot } from 'react-dom/client' 6 | import { Provider } from 'react-redux' 7 | 8 | import { APIClient } from './api/client' 9 | import App from './components/App' 10 | import { store } from './store' 11 | 12 | const apiClient = new APIClient(store) 13 | 14 | const Index = () => ( 15 | 16 | 17 | 18 | ) 19 | 20 | const container = document.getElementById('root') as HTMLElement 21 | const root = createRoot(container) 22 | root.render() 23 | -------------------------------------------------------------------------------- /gui/src/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | import type { Action as APIAction } from './api/actions' 4 | import type { State as APIState } from './api/reducer' 5 | import { reducer as api } from './api/reducer' 6 | 7 | export type State = { 8 | api: APIState 9 | } 10 | 11 | export type Action = APIAction | { type: '__any_other_action_type__' } 12 | 13 | export default combineReducers({ 14 | api, 15 | } as any) // FIXME hpello https://github.com/reactjs/redux/issues/2709 16 | -------------------------------------------------------------------------------- /gui/src/store.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux' 2 | import { createLogger } from 'redux-logger' 3 | 4 | import reducer from './reducer' 5 | 6 | const makeMiddleware = () => { 7 | return applyMiddleware( 8 | createLogger({ 9 | level: { 10 | prevState: false, 11 | action: 'log', 12 | nextState: false, 13 | error: 'error', 14 | }, 15 | }) 16 | ) 17 | } 18 | 19 | export const store = createStore(reducer, makeMiddleware()) 20 | -------------------------------------------------------------------------------- /gui/src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import type { Store as ReduxStore } from 'redux' 2 | 3 | import type { Action as _Action, State as _State } from '@/gui/reducer' 4 | 5 | export type Action = _Action 6 | export type State = _State 7 | 8 | export type Store = ReduxStore 9 | -------------------------------------------------------------------------------- /gui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /gui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import tsconfigPaths from 'vite-tsconfig-paths' 4 | 5 | export default defineConfig({ 6 | plugins: [tsconfigPaths(), react()], 7 | build: { 8 | sourcemap: true, 9 | }, 10 | server: { 11 | port: 1234, 12 | }, 13 | }) 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshmon", 3 | "version": "0.5.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "yarn --cwd server build && yarn --cwd gui build", 7 | "build-watch": "yarn --cwd server build-watch & yarn --cwd gui build-watch", 8 | "lint": "eslint . --ext .js,.jsx,.ts,.tsx,.json", 9 | "prettier-check": "prettier --check .", 10 | "clean": "yarn --cwd server clean && yarn --cwd gui clean", 11 | "tsc": "tsc --outDir /tmp/none", 12 | "tsc-watch": "yarn tsc --watch", 13 | "postinstall": "yarn --cwd server && yarn --cwd gui", 14 | "pkg": "mkdir -p build && pkg . -o build/sshmon", 15 | "pkg-clean": "rm -rf build", 16 | "start": "node server/build/cli.js", 17 | "deploy": "yarn pkg-clean && yarn clean && yarn build && yarn pkg", 18 | "test": "cd test && make test" 19 | }, 20 | "devDependencies": { 21 | "@typescript-eslint/eslint-plugin": "^5.54.1", 22 | "@typescript-eslint/parser": "^5.54.1", 23 | "eslint": "^8.36.0", 24 | "eslint-config-prettier": "^8.7.0", 25 | "eslint-plugin-simple-import-sort": "^10.0.0", 26 | "prettier": "2.8.4", 27 | "typescript": "^4.9.5" 28 | }, 29 | "bin": "server/build/cli.js", 30 | "pkg": { 31 | "assets": [ 32 | "gui/dist", 33 | "server/node_modules/bunyan" 34 | ] 35 | }, 36 | "dependencies": { 37 | "pkg": "^5.8.1" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshmon-server", 3 | "version": "", 4 | "license": "MIT", 5 | "scripts": { 6 | "build": "tsc --outDir build && resolve-tspaths --out build", 7 | "build-watch": "tsc --outDir build --watch | ruby -ne 'puts $_.gsub(/\\x1Bc/, \"\")' & chokidar build/ --slient -c 'resolve-tspaths --out build'", 8 | "clean": "rm -rf build", 9 | "start": "node build/cli.js" 10 | }, 11 | "dependencies": { 12 | "bunyan": "^1.8.12", 13 | "chokidar": "^3.5.3", 14 | "http-proxy": "^1.17.0", 15 | "joi": "^17.8.3", 16 | "js-yaml": "^4.1.0", 17 | "json-stable-stringify": "^1.0.1", 18 | "lodash": "^4.17.21", 19 | "redux": "^4.2.1", 20 | "redux-thunk": "2.4.2", 21 | "restify": "^11.1.0", 22 | "socket.io": "^4.6.2", 23 | "tmp": "0.2.1", 24 | "yargs": "^17.7.1" 25 | }, 26 | "devDependencies": { 27 | "@types/http-proxy": "^1.16.2", 28 | "@types/js-yaml": "^4.0.5", 29 | "@types/json-stable-stringify": "^1.0.32", 30 | "@types/lodash": "^4.14.191", 31 | "@types/restify": "^8.5.6", 32 | "@types/tmp": "0.2.3", 33 | "@types/yargs": "^17.0.22", 34 | "chokidar-cli": "^3.0.0", 35 | "resolve-tspaths": "^0.8.13", 36 | "typescript": "^4.9.5" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /server/src/api/api-server.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from '@/server/types/redux' 2 | 3 | import type { SocketNotify } from './socket-notify' 4 | import { createIO } from './socket-server' 5 | import { createServer as createStaticServer } from './static-server' 6 | 7 | export const createServer = (store: Store, socketNotify: SocketNotify) => { 8 | const server = createStaticServer() 9 | const io = createIO(store, socketNotify) 10 | io.attach(server.server) 11 | 12 | return server 13 | } 14 | -------------------------------------------------------------------------------- /server/src/api/api.ts: -------------------------------------------------------------------------------- 1 | import type { AutoconnectConfig } from '@/server/autoconnect' 2 | import type { AutoforwardConfig } from '@/server/autoforward' 3 | import type { ForwardingConfig } from '@/server/forward' 4 | import type { HostConfig } from '@/server/host' 5 | 6 | export enum apiKeys { 7 | hostCreate = 'hostCreate', 8 | hostEdit = 'hostEdit', 9 | hostDelete = 'hostDelete', 10 | hostConnect = 'hostConnect', 11 | hostDisconnect = 'hostDisconnect', 12 | 13 | forwardingCreate = 'forwardingCreate', 14 | forwardingEdit = 'forwardingEdit', 15 | forwardingDelete = 'forwardingDelete', 16 | forwardingConnect = 'forwardingConnect', 17 | forwardingDisconnect = 'forwardingDisconnect', 18 | } 19 | 20 | export type APIEndpoint = 21 | | { 22 | key: apiKeys.hostCreate 23 | args: { id: string; config: HostConfig; autoConfig: AutoconnectConfig } 24 | } 25 | | { 26 | key: apiKeys.hostEdit 27 | args: { id: string; config: HostConfig; autoConfig: AutoconnectConfig } 28 | } 29 | | { key: apiKeys.hostDelete; args: { id: string } } 30 | | { key: apiKeys.hostConnect; args: { id: string } } 31 | | { key: apiKeys.hostDisconnect; args: { id: string } } 32 | | { 33 | key: apiKeys.forwardingCreate 34 | args: { 35 | id: string 36 | fwdId: string 37 | config: ForwardingConfig 38 | autoConfig: AutoforwardConfig 39 | } 40 | } 41 | | { 42 | key: apiKeys.forwardingEdit 43 | args: { 44 | id: string 45 | fwdId: string 46 | config: ForwardingConfig 47 | autoConfig: AutoforwardConfig 48 | } 49 | } 50 | | { key: apiKeys.forwardingDelete; args: { id: string; fwdId: string } } 51 | | { key: apiKeys.forwardingConnect; args: { id: string; fwdId: string } } 52 | | { key: apiKeys.forwardingDisconnect; args: { id: string; fwdId: string } } 53 | -------------------------------------------------------------------------------- /server/src/api/component.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'http' 2 | import { promisify } from 'util' 3 | 4 | import { createLogger } from '@/server/log' 5 | import type { State, Store } from '@/server/types/redux' 6 | import { formatURL } from '@/server/utils/server-url' 7 | import { makeTmpPath } from '@/server/utils/tmp' 8 | 9 | import { createServer as createAPIServer } from './api-server' 10 | import type { GatewayServer } from './gateway-server' 11 | import { createServer as createGatewayServer } from './gateway-server' 12 | import type { ProxyTarget } from './simple-proxy-server' 13 | import { SocketNotify } from './socket-notify' 14 | import { onStateChange } from './utils' 15 | 16 | const log = createLogger(__filename) 17 | 18 | export class API { 19 | store: Store 20 | prevState: State 21 | apiServer: Server 22 | gatewayServer: GatewayServer 23 | socketNotify: SocketNotify 24 | 25 | constructor(params: { store: Store }) { 26 | this.store = params.store 27 | const state = params.store.getState() 28 | 29 | this.prevState = state 30 | this.socketNotify = new SocketNotify(state) 31 | this.apiServer = createAPIServer(params.store, this.socketNotify) 32 | this.gatewayServer = createGatewayServer() 33 | } 34 | 35 | setup() { 36 | this.store.subscribe(() => { 37 | const state = this.store.getState() 38 | 39 | process.nextTick(() => { 40 | // prevent recursion 41 | onStateChange( 42 | this.prevState, 43 | state, 44 | (id: string, fwdId: string, target: ProxyTarget) => 45 | this.gatewayServer.addForwardingProxy(id, fwdId, target), 46 | (id: string, fwdId: string) => 47 | this.gatewayServer.removeForwardingProxy(id, fwdId) 48 | ) 49 | 50 | this.socketNotify.onStateChange(state) 51 | 52 | this.prevState = state 53 | }) 54 | }) 55 | } 56 | 57 | async shutdown() { 58 | await this.gatewayServer.shutdown() 59 | } 60 | 61 | async listen(...args: any[]) { 62 | const apiSocketPath = await makeTmpPath(__filename)('api-server') 63 | const apiListen = promisify( 64 | this.apiServer.listen.bind(this.apiServer, apiSocketPath) 65 | ) 66 | await apiListen() 67 | log.debug('api socket listening at %s', formatURL(this.apiServer)) 68 | 69 | this.gatewayServer.setDefaultTarget({ socketPath: apiSocketPath }) 70 | const gatewayListen = promisify( 71 | this.gatewayServer.listen.bind(this.gatewayServer, ...args) 72 | ) 73 | await gatewayListen() 74 | log.info('api listening at %s', formatURL(this.gatewayServer.server.server)) 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /server/src/api/constants.ts: -------------------------------------------------------------------------------- 1 | export enum socketTypes { 2 | // from client 3 | register = 'register', 4 | unregister = 'unregister', 5 | apiCall = 'apiCall', 6 | 7 | // from server 8 | state = 'state', 9 | } 10 | 11 | export interface SocketMessageError { 12 | message: string 13 | } 14 | 15 | export const PROXY_PATH_PREFIX = '/proxy' 16 | export const SOCKET_PATH = '/socket' 17 | -------------------------------------------------------------------------------- /server/src/api/gateway-server.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'http' 2 | import type { Socket } from 'net' 3 | 4 | import { PROXY_PATH_PREFIX } from './constants' 5 | import type { ProxyTarget } from './simple-proxy-server' 6 | import { SimpleProxyServer } from './simple-proxy-server' 7 | 8 | const makeProxyPathPrefix = (pathPrefix: string, id: string, fwdId: string) => 9 | [pathPrefix, id, fwdId].join('/') 10 | 11 | export class GatewayServer { 12 | server: SimpleProxyServer 13 | pathPrefix: string 14 | sockets: Set = new Set() 15 | 16 | constructor(pathPrefix: string) { 17 | this.pathPrefix = pathPrefix 18 | this.server = new SimpleProxyServer() 19 | } 20 | 21 | setDefaultTarget(target: ProxyTarget) { 22 | this.server.addTarget('/', target) 23 | } 24 | 25 | addForwardingProxy(id: string, fwdId: string, target: ProxyTarget) { 26 | const fullPrefix = makeProxyPathPrefix(this.pathPrefix, id, fwdId) 27 | this.server.addTarget(fullPrefix, target) 28 | } 29 | 30 | removeForwardingProxy(id: string, fwdId: string) { 31 | const fullPrefix = makeProxyPathPrefix(this.pathPrefix, id, fwdId) 32 | this.server.removeTarget(fullPrefix) 33 | } 34 | 35 | listen(...args: Parameters) { 36 | this.server.server.listen(...args) 37 | 38 | // keep track of connected sockets for shutdown 39 | this.server.server.on('connection', (socket) => { 40 | this.sockets.add(socket) 41 | socket.on('close', () => this.sockets.delete(socket)) 42 | }) 43 | } 44 | 45 | shutdown() { 46 | return new Promise((resolve) => { 47 | this.server.server.close(resolve) 48 | this.sockets.forEach((socket) => socket.destroy()) 49 | }) 50 | } 51 | } 52 | 53 | export const createServer = () => new GatewayServer(PROXY_PATH_PREFIX) 54 | -------------------------------------------------------------------------------- /server/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export * from './component' 2 | export * from './constants' 3 | -------------------------------------------------------------------------------- /server/src/api/simple-proxy-server.ts: -------------------------------------------------------------------------------- 1 | import type { IncomingMessage, Server, ServerResponse } from 'http' 2 | import { createServer as createHTTPServer } from 'http' 3 | import * as HTTPProxy from 'http-proxy' 4 | import type { Socket } from 'net' 5 | 6 | export type ProxyTarget = 7 | | { host: string; port: number } 8 | | { socketPath: string } 9 | 10 | type ProxyConfig = { 11 | pathPrefix: string 12 | proxyServer: HTTPProxy 13 | } 14 | 15 | const handleRequest = 16 | (server: SimpleProxyServer) => 17 | (req: IncomingMessage, res: ServerResponse) => { 18 | const { proxies } = server 19 | const url = req.url as string // see IncomingMessage 20 | 21 | const proxy = proxies.find((p) => url.startsWith(p.pathPrefix)) 22 | if (!proxy) { 23 | res.writeHead(404, { 'Content-Type': 'application/json' }) 24 | res.end(JSON.stringify({ code: 'NotFoundError', message: 'Not found' })) 25 | return 26 | } 27 | 28 | const { pathPrefix } = proxy 29 | if (pathPrefix !== '/') { 30 | if (!url.startsWith(`${pathPrefix}/`)) { 31 | res.writeHead(302, { 32 | Location: `${pathPrefix.slice(pathPrefix.lastIndexOf('/') + 1)}/`, 33 | }) 34 | res.end() 35 | return 36 | } 37 | 38 | req.url = url.slice(pathPrefix.length) 39 | } 40 | 41 | proxy.proxyServer.web(req, res, {}, (err) => { 42 | if ('code' in err && err.code === 'ECONNRESET') { 43 | res.writeHead(502, { 'Content-Type': 'application/json' }) 44 | res.end( 45 | JSON.stringify({ 46 | code: 'BadGatewayError', 47 | message: 'Target could not be read', 48 | error: err, 49 | }) 50 | ) 51 | return 52 | } 53 | 54 | res.writeHead(500, { 'Content-Type': 'application/json' }) 55 | res.end( 56 | JSON.stringify({ 57 | code: 'InternalServerError', 58 | message: 'Internal server error', 59 | error: err, 60 | }) 61 | ) 62 | }) 63 | } 64 | 65 | const handleUpgrade = 66 | (server: SimpleProxyServer) => 67 | (req: IncomingMessage, socket: Socket, head: Buffer) => { 68 | const { proxies } = server 69 | const url = req.url as string // see IncomingMessage 70 | 71 | const proxy = proxies.find((p) => url.startsWith(p.pathPrefix)) 72 | if (!proxy) { 73 | // TODO hpello notify socket? 74 | socket.destroy() 75 | return 76 | } 77 | 78 | const { pathPrefix } = proxy 79 | if (pathPrefix !== '/') { 80 | req.url = url.slice(pathPrefix.length) 81 | } 82 | 83 | proxy.proxyServer.ws(req, socket, head) 84 | } 85 | 86 | export class SimpleProxyServer { 87 | server: Server 88 | proxies: ProxyConfig[] 89 | 90 | constructor() { 91 | this.proxies = [] 92 | this.server = createHTTPServer(handleRequest(this)) 93 | this.server.on('upgrade', handleUpgrade(this)) 94 | } 95 | 96 | addTarget(pathPrefix: string, target: ProxyTarget) { 97 | const proxyServer = HTTPProxy.createProxyServer({ 98 | // @ts-expect-error: we use the undocumented 'socketPath' parameter 99 | target, 100 | xfwd: true, 101 | }) 102 | 103 | this.proxies = this.proxies 104 | .filter((p) => p.pathPrefix !== pathPrefix) 105 | .concat([{ pathPrefix, proxyServer }]) 106 | .sort((a, b) => { 107 | // sort path prefix descending to ensure path processing order later 108 | if (a.pathPrefix.length < b.pathPrefix.length) { 109 | return 1 110 | } 111 | if (a.pathPrefix.length > b.pathPrefix.length) { 112 | return -1 113 | } 114 | return 0 115 | }) 116 | } 117 | 118 | removeTarget(pathPrefix: string) { 119 | this.proxies = this.proxies.filter((p) => p.pathPrefix !== pathPrefix) 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /server/src/api/socket-notify.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from 'socket.io' 2 | 3 | import { createLogger } from '@/server/log' 4 | import type { State } from '@/server/types/redux' 5 | 6 | import { socketTypes } from './constants' 7 | 8 | const log = createLogger(__filename) 9 | 10 | export class SocketNotify { 11 | state: State | null 12 | sockets: Set = new Set() 13 | 14 | constructor(state: State) { 15 | this.state = state 16 | } 17 | 18 | register(socket: Socket) { 19 | if (!this.sockets.has(socket)) { 20 | log.debug('register socket %s', socket.id) 21 | } 22 | this.sockets.add(socket) 23 | if (this.state !== null) { 24 | socket.emit(socketTypes.state, this.state) 25 | } 26 | } 27 | 28 | unregister(socket: Socket) { 29 | if (this.sockets.has(socket)) { 30 | log.debug('unregister socket %s', socket.id) 31 | } 32 | this.sockets.delete(socket) 33 | } 34 | 35 | onStateChange(state: State) { 36 | this.state = state 37 | this.sockets.forEach((socket) => { 38 | socket.emit(socketTypes.state, this.state) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/api/socket-server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io' 2 | 3 | import { createLogger } from '@/server/log' 4 | import type { Store } from '@/server/types/redux' 5 | 6 | import { SOCKET_PATH, socketTypes } from './constants' 7 | import type { SocketNotify } from './socket-notify' 8 | import { setupSocket } from './socket-setup' 9 | 10 | const log = createLogger(__filename) 11 | 12 | export const createIO = (store: Store, socketNotify: SocketNotify) => { 13 | const io = new Server({ 14 | path: SOCKET_PATH, 15 | serveClient: false, 16 | }) 17 | 18 | io.on('connection', (socket) => { 19 | const xfwdfor = socket.handshake.headers['x-forwarded-for'] as string 20 | const address = xfwdfor ? xfwdfor.split(',')[0] : socket.handshake.address 21 | log.info('client connected: %s at %s', socket.id, address) 22 | 23 | socket.on(socketTypes.register, () => socketNotify.register(socket)) 24 | socket.on(socketTypes.unregister, () => socketNotify.unregister(socket)) 25 | 26 | socket.on('disconnect', () => { 27 | socketNotify.unregister(socket) 28 | log.info('client disconnected: %s at %s', socket.id, address) 29 | }) 30 | 31 | setupSocket(socket, store) 32 | }) 33 | 34 | return io 35 | } 36 | -------------------------------------------------------------------------------- /server/src/api/socket-setup.ts: -------------------------------------------------------------------------------- 1 | import type { Socket } from 'socket.io' 2 | 3 | import { thunks as autoconnectThunks } from '@/server/autoconnect' 4 | import { thunks as autoforwardThunks } from '@/server/autoforward' 5 | import { thunks as forwardThunks } from '@/server/forward' 6 | import { thunks as hostThunks } from '@/server/host' 7 | import { createLogger } from '@/server/log' 8 | import type { AsyncThunkAction, Store } from '@/server/types/redux' 9 | 10 | import type { APIEndpoint } from './api' 11 | import { apiKeys } from './api' 12 | import type { SocketMessageError } from './constants' 13 | import { socketTypes } from './constants' 14 | 15 | const log = createLogger(__filename) 16 | 17 | const CONNECT_REASON = 'api' 18 | const FORWARD_REASON = 'api' 19 | 20 | const makeActions = (e: APIEndpoint): AsyncThunkAction[] | null => { 21 | switch (e.key) { 22 | case apiKeys.hostCreate: 23 | return [ 24 | hostThunks.hostCreate(e.args.id, e.args.config), 25 | autoconnectThunks.autoconnectCreate(e.args.id, e.args.autoConfig), 26 | ] 27 | case apiKeys.hostEdit: 28 | return [ 29 | hostThunks.hostEdit(e.args.id, e.args.config), 30 | autoconnectThunks.autoconnectEdit(e.args.id, e.args.autoConfig), 31 | ] 32 | case apiKeys.hostDelete: 33 | return [ 34 | hostThunks.hostDelete(e.args.id), 35 | autoconnectThunks.autoconnectDelete(e.args.id), 36 | ] 37 | case apiKeys.hostConnect: 38 | return [hostThunks.hostConnect(e.args.id, CONNECT_REASON)] 39 | case apiKeys.hostDisconnect: 40 | return [hostThunks.hostDisconnect(e.args.id, CONNECT_REASON)] 41 | 42 | case apiKeys.forwardingCreate: 43 | return [ 44 | forwardThunks.forwardingCreate(e.args.id, e.args.fwdId, e.args.config), 45 | autoforwardThunks.autoforwardCreate( 46 | e.args.id, 47 | e.args.fwdId, 48 | e.args.autoConfig 49 | ), 50 | ] 51 | case apiKeys.forwardingEdit: 52 | return [ 53 | forwardThunks.forwardingEdit(e.args.id, e.args.fwdId, e.args.config), 54 | autoforwardThunks.autoforwardEdit( 55 | e.args.id, 56 | e.args.fwdId, 57 | e.args.autoConfig 58 | ), 59 | ] 60 | case apiKeys.forwardingDelete: 61 | return [ 62 | forwardThunks.forwardingDelete(e.args.id, e.args.fwdId), 63 | autoforwardThunks.autoforwardDelete(e.args.id, e.args.fwdId), 64 | ] 65 | case apiKeys.forwardingConnect: 66 | return [ 67 | forwardThunks.forwardingConnect( 68 | e.args.id, 69 | e.args.fwdId, 70 | FORWARD_REASON 71 | ), 72 | ] 73 | case apiKeys.forwardingDisconnect: 74 | return [ 75 | forwardThunks.forwardingDisconnect( 76 | e.args.id, 77 | e.args.fwdId, 78 | FORWARD_REASON 79 | ), 80 | ] 81 | default: 82 | return null 83 | } 84 | } 85 | 86 | export const setupSocket = (socket: Socket, store: Store) => { 87 | socket.on( 88 | socketTypes.apiCall, 89 | ( 90 | e: APIEndpoint, 91 | callback: (err: SocketMessageError | null, result: any) => any 92 | ) => { 93 | const actions = makeActions(e) 94 | 95 | if (actions === null) { 96 | const message = `unhandled api call key: ${e.key}` 97 | log.error({ err: new Error(message) }) 98 | callback({ message }, null) 99 | return 100 | } 101 | 102 | actions 103 | .reduce( 104 | (acc, val) => acc.then(() => store.dispatch(val)), 105 | Promise.resolve() 106 | ) 107 | .then((result) => { 108 | log.info({ apiCall: e }, 'api call success') 109 | callback(null, result) 110 | }) 111 | .catch((err: Error) => { 112 | log.error({ err, apiCall: e }, 'api call failure') 113 | callback({ message: err.message }, null) 114 | }) 115 | } 116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /server/src/api/static-server.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path' 2 | import { createServer as restifyCreateServer, plugins } from 'restify' 3 | 4 | export const createServer = () => { 5 | const server = restifyCreateServer() 6 | 7 | server.use(plugins.gzipResponse()) 8 | 9 | server.get( 10 | '/assets/*', 11 | plugins.serveStatic({ 12 | directory: join(__dirname, '../../../gui/dist'), 13 | }) 14 | ) 15 | 16 | server.get( 17 | '/', 18 | plugins.serveStatic({ 19 | directory: join(__dirname, '../../../gui/dist'), 20 | default: 'index.html', 21 | }) 22 | ) 23 | 24 | return server 25 | } 26 | -------------------------------------------------------------------------------- /server/src/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import type { ForwardingStatus } from '@/server/forward' 4 | import { fwdTypes } from '@/server/forward' 5 | import type { State } from '@/server/types/redux' 6 | 7 | import type { ProxyTarget } from './simple-proxy-server' 8 | 9 | const getForwardingStatus = ( 10 | state: State, 11 | id: string, 12 | fwdId: string 13 | ): ForwardingStatus | null => { 14 | const forwarding = find(state.forwardings, { id, fwdId }) 15 | return !forwarding ? null : forwarding.state.status 16 | } 17 | 18 | const getForwardingBind = ( 19 | state: State, 20 | id: string, 21 | fwdId: string 22 | ): string | null => { 23 | const forwarding = find(state.forwardings, { id, fwdId }) 24 | if (!forwarding) { 25 | return null 26 | } 27 | if (!forwarding.state.params) { 28 | return null 29 | } 30 | return forwarding.state.params.bind 31 | } 32 | 33 | type AddForwardingProxy = ( 34 | id: string, 35 | fwdId: string, 36 | target: ProxyTarget 37 | ) => void 38 | type RemoveForwardingProxy = (id: string, fwdId: string) => void 39 | 40 | const makeProxyTarget = (address: string): ProxyTarget | null => { 41 | if (address.includes('/')) { 42 | return { socketPath: address } 43 | } 44 | if ((address.match(/:/g) || []).length !== 1) { 45 | return null 46 | } 47 | const [host, portStr] = address.split(':') 48 | const port = parseInt(portStr, 10) 49 | return { host, port } 50 | } 51 | 52 | const addForwardingProxies = ( 53 | prevState: State, 54 | state: State, 55 | addForwardingProxy: AddForwardingProxy 56 | ) => { 57 | state.forwardings 58 | .filter(({ config }) => config.spec.type === fwdTypes.http) 59 | .filter( 60 | ({ id, fwdId }) => 61 | getForwardingStatus(prevState, id, fwdId) !== 'connected' 62 | ) 63 | .filter( 64 | ({ id, fwdId }) => getForwardingStatus(state, id, fwdId) === 'connected' 65 | ) 66 | .forEach(({ id, fwdId }) => { 67 | const bind = getForwardingBind(state, id, fwdId) 68 | if (bind === null) { 69 | return 70 | } 71 | const target = makeProxyTarget(bind) 72 | if (target === null) { 73 | return 74 | } 75 | addForwardingProxy(id, fwdId, target) 76 | }) 77 | } 78 | 79 | const removeForwardingProxies = ( 80 | prevState: State, 81 | state: State, 82 | removeForwardingProxy: RemoveForwardingProxy 83 | ) => { 84 | state.forwardings 85 | .filter(({ config }) => config.spec.type === fwdTypes.http) 86 | .filter( 87 | ({ id, fwdId }) => 88 | getForwardingStatus(prevState, id, fwdId) === 'connected' 89 | ) 90 | .filter( 91 | ({ id, fwdId }) => getForwardingStatus(state, id, fwdId) !== 'connected' 92 | ) 93 | .forEach(({ id, fwdId }) => { 94 | removeForwardingProxy(id, fwdId) 95 | }) 96 | } 97 | 98 | export const onStateChange = ( 99 | prevState: State, 100 | state: State, 101 | addForwardingProxy: AddForwardingProxy, 102 | removeForwardingProxy: RemoveForwardingProxy 103 | ) => { 104 | addForwardingProxies(prevState, state, addForwardingProxy) 105 | removeForwardingProxies(prevState, state, removeForwardingProxy) 106 | } 107 | -------------------------------------------------------------------------------- /server/src/autoconnect/actions.ts: -------------------------------------------------------------------------------- 1 | import type { AutoconnectConfig } from './types' 2 | 3 | export enum types { 4 | AUTOCONNECT_CREATE = 'AUTOCONNECT_CREATE', 5 | AUTOCONNECT_EDIT = 'AUTOCONNECT_EDIT', 6 | AUTOCONNECT_DELETE = 'AUTOCONNECT_DELETE', 7 | AUTOCONNECT_LAUNCH = 'AUTOCONNECT_LAUNCH', 8 | AUTOCONNECT_CANCEL = 'AUTOCONNECT_CANCEL', 9 | } 10 | 11 | interface AutoconnectCreateAction { 12 | type: types.AUTOCONNECT_CREATE 13 | id: string 14 | config: AutoconnectConfig 15 | } 16 | 17 | interface AutoconnectEditAction { 18 | type: types.AUTOCONNECT_EDIT 19 | id: string 20 | config: AutoconnectConfig 21 | } 22 | 23 | interface AutoconnectDeleteAction { 24 | type: types.AUTOCONNECT_DELETE 25 | id: string 26 | } 27 | 28 | interface AutoconnectLaunchAction { 29 | type: types.AUTOCONNECT_LAUNCH 30 | id: string 31 | autoretryId: string 32 | numRetries: number 33 | timeout: number 34 | } 35 | 36 | interface AutoconnectCancelAction { 37 | type: types.AUTOCONNECT_CANCEL 38 | id: string 39 | } 40 | 41 | export type Action = 42 | | AutoconnectCreateAction 43 | | AutoconnectEditAction 44 | | AutoconnectDeleteAction 45 | | AutoconnectLaunchAction 46 | | AutoconnectCancelAction 47 | 48 | export const actions = { 49 | autoconnectCreate: (id: string, config: AutoconnectConfig): Action => ({ 50 | type: types.AUTOCONNECT_CREATE, 51 | id, 52 | config, 53 | }), 54 | autoconnectEdit: (id: string, config: AutoconnectConfig): Action => ({ 55 | type: types.AUTOCONNECT_EDIT, 56 | id, 57 | config, 58 | }), 59 | autoconnectDelete: (id: string): Action => ({ 60 | type: types.AUTOCONNECT_DELETE, 61 | id, 62 | }), 63 | autoconnectLaunch: ( 64 | id: string, 65 | autoretryId: string, 66 | numRetries: number, 67 | timeout: number 68 | ): Action => ({ 69 | type: types.AUTOCONNECT_LAUNCH, 70 | id, 71 | autoretryId, 72 | numRetries, 73 | timeout, 74 | }), 75 | autoconnectCancel: (id: string): Action => ({ 76 | type: types.AUTOCONNECT_CANCEL, 77 | id, 78 | }), 79 | } 80 | -------------------------------------------------------------------------------- /server/src/autoconnect/component.ts: -------------------------------------------------------------------------------- 1 | import type { State, Store } from '@/server/types/redux' 2 | 3 | import { onStateChange } from './utils' 4 | 5 | export class Autoconnector { 6 | store: Store 7 | prevState: State 8 | 9 | constructor(params: { store: Store }) { 10 | this.store = params.store 11 | this.prevState = params.store.getState() 12 | } 13 | 14 | setup() { 15 | this.store.subscribe(() => { 16 | const state = this.store.getState() 17 | 18 | process.nextTick(() => { 19 | // prevent recursion 20 | onStateChange(this.prevState, state, this.store.dispatch) 21 | this.prevState = state 22 | }) 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/src/autoconnect/constants.ts: -------------------------------------------------------------------------------- 1 | export const CONNECT_REASON_AUTOSTART = 'autostart' 2 | export const CONNECT_REASON_AUTORETRY = 'autoretry' 3 | -------------------------------------------------------------------------------- /server/src/autoconnect/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './component' 3 | export * from './constants' 4 | export * from './reducer' 5 | export * from './thunks' 6 | export * from './types' 7 | -------------------------------------------------------------------------------- /server/src/autoconnect/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/server/types/redux' 2 | 3 | import { types } from './actions' 4 | import type { AutoconnectConfig } from './types' 5 | 6 | export interface AutoconnectSubState { 7 | autoretryId: string | null 8 | numRetries: number 9 | timeout: number 10 | } 11 | 12 | export interface AutoconnectState { 13 | id: string 14 | config: AutoconnectConfig 15 | state: AutoconnectSubState 16 | } 17 | 18 | export type State = AutoconnectState[] 19 | 20 | const initialState = (): State => [] 21 | 22 | const defaultSubState = (): AutoconnectSubState => ({ 23 | autoretryId: null, 24 | numRetries: 0, 25 | timeout: 0, 26 | }) 27 | 28 | export const reducer = ( 29 | state: State = initialState(), 30 | action: Action 31 | ): State => { 32 | switch (action.type) { 33 | case types.AUTOCONNECT_CREATE: { 34 | const { id, config } = action 35 | 36 | const autoconnectState = { 37 | id, 38 | config, 39 | state: defaultSubState(), 40 | } 41 | 42 | return [...state, autoconnectState] 43 | } 44 | case types.AUTOCONNECT_EDIT: { 45 | const { id, config } = action 46 | return state.map((x) => (x.id === id ? { ...x, config } : x)) 47 | } 48 | case types.AUTOCONNECT_DELETE: { 49 | const { id } = action 50 | return state.filter((x) => x.id !== id) 51 | } 52 | case types.AUTOCONNECT_LAUNCH: { 53 | const { id, autoretryId, numRetries, timeout } = action 54 | 55 | return state.map((autoconnectState) => { 56 | if (autoconnectState.id !== id) { 57 | return autoconnectState 58 | } 59 | 60 | return { 61 | ...autoconnectState, 62 | state: { autoretryId, numRetries, timeout }, 63 | } 64 | }) 65 | } 66 | case types.AUTOCONNECT_CANCEL: { 67 | const { id } = action 68 | return state.map((autoconnectState) => { 69 | if (autoconnectState.id !== id) { 70 | return autoconnectState 71 | } 72 | 73 | return { 74 | ...autoconnectState, 75 | state: defaultSubState(), 76 | } 77 | }) 78 | } 79 | default: 80 | return state 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /server/src/autoconnect/thunks/create-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/autoconnect/actions' 4 | import type { AutoconnectConfig } from '@/server/autoconnect/types' 5 | import { createLogger } from '@/server/log' 6 | import type { AsyncThunkAction, Dispatch, GetState } from '@/server/types/redux' 7 | import { ErrorWithCode } from '@/server/utils/error-with-code' 8 | 9 | const log = createLogger(__filename) 10 | 11 | export const autoconnectCreate = 12 | (id: string, config: AutoconnectConfig): AsyncThunkAction => 13 | async (dispatch: Dispatch, getState: GetState) => { 14 | const state = getState() 15 | const host = find(state.hosts, { id }) 16 | if (!host) { 17 | log.info(`host not found: ${id}`) 18 | return 19 | } 20 | 21 | const autoconnect = find(state.autoconnects, { id }) 22 | if (autoconnect) { 23 | throw new ErrorWithCode(409, `autoconnect already exists: ${id}`) 24 | } 25 | 26 | dispatch(actions.autoconnectCreate(id, config)) 27 | } 28 | 29 | export const autoconnectEdit = 30 | (id: string, config: AutoconnectConfig): AsyncThunkAction => 31 | async (dispatch: Dispatch, getState: GetState) => { 32 | const state = getState() 33 | const host = find(state.hosts, { id }) 34 | if (!host) { 35 | log.info(`host not found: ${id}`) 36 | return 37 | } 38 | 39 | const autoconnect = find(state.autoconnects, { id }) 40 | if (!autoconnect) { 41 | throw new ErrorWithCode(404, `autoconnect not found: ${id}`) 42 | } 43 | 44 | dispatch(actions.autoconnectEdit(id, config)) 45 | } 46 | 47 | export const autoconnectDelete = 48 | (id: string): AsyncThunkAction => 49 | async (dispatch: Dispatch, getState: GetState) => { 50 | const state = getState() 51 | const autoconnect = find(state.autoconnects, { id }) 52 | if (!autoconnect) { 53 | log.info(`autoconnect not found: ${id}`) 54 | return 55 | } 56 | 57 | dispatch(actions.autoconnectDelete(id)) 58 | } 59 | -------------------------------------------------------------------------------- /server/src/autoconnect/thunks/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | autoconnectCreate, 3 | autoconnectDelete, 4 | autoconnectEdit, 5 | } from './create-thunks' 6 | import { autoretrySpawn } from './retry-thunks' 7 | 8 | export const thunks = { 9 | autoconnectCreate, 10 | autoconnectEdit, 11 | autoconnectDelete, 12 | autoretrySpawn, 13 | } 14 | -------------------------------------------------------------------------------- /server/src/autoconnect/thunks/retry-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/autoconnect/actions' 4 | import { CONNECT_REASON_AUTORETRY } from '@/server/autoconnect/constants' 5 | import type { AutoconnectState } from '@/server/autoconnect/reducer' 6 | import { thunks as hostThunks } from '@/server/host' 7 | import type { Dispatch, GetState } from '@/server/types/redux' 8 | 9 | const MIN_TIMEOUT_MS = 100 10 | const MAX_TIMEOUT_MS = 5000 11 | const NEXT_TIMEOUT_FACTOR = 2 12 | 13 | const makeNextTimeout = (autoconnect: AutoconnectState): number => { 14 | if (autoconnect.state.timeout === 0) { 15 | return MIN_TIMEOUT_MS 16 | } 17 | 18 | const timeout = autoconnect.state.timeout * NEXT_TIMEOUT_FACTOR 19 | if (timeout > MAX_TIMEOUT_MS) { 20 | return MAX_TIMEOUT_MS 21 | } 22 | 23 | return timeout 24 | } 25 | 26 | const makeAutoretryId = (): string => process.hrtime().join('-') 27 | 28 | export const autoretrySpawn = 29 | (id: string) => async (dispatch: Dispatch, getState: GetState) => { 30 | const state = getState() 31 | 32 | const autoconnect = find(state.autoconnects, { id }) 33 | if (!autoconnect) { 34 | return 35 | } 36 | 37 | const autoretryId = makeAutoretryId() 38 | const numRetries = autoconnect.state.numRetries + 1 39 | const timeout = makeNextTimeout(autoconnect) 40 | 41 | dispatch(actions.autoconnectLaunch(id, autoretryId, numRetries, timeout)) 42 | 43 | setTimeout(() => { 44 | const a = find(getState().autoconnects, { id }) 45 | if (a && a.state.autoretryId === autoretryId) { 46 | dispatch(hostThunks.hostConnect(id, CONNECT_REASON_AUTORETRY)) 47 | } 48 | }, timeout) 49 | } 50 | -------------------------------------------------------------------------------- /server/src/autoconnect/types.ts: -------------------------------------------------------------------------------- 1 | export type AutoconnectConfig = { 2 | start: boolean 3 | retry: boolean 4 | } 5 | -------------------------------------------------------------------------------- /server/src/autoconnect/utils.ts: -------------------------------------------------------------------------------- 1 | import { find, some } from 'lodash' 2 | 3 | import type { HostStatus } from '@/server/host' 4 | import { thunks as hostThunks } from '@/server/host' 5 | import type { Dispatch, State } from '@/server/types/redux' 6 | 7 | import { actions } from './actions' 8 | import { CONNECT_REASON_AUTORETRY, CONNECT_REASON_AUTOSTART } from './constants' 9 | import { thunks } from './thunks' 10 | 11 | const getStatus = (state: State, id: string): HostStatus | null => { 12 | const host = find(state.hosts, { id }) 13 | return !host ? null : host.state.status 14 | } 15 | 16 | const getConnectReason = (state: State, id: string): string | null => { 17 | const host = find(state.hosts, { id }) 18 | return !host ? null : host.state.reason 19 | } 20 | 21 | const getAutostart = (state: State, id: string): boolean | null => { 22 | const autoconnect = find(state.autoconnects, { id }) 23 | return !autoconnect ? null : autoconnect.config.start 24 | } 25 | 26 | const getAutoretry = (state: State, id: string): boolean | null => { 27 | const autoconnect = find(state.autoconnects, { id }) 28 | return !autoconnect ? null : autoconnect.config.retry 29 | } 30 | 31 | const autoconnectExist = (state: State, id: string): boolean => 32 | some(state.autoconnects, { id }) 33 | 34 | const hasActiveAutoretry = (state: State, id: string): boolean => { 35 | const autoconnect = find(state.autoconnects, { id }) 36 | return !autoconnect ? false : autoconnect.state.autoretryId !== null 37 | } 38 | 39 | const includes = (array: T[], x: T | null): boolean => 40 | x === null ? false : array.includes(x) 41 | 42 | const autoStartHosts = (prevState: State, state: State, dispatch: Dispatch) => { 43 | state.autoconnects 44 | .map((x) => x.id) 45 | .filter((id) => !autoconnectExist(prevState, id)) 46 | .filter((id) => autoconnectExist(state, id)) 47 | .filter((id) => getAutostart(state, id)) 48 | .forEach((id) => { 49 | dispatch(hostThunks.hostConnect(id, CONNECT_REASON_AUTOSTART)) 50 | }) 51 | } 52 | 53 | const autoRetryHosts = (prevState: State, state: State, dispatch: Dispatch) => { 54 | state.autoconnects 55 | .map((x) => x.id) 56 | .filter((id) => getAutoretry(state, id)) 57 | .filter((id) => !includes(['error'], getStatus(prevState, id))) 58 | .filter((id) => includes(['error'], getStatus(state, id))) 59 | .forEach((id) => { 60 | dispatch(thunks.autoretrySpawn(id)) 61 | }) 62 | } 63 | 64 | const cancelAutoconnects = ( 65 | prevState: State, 66 | state: State, 67 | dispatch: Dispatch 68 | ) => { 69 | state.autoconnects 70 | .map((x) => x.id) 71 | .filter((id) => getStatus(prevState, id) !== 'connected') 72 | .filter((id) => getStatus(state, id) === 'connected') 73 | .filter((id) => hasActiveAutoretry(state, id)) 74 | .forEach((id) => { 75 | dispatch(actions.autoconnectCancel(id)) 76 | }) 77 | 78 | state.autoconnects 79 | .map((x) => x.id) 80 | .filter((id) => getStatus(prevState, id) !== 'connecting') 81 | .filter((id) => getStatus(state, id) === 'connecting') 82 | .filter( 83 | (id) => 84 | getConnectReason(state, id) !== CONNECT_REASON_AUTOSTART && 85 | getConnectReason(state, id) !== CONNECT_REASON_AUTORETRY 86 | ) 87 | .filter((id) => hasActiveAutoretry(state, id)) 88 | .forEach((id) => { 89 | dispatch(actions.autoconnectCancel(id)) 90 | }) 91 | 92 | state.autoconnects 93 | .map((x) => x.id) 94 | .filter((id) => getStatus(prevState, id) !== 'disconnecting') 95 | .filter((id) => getStatus(state, id) === 'disconnecting') 96 | .filter( 97 | (id) => 98 | getConnectReason(state, id) !== CONNECT_REASON_AUTOSTART && 99 | getConnectReason(state, id) !== CONNECT_REASON_AUTORETRY 100 | ) 101 | .filter((id) => hasActiveAutoretry(state, id)) 102 | .forEach((id) => { 103 | dispatch(actions.autoconnectCancel(id)) 104 | }) 105 | } 106 | 107 | export const onStateChange = ( 108 | prevState: State, 109 | state: State, 110 | dispatch: Dispatch 111 | ) => { 112 | autoStartHosts(prevState, state, dispatch) 113 | autoRetryHosts(prevState, state, dispatch) 114 | cancelAutoconnects(prevState, state, dispatch) 115 | } 116 | -------------------------------------------------------------------------------- /server/src/autoforward/actions.ts: -------------------------------------------------------------------------------- 1 | import type { AutoforwardConfig } from './types' 2 | 3 | export enum types { 4 | AUTOFORWARD_CREATE = 'AUTOFORWARD_CREATE', 5 | AUTOFORWARD_EDIT = 'AUTOFORWARD_EDIT', 6 | AUTOFORWARD_DELETE = 'AUTOFORWARD_DELETE', 7 | AUTOFORWARD_LAUNCH = 'AUTOFORWARD_LAUNCH', 8 | AUTOFORWARD_CANCEL = 'AUTOFORWARD_CANCEL', 9 | } 10 | 11 | interface AutoforwardCreateAction { 12 | type: types.AUTOFORWARD_CREATE 13 | id: string 14 | fwdId: string 15 | config: AutoforwardConfig 16 | } 17 | 18 | interface AutoforwardEditAction { 19 | type: types.AUTOFORWARD_EDIT 20 | id: string 21 | fwdId: string 22 | config: AutoforwardConfig 23 | } 24 | 25 | interface AutoforwardDeleteAction { 26 | type: types.AUTOFORWARD_DELETE 27 | id: string 28 | fwdId: string 29 | } 30 | 31 | interface AutoforwardLaunchAction { 32 | type: types.AUTOFORWARD_LAUNCH 33 | id: string 34 | fwdId: string 35 | autoretryId: string 36 | numRetries: number 37 | timeout: number 38 | } 39 | 40 | interface AutoforwardCancelAction { 41 | type: types.AUTOFORWARD_CANCEL 42 | id: string 43 | fwdId: string 44 | } 45 | 46 | export type Action = 47 | | AutoforwardCreateAction 48 | | AutoforwardEditAction 49 | | AutoforwardDeleteAction 50 | | AutoforwardLaunchAction 51 | | AutoforwardCancelAction 52 | 53 | export const actions = { 54 | autoforwardCreate: ( 55 | id: string, 56 | fwdId: string, 57 | config: AutoforwardConfig 58 | ): Action => ({ type: types.AUTOFORWARD_CREATE, id, fwdId, config }), 59 | autoforwardEdit: ( 60 | id: string, 61 | fwdId: string, 62 | config: AutoforwardConfig 63 | ): Action => ({ type: types.AUTOFORWARD_EDIT, id, fwdId, config }), 64 | autoforwardDelete: (id: string, fwdId: string): Action => ({ 65 | type: types.AUTOFORWARD_DELETE, 66 | id, 67 | fwdId, 68 | }), 69 | autoforwardLaunch: ( 70 | id: string, 71 | fwdId: string, 72 | autoretryId: string, 73 | numRetries: number, 74 | timeout: number 75 | ): Action => ({ 76 | type: types.AUTOFORWARD_LAUNCH, 77 | id, 78 | fwdId, 79 | autoretryId, 80 | numRetries, 81 | timeout, 82 | }), 83 | autoforwardCancel: (id: string, fwdId: string): Action => ({ 84 | type: types.AUTOFORWARD_CANCEL, 85 | id, 86 | fwdId, 87 | }), 88 | } 89 | -------------------------------------------------------------------------------- /server/src/autoforward/component.ts: -------------------------------------------------------------------------------- 1 | import type { State, Store } from '@/server/types/redux' 2 | 3 | import { onStateChange } from './utils' 4 | 5 | export class Autoforwarder { 6 | store: Store 7 | prevState: State 8 | 9 | constructor(params: { store: Store }) { 10 | this.store = params.store 11 | this.prevState = params.store.getState() 12 | } 13 | 14 | setup() { 15 | this.store.subscribe(() => { 16 | const state = this.store.getState() 17 | 18 | process.nextTick(() => { 19 | // prevent recursion 20 | onStateChange(this.prevState, state, this.store.dispatch) 21 | this.prevState = state 22 | }) 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/src/autoforward/constants.ts: -------------------------------------------------------------------------------- 1 | export const FORWARD_REASON_AUTOSTART = 'autostart' 2 | export const FORWARD_REASON_AUTORETRY = 'autoretry' 3 | -------------------------------------------------------------------------------- /server/src/autoforward/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './component' 3 | export * from './constants' 4 | export * from './reducer' 5 | export * from './thunks' 6 | export * from './types' 7 | -------------------------------------------------------------------------------- /server/src/autoforward/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/server/types/redux' 2 | 3 | import { types } from './actions' 4 | import type { AutoforwardConfig } from './types' 5 | 6 | export interface AutoforwardSubState { 7 | autoretryId: string | null 8 | numRetries: number 9 | timeout: number 10 | } 11 | 12 | export interface AutoforwardState { 13 | id: string 14 | fwdId: string 15 | config: AutoforwardConfig 16 | state: AutoforwardSubState 17 | } 18 | 19 | export type State = AutoforwardState[] 20 | 21 | const initialState = (): State => [] 22 | 23 | const defaultSubState = (): AutoforwardSubState => ({ 24 | autoretryId: null, 25 | numRetries: 0, 26 | timeout: 0, 27 | }) 28 | 29 | export const reducer = ( 30 | state: State = initialState(), 31 | action: Action 32 | ): State => { 33 | switch (action.type) { 34 | case types.AUTOFORWARD_CREATE: { 35 | const { id, fwdId, config } = action 36 | 37 | const autoforwardState = { 38 | id, 39 | fwdId, 40 | config, 41 | state: defaultSubState(), 42 | } 43 | 44 | return [...state, autoforwardState] 45 | } 46 | case types.AUTOFORWARD_EDIT: { 47 | const { id, fwdId, config } = action 48 | return state.map((x) => 49 | x.id === id && x.fwdId === fwdId ? { ...x, config } : x 50 | ) 51 | } 52 | case types.AUTOFORWARD_DELETE: { 53 | const { id, fwdId } = action 54 | return state.filter((x) => x.id !== id || x.fwdId !== fwdId) 55 | } 56 | case types.AUTOFORWARD_LAUNCH: { 57 | const { id, fwdId, autoretryId, numRetries, timeout } = action 58 | 59 | return state.map((autoforwardState) => { 60 | if (autoforwardState.id !== id || autoforwardState.fwdId !== fwdId) { 61 | return autoforwardState 62 | } 63 | 64 | return { 65 | ...autoforwardState, 66 | state: { autoretryId, numRetries, timeout }, 67 | } 68 | }) 69 | } 70 | case types.AUTOFORWARD_CANCEL: { 71 | const { id, fwdId } = action 72 | return state.map((autoforwardState) => { 73 | if (autoforwardState.id !== id || autoforwardState.fwdId !== fwdId) { 74 | return autoforwardState 75 | } 76 | 77 | return { 78 | ...autoforwardState, 79 | state: defaultSubState(), 80 | } 81 | }) 82 | } 83 | default: 84 | return state 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /server/src/autoforward/thunks/create-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/autoforward/actions' 4 | import type { AutoforwardConfig } from '@/server/autoforward/types' 5 | import { createLogger } from '@/server/log' 6 | import type { AsyncThunkAction, Dispatch, GetState } from '@/server/types/redux' 7 | import { ErrorWithCode } from '@/server/utils/error-with-code' 8 | 9 | const log = createLogger(__filename) 10 | 11 | export const autoforwardCreate = 12 | (id: string, fwdId: string, config: AutoforwardConfig): AsyncThunkAction => 13 | async (dispatch: Dispatch, getState: GetState) => { 14 | const state = getState() 15 | const host = find(state.hosts, { id }) 16 | if (!host) { 17 | log.info(`host not found: ${id}`) 18 | return 19 | } 20 | 21 | const autoforward = find(state.autoforwards, { id, fwdId }) 22 | if (autoforward) { 23 | throw new ErrorWithCode(409, `autoforward already exists: ${id}/${fwdId}`) 24 | } 25 | 26 | dispatch(actions.autoforwardCreate(id, fwdId, config)) 27 | } 28 | 29 | export const autoforwardEdit = 30 | (id: string, fwdId: string, config: AutoforwardConfig): AsyncThunkAction => 31 | async (dispatch: Dispatch, getState: GetState) => { 32 | const state = getState() 33 | const host = find(state.hosts, { id }) 34 | if (!host) { 35 | log.info(`host not found: ${id}`) 36 | return 37 | } 38 | 39 | const autoforward = find(state.autoforwards, { id, fwdId }) 40 | if (!autoforward) { 41 | throw new ErrorWithCode(404, `autoforward not found: ${id}/${fwdId}`) 42 | } 43 | 44 | dispatch(actions.autoforwardEdit(id, fwdId, config)) 45 | } 46 | 47 | export const autoforwardDelete = 48 | (id: string, fwdId: string): AsyncThunkAction => 49 | async (dispatch: Dispatch, getState: GetState) => { 50 | const state = getState() 51 | const autoforward = find(state.autoforwards, { id, fwdId }) 52 | if (!autoforward) { 53 | log.info(`autoforward not found: ${id}/${fwdId}`) 54 | return 55 | } 56 | 57 | dispatch(actions.autoforwardDelete(id, fwdId)) 58 | } 59 | -------------------------------------------------------------------------------- /server/src/autoforward/thunks/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | autoforwardCreate, 3 | autoforwardDelete, 4 | autoforwardEdit, 5 | } from './create-thunks' 6 | import { autoretrySpawn } from './retry-thunks' 7 | 8 | export const thunks = { 9 | autoforwardCreate, 10 | autoforwardEdit, 11 | autoforwardDelete, 12 | autoretrySpawn, 13 | } 14 | -------------------------------------------------------------------------------- /server/src/autoforward/thunks/retry-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/autoforward/actions' 4 | import { FORWARD_REASON_AUTORETRY } from '@/server/autoforward/constants' 5 | import type { AutoforwardState } from '@/server/autoforward/reducer' 6 | import { thunks as forwardTunks } from '@/server/forward' 7 | import type { Dispatch, GetState } from '@/server/types/redux' 8 | 9 | const MIN_TIMEOUT_MS = 100 10 | const MAX_TIMEOUT_MS = 5000 11 | const NEXT_TIMEOUT_FACTOR = 2 12 | 13 | const makeNextTimeout = (autoforward: AutoforwardState): number => { 14 | if (autoforward.state.timeout === 0) { 15 | return MIN_TIMEOUT_MS 16 | } 17 | 18 | const timeout = autoforward.state.timeout * NEXT_TIMEOUT_FACTOR 19 | if (timeout > MAX_TIMEOUT_MS) { 20 | return MAX_TIMEOUT_MS 21 | } 22 | 23 | return timeout 24 | } 25 | 26 | const makeAutoretryId = (): string => process.hrtime().join('-') 27 | 28 | export const autoretrySpawn = 29 | (id: string, fwdId: string) => 30 | async (dispatch: Dispatch, getState: GetState) => { 31 | const state = getState() 32 | 33 | const autoforward = find(state.autoforwards, { id, fwdId }) 34 | if (!autoforward) { 35 | return 36 | } 37 | 38 | const autoretryId = makeAutoretryId() 39 | const numRetries = autoforward.state.numRetries + 1 40 | const timeout = makeNextTimeout(autoforward) 41 | 42 | dispatch( 43 | actions.autoforwardLaunch(id, fwdId, autoretryId, numRetries, timeout) 44 | ) 45 | 46 | setTimeout(() => { 47 | const a = find(getState().autoforwards, { id, fwdId }) 48 | if (a && a.state.autoretryId === autoretryId) { 49 | dispatch( 50 | forwardTunks.forwardingConnect(id, fwdId, FORWARD_REASON_AUTORETRY) 51 | ) 52 | } 53 | }, timeout) 54 | } 55 | -------------------------------------------------------------------------------- /server/src/autoforward/types.ts: -------------------------------------------------------------------------------- 1 | export type AutoforwardConfig = { 2 | start: boolean 3 | retry: boolean 4 | } 5 | -------------------------------------------------------------------------------- /server/src/autoforward/utils.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import type { ForwardingStatus } from '@/server/forward' 4 | import { thunks as forwardThunks } from '@/server/forward' 5 | import type { HostStatus } from '@/server/host' 6 | import type { Dispatch, State } from '@/server/types/redux' 7 | 8 | import { actions } from './actions' 9 | import { FORWARD_REASON_AUTORETRY, FORWARD_REASON_AUTOSTART } from './constants' 10 | import { thunks } from './thunks' 11 | 12 | const getHostStatus = (state: State, id: string): HostStatus | null => { 13 | const host = find(state.hosts, { id }) 14 | return !host ? null : host.state.status 15 | } 16 | 17 | const getForwardingStatus = ( 18 | state: State, 19 | id: string, 20 | fwdId: string 21 | ): ForwardingStatus | null => { 22 | const forwarding = find(state.forwardings, { id, fwdId }) 23 | return !forwarding ? null : forwarding.state.status 24 | } 25 | 26 | const getForwardReason = ( 27 | state: State, 28 | id: string, 29 | fwdId: string 30 | ): string | null => { 31 | const forwarding = find(state.forwardings, { id, fwdId }) 32 | return !forwarding ? null : forwarding.state.reason 33 | } 34 | 35 | const getAutostart = ( 36 | state: State, 37 | id: string, 38 | fwdId: string 39 | ): boolean | null => { 40 | const autoforward = find(state.autoforwards, { id, fwdId }) 41 | return !autoforward ? null : autoforward.config.start 42 | } 43 | 44 | const getAutoretry = ( 45 | state: State, 46 | id: string, 47 | fwdId: string 48 | ): boolean | null => { 49 | const autoforward = find(state.autoforwards, { id, fwdId }) 50 | return !autoforward ? null : autoforward.config.retry 51 | } 52 | 53 | const hasActiveAutoretry = ( 54 | state: State, 55 | id: string, 56 | fwdId: string 57 | ): boolean => { 58 | const autoforward = find(state.autoforwards, { id, fwdId }) 59 | return !autoforward ? false : autoforward.state.autoretryId !== null 60 | } 61 | 62 | const includes = (array: T[], x: T | null): boolean => 63 | x === null ? false : array.includes(x) 64 | 65 | const autoStartForwardings = ( 66 | prevState: State, 67 | state: State, 68 | dispatch: Dispatch 69 | ) => { 70 | state.autoforwards 71 | .map((x) => ({ id: x.id, fwdId: x.fwdId })) 72 | .filter(({ id }) => getHostStatus(prevState, id) !== 'connected') 73 | .filter(({ id }) => getHostStatus(state, id) === 'connected') 74 | .filter(({ id, fwdId }) => getAutostart(state, id, fwdId)) 75 | .forEach(({ id, fwdId }) => { 76 | dispatch( 77 | forwardThunks.forwardingConnect(id, fwdId, FORWARD_REASON_AUTOSTART) 78 | ) 79 | }) 80 | } 81 | 82 | const autoRetryForwardings = ( 83 | prevState: State, 84 | state: State, 85 | dispatch: Dispatch 86 | ) => { 87 | state.autoforwards 88 | .map((x) => ({ id: x.id, fwdId: x.fwdId })) 89 | .filter(({ id, fwdId }) => getAutoretry(state, id, fwdId)) 90 | .filter( 91 | ({ id, fwdId }) => 92 | !includes(['error'], getForwardingStatus(prevState, id, fwdId)) 93 | ) 94 | .filter(({ id, fwdId }) => 95 | includes(['error'], getForwardingStatus(state, id, fwdId)) 96 | ) 97 | .forEach(({ id, fwdId }) => { 98 | dispatch(thunks.autoretrySpawn(id, fwdId)) 99 | }) 100 | } 101 | 102 | const cancelAutoforwards = ( 103 | prevState: State, 104 | state: State, 105 | dispatch: Dispatch 106 | ) => { 107 | state.autoforwards 108 | .map((x) => ({ id: x.id, fwdId: x.fwdId })) 109 | .filter( 110 | ({ id, fwdId }) => 111 | getForwardingStatus(prevState, id, fwdId) !== 'connected' 112 | ) 113 | .filter( 114 | ({ id, fwdId }) => getForwardingStatus(state, id, fwdId) === 'connected' 115 | ) 116 | .filter(({ id, fwdId }) => hasActiveAutoretry(state, id, fwdId)) 117 | .forEach(({ id, fwdId }) => { 118 | dispatch(actions.autoforwardCancel(id, fwdId)) 119 | }) 120 | 121 | state.autoforwards 122 | .map((x) => ({ id: x.id, fwdId: x.fwdId })) 123 | .filter( 124 | ({ id, fwdId }) => 125 | getForwardingStatus(prevState, id, fwdId) !== 'connecting' 126 | ) 127 | .filter( 128 | ({ id, fwdId }) => getForwardingStatus(state, id, fwdId) === 'connecting' 129 | ) 130 | .filter( 131 | ({ id, fwdId }) => 132 | getForwardReason(state, id, fwdId) !== FORWARD_REASON_AUTOSTART && 133 | getForwardReason(state, id, fwdId) !== FORWARD_REASON_AUTORETRY 134 | ) 135 | .filter(({ id, fwdId }) => hasActiveAutoretry(state, id, fwdId)) 136 | .forEach(({ id, fwdId }) => { 137 | dispatch(actions.autoforwardCancel(id, fwdId)) 138 | }) 139 | 140 | state.autoforwards 141 | .map((x) => ({ id: x.id, fwdId: x.fwdId })) 142 | .filter( 143 | ({ id, fwdId }) => 144 | getForwardingStatus(prevState, id, fwdId) !== 'disconnecting' 145 | ) 146 | .filter( 147 | ({ id, fwdId }) => 148 | getForwardingStatus(state, id, fwdId) === 'disconnecting' 149 | ) 150 | .filter( 151 | ({ id, fwdId }) => 152 | getForwardReason(state, id, fwdId) !== FORWARD_REASON_AUTOSTART && 153 | getForwardReason(state, id, fwdId) !== FORWARD_REASON_AUTORETRY 154 | ) 155 | .filter(({ id, fwdId }) => hasActiveAutoretry(state, id, fwdId)) 156 | .forEach(({ id, fwdId }) => { 157 | dispatch(actions.autoforwardCancel(id, fwdId)) 158 | }) 159 | } 160 | 161 | export const onStateChange = ( 162 | prevState: State, 163 | state: State, 164 | dispatch: Dispatch 165 | ) => { 166 | autoStartForwardings(prevState, state, dispatch) 167 | autoRetryForwardings(prevState, state, dispatch) 168 | cancelAutoforwards(prevState, state, dispatch) 169 | } 170 | -------------------------------------------------------------------------------- /server/src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as yargs from 'yargs' 2 | 3 | process.env.NODE_ENV = process.env.NODE_ENV || 'production' 4 | 5 | import type { EngineOptions } from './engine' 6 | import { start } from './engine' 7 | 8 | const argv = yargs 9 | .usage('Usage: $0 [options]') 10 | .options({ 11 | c: { 12 | alias: 'config-file', 13 | describe: 'Path to SSHMon config file', 14 | type: 'string', 15 | }, 16 | }) 17 | .help() 18 | .alias('h', 'help') 19 | .version() 20 | .alias('v', 'version') 21 | .parse(process.argv) 22 | 23 | start((argv) as EngineOptions) 24 | -------------------------------------------------------------------------------- /server/src/config/actions.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigConfig } from './types' 2 | 3 | export enum types { 4 | CONFIG_EDIT = 'CONFIG_EDIT', 5 | } 6 | 7 | interface ConfigEditAction { 8 | type: types.CONFIG_EDIT 9 | config: ConfigConfig 10 | } 11 | 12 | export type Action = ConfigEditAction 13 | 14 | export const actions = { 15 | configEdit: (config: ConfigConfig): Action => ({ 16 | type: types.CONFIG_EDIT, 17 | config, 18 | }), 19 | } 20 | -------------------------------------------------------------------------------- /server/src/config/component.ts: -------------------------------------------------------------------------------- 1 | import { access, mkdir, stat, writeFile } from 'fs' 2 | import * as stringify from 'json-stable-stringify' 3 | import { homedir } from 'os' 4 | import { join } from 'path' 5 | import { promisify } from 'util' 6 | const accessAsync = promisify(access) 7 | const mkdirAsync = promisify(mkdir) 8 | const statAsync = promisify(stat) 9 | const writeFileAsync = promisify(writeFile) 10 | 11 | import type { AutoconnectConfig } from '@/server/autoconnect' 12 | import { thunks as autoconnectThunks } from '@/server/autoconnect' 13 | import type { AutoforwardConfig } from '@/server/autoforward' 14 | import { thunks as autoforwardThunks } from '@/server/autoforward' 15 | import type { ForwardingConfig } from '@/server/forward' 16 | import { thunks as forwardThunks } from '@/server/forward' 17 | import type { HostConfig } from '@/server/host' 18 | import { thunks as hostThunks } from '@/server/host' 19 | import { createLogger } from '@/server/log' 20 | import type { State, Store } from '@/server/types/redux' 21 | 22 | import { actions as configActions } from './actions' 23 | import { configObjectToState, configStateToObject } from './convert' 24 | import type { ConfigSchema } from './schema' 25 | import { load, save } from './serialize' 26 | import type { ConfigConfig, ConfigType } from './types' 27 | 28 | const log = createLogger(__filename) 29 | 30 | const dispatchConfigActions = async (config: ConfigType, store: Store) => { 31 | config.hosts.forEach(({ id, config }) => { 32 | store.dispatch(hostThunks.hostCreate(id, config)) 33 | }) 34 | config.forwardings.forEach(({ id, fwdId, config }) => { 35 | store.dispatch(forwardThunks.forwardingCreate(id, fwdId, config)) 36 | }) 37 | config.autoconnects.forEach(({ id, config }) => { 38 | store.dispatch(autoconnectThunks.autoconnectCreate(id, config)) 39 | }) 40 | config.autoforwards.forEach(({ id, fwdId, config }) => { 41 | store.dispatch(autoforwardThunks.autoforwardCreate(id, fwdId, config)) 42 | }) 43 | store.dispatch(configActions.configEdit(config.config)) 44 | } 45 | 46 | const extractConfigFromState = (state: State): ConfigType => { 47 | const hosts: { id: string; config: HostConfig }[] = state.hosts.map( 48 | ({ id, config }) => ({ id, config }) 49 | ) 50 | const forwardings: { id: string; fwdId: string; config: ForwardingConfig }[] = 51 | state.forwardings.map(({ id, fwdId, config }) => ({ id, fwdId, config })) 52 | const autoconnects: { id: string; config: AutoconnectConfig }[] = 53 | state.autoconnects.map(({ id, config }) => ({ id, config })) 54 | const autoforwards: { 55 | id: string 56 | fwdId: string 57 | config: AutoforwardConfig 58 | }[] = state.autoforwards.map(({ id, fwdId, config }) => ({ 59 | id, 60 | fwdId, 61 | config, 62 | })) 63 | const config: ConfigConfig = state.config 64 | return { hosts, forwardings, autoconnects, autoforwards, config } 65 | } 66 | 67 | const saveConfigIfNeeded = async ( 68 | state: State, 69 | prevState: State, 70 | path: string 71 | ) => { 72 | const config = extractConfigFromState(state) 73 | const prevConfig = extractConfigFromState(prevState) 74 | if (stringify(config) === stringify(prevConfig)) { 75 | return 76 | } 77 | 78 | const configObject = configStateToObject(config) 79 | if (config.config.autosave) { 80 | save(configObject, path) 81 | } 82 | } 83 | 84 | const exist = async (path: string): Promise => { 85 | try { 86 | await accessAsync(path) 87 | } catch { 88 | return false 89 | } 90 | return true 91 | } 92 | 93 | const defaultConfig = (): ConfigSchema => ({ 94 | config: { 95 | autosave: true, 96 | }, 97 | }) 98 | 99 | const ensureDefaultConfigFile = async (): Promise => { 100 | const dir = join(homedir(), '.sshmon') 101 | if (!(await exist(dir))) { 102 | log.info('create directory at', dir) 103 | await mkdirAsync(dir, 0o700) 104 | } 105 | 106 | const configFile = join(dir, 'config.yml') 107 | if (!(await exist(configFile))) { 108 | log.info('create config file at', configFile) 109 | await writeFileAsync(configFile, '', { mode: 0o644 }) 110 | await save(defaultConfig(), configFile) 111 | } 112 | const statFile = await statAsync(configFile) 113 | if ((statFile.mode & 0o022) !== 0) { 114 | throw new Error(`Bad permissions for config file ${configFile}`) 115 | } 116 | 117 | return configFile 118 | } 119 | 120 | export class Config { 121 | store: Store 122 | prevState: State 123 | configPath: string 124 | 125 | constructor(params: { store: Store }) { 126 | const { store } = params 127 | this.store = store 128 | this.prevState = params.store.getState() 129 | this.configPath = '' 130 | } 131 | 132 | async setup(params: { configPath: string | null }) { 133 | this.configPath = 134 | params.configPath !== null 135 | ? params.configPath 136 | : await ensureDefaultConfigFile() 137 | 138 | log.info('config file is', this.configPath) 139 | const config = await load(this.configPath) 140 | const configState = configObjectToState(config) 141 | log.debug({ config: JSON.stringify(configState) }, 'loaded config') 142 | 143 | await dispatchConfigActions(configState, this.store) 144 | this.store.subscribe(() => { 145 | const state = this.store.getState() 146 | saveConfigIfNeeded(state, this.prevState, this.configPath) 147 | this.prevState = state 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /server/src/config/convert.ts: -------------------------------------------------------------------------------- 1 | import { find, flatten } from 'lodash' 2 | 3 | import type { AutoconnectConfig } from '@/server/autoconnect' 4 | import type { AutoforwardConfig } from '@/server/autoforward' 5 | import type { ForwardingConfig } from '@/server/forward' 6 | import type { HostConfig } from '@/server/host' 7 | 8 | import { parseForwardingSpec, serializeForwardingSpec } from './forwarding-spec' 9 | import type { 10 | ConfigConfigSchema, 11 | ConfigSchema, 12 | ForwardingSchema, 13 | HostSchema, 14 | } from './schema' 15 | import type { ConfigConfig, ConfigType } from './types' 16 | 17 | // object to state 18 | const makeForwardingState = ( 19 | forwarding: ForwardingSchema 20 | ): ForwardingConfig => { 21 | if (typeof forwarding === 'string') { 22 | return { spec: parseForwardingSpec(forwarding), label: '' } 23 | } 24 | 25 | const spec = parseForwardingSpec(forwarding.spec) 26 | const label = forwarding.label || '' 27 | 28 | return { spec, label } 29 | } 30 | 31 | const makeHostState = (id: string, h: HostSchema): HostConfig => { 32 | const host = h === null ? {} : h 33 | 34 | const sshHost = host.ssh && host.ssh.host ? host.ssh.host : id 35 | const sshConfig = host.ssh && host.ssh.config ? host.ssh.config : {} 36 | const ssh = { 37 | host: sshHost, 38 | config: sshConfig, 39 | } 40 | const label = host.label || '' 41 | 42 | return { ssh, label } 43 | } 44 | 45 | const makeAutoconnectState = (h: HostSchema): AutoconnectConfig => { 46 | const host = h === null ? {} : h 47 | 48 | const start = host.autostart || false 49 | const retry = host.autoretry || false 50 | return { start, retry } 51 | } 52 | 53 | const makeAutoforwardState = ( 54 | forwarding: ForwardingSchema 55 | ): AutoforwardConfig => { 56 | if (typeof forwarding === 'string') { 57 | return { start: false, retry: false } 58 | } 59 | 60 | const start = forwarding.autostart || false 61 | const retry = forwarding.autoretry || false 62 | return { start, retry } 63 | } 64 | 65 | const makeConfigState = (config: ConfigConfigSchema): ConfigConfig => ({ 66 | autosave: config.autosave || false, 67 | }) 68 | 69 | const mapKVArray = ( 70 | array: { [key: string]: T }[], 71 | func: (value: T, key: string) => any 72 | ) => { 73 | return array.map((x) => { 74 | const key = Object.keys(x)[0] 75 | const value = x[key] 76 | return func(value, key) 77 | }) 78 | } 79 | 80 | export const configObjectToState = (config: ConfigSchema): ConfigType => { 81 | const defaultConfigConfig = { autosave: false } 82 | if (config === null) { 83 | return { 84 | hosts: [], 85 | forwardings: [], 86 | autoconnects: [], 87 | autoforwards: [], 88 | config: defaultConfigConfig, 89 | } 90 | } 91 | 92 | const hosts = mapKVArray(config.hosts || [], (v, id) => ({ 93 | id, 94 | config: makeHostState(id, v), 95 | })) 96 | const forwardings = flatten( 97 | mapKVArray(config.hosts || [], (v, id) => 98 | mapKVArray((v || {}).forward || [], (vv, fwdId) => ({ 99 | id, 100 | fwdId, 101 | config: makeForwardingState(vv), 102 | })) 103 | ) 104 | ) 105 | const autoconnects = mapKVArray(config.hosts || [], (v, id) => ({ 106 | id, 107 | config: makeAutoconnectState(v), 108 | })) 109 | const autoforwards = flatten( 110 | mapKVArray(config.hosts || [], (v, id) => 111 | mapKVArray((v || {}).forward || [], (vv, fwdId) => ({ 112 | id, 113 | fwdId, 114 | config: makeAutoforwardState(vv), 115 | })) 116 | ) 117 | ) 118 | const configConfig = config.config 119 | ? makeConfigState(config.config) 120 | : defaultConfigConfig 121 | 122 | return { 123 | hosts, 124 | forwardings, 125 | autoconnects, 126 | autoforwards, 127 | config: configConfig, 128 | } 129 | } 130 | 131 | // state to object 132 | const makeForwardingObject = ( 133 | forwarding: ForwardingConfig, 134 | autoforward: AutoforwardConfig | null 135 | ): ForwardingSchema => { 136 | const result: any = {} 137 | 138 | if (forwarding.spec) { 139 | result.spec = serializeForwardingSpec(forwarding.spec) 140 | } 141 | if (forwarding.label) { 142 | result.label = forwarding.label 143 | } 144 | if (autoforward && autoforward.start) { 145 | result.autostart = true 146 | } 147 | if (autoforward && autoforward.retry) { 148 | result.autoretry = true 149 | } 150 | 151 | if (!result.label && !result.autostart && !result.autoretry) { 152 | return result.spec 153 | } 154 | 155 | return result 156 | } 157 | 158 | const makeSSHObject = (id: string, host: HostConfig) => { 159 | const ssh = { ...host.ssh } 160 | if (ssh.host === id) { 161 | // @ts-expect-error The operand of a 'delete' operator must be optional. 162 | delete ssh.host 163 | } 164 | if (Object.keys(ssh.config).length === 0) { 165 | // @ts-expect-error The operand of a 'delete' operator must be optional. 166 | delete ssh.config 167 | } 168 | 169 | return ssh 170 | } 171 | 172 | const makeHostObject = ( 173 | id: string, 174 | host: HostConfig, 175 | forwardings: { id: string; fwdId: string; config: ForwardingConfig }[], 176 | autoconnect: AutoconnectConfig | null, 177 | autoforwards: { id: string; fwdId: string; config: AutoforwardConfig }[] 178 | ): HostSchema => { 179 | const ssh = makeSSHObject(id, host) 180 | 181 | const forward = forwardings.map((forwarding) => { 182 | const autoforward = find(autoforwards, { id, fwdId: forwarding.fwdId }) 183 | const result: { [key: string]: ForwardingSchema } = {} 184 | result[forwarding.fwdId] = makeForwardingObject( 185 | forwarding.config, 186 | autoforward ? autoforward.config : null 187 | ) 188 | return result 189 | }) 190 | 191 | const result: HostSchema = {} 192 | 193 | if (host.label) { 194 | result.label = host.label 195 | } 196 | if (Object.keys(ssh).length > 0) { 197 | result.ssh = ssh 198 | } 199 | if (Object.keys(forward).length > 0) { 200 | result.forward = forward 201 | } 202 | if (autoconnect && autoconnect.start) { 203 | result.autostart = true 204 | } 205 | if (autoconnect && autoconnect.retry) { 206 | result.autoretry = true 207 | } 208 | 209 | return Object.keys(result).length > 0 ? result : null 210 | } 211 | 212 | const makeConfigObject = (config: ConfigConfig): ConfigConfigSchema => { 213 | const result = { ...config } 214 | if (!result.autosave) { 215 | // @ts-expect-error The operand of a 'delete' operator must be optional. 216 | delete result.autosave 217 | } 218 | return config 219 | } 220 | 221 | export const configStateToObject = (config: ConfigType): ConfigSchema => { 222 | const hosts = config.hosts.map((host) => { 223 | const forwardings = config.forwardings.filter((x) => x.id === host.id) 224 | const autoconnect = find(config.autoconnects, { id: host.id }) 225 | const result: { [key: string]: HostSchema | null } = {} 226 | result[host.id] = makeHostObject( 227 | host.id, 228 | host.config, 229 | forwardings, 230 | autoconnect ? autoconnect.config : null, 231 | config.autoforwards 232 | ) 233 | return result 234 | }) 235 | 236 | const c = makeConfigObject(config.config) 237 | 238 | const result = { 239 | hosts, 240 | config: c, 241 | } 242 | 243 | if (Object.keys(result.config).length === 0) { 244 | // @ts-expect-error The operand of a 'delete' operator must be optional. 245 | delete result.config 246 | } 247 | if (Object.keys(result.hosts).length === 0) { 248 | // @ts-expect-error The operand of a 'delete' operator must be optional. 249 | delete result.hosts 250 | } 251 | 252 | return result 253 | } 254 | -------------------------------------------------------------------------------- /server/src/config/forwarding-spec.ts: -------------------------------------------------------------------------------- 1 | import type { ForwardingSpec } from '@/server/forward' 2 | import { fwdTypes } from '@/server/forward' 3 | 4 | const token = '((?:\\S|(?:\\\\ ))+)' 5 | const dynamicRegex = new RegExp(`^(?:D|dynamic) +${token}$`) 6 | const localRegex = new RegExp(`^(?:L|local) +${token} +${token}$`) 7 | const remoteRegex = new RegExp(`^(?:R|remote) +${token} +${token}$`) 8 | const httpRegex = new RegExp(`^(?:H|http) +${token}$`) 9 | 10 | export const parseForwardingSpec = (str: string): ForwardingSpec => { 11 | if (dynamicRegex.test(str)) { 12 | const [, /**/ bind] = str.match(dynamicRegex) || ['', ''] 13 | return { type: fwdTypes.dynamic, bind } 14 | } 15 | if (localRegex.test(str)) { 16 | const [, /**/ bind, target] = str.match(localRegex) || ['', '', ''] 17 | return { type: fwdTypes.local, bind, target } 18 | } 19 | if (remoteRegex.test(str)) { 20 | const [, /**/ bind, target] = str.match(remoteRegex) || ['', '', ''] 21 | return { type: fwdTypes.remote, bind, target } 22 | } 23 | if (httpRegex.test(str)) { 24 | const [, /**/ target] = str.match(httpRegex) || ['', '', ''] 25 | return { type: fwdTypes.http, target } 26 | } 27 | 28 | throw Error(`invalid forwarding spec: ${JSON.stringify(str)}`) 29 | } 30 | 31 | export const serializeForwardingSpec = (spec: ForwardingSpec): string => { 32 | switch (spec.type) { 33 | case fwdTypes.dynamic: 34 | return `D ${spec.bind}` 35 | case fwdTypes.local: 36 | return `L ${spec.bind} ${spec.target}` 37 | case fwdTypes.remote: 38 | return `R ${spec.bind} ${spec.target}` 39 | case fwdTypes.http: 40 | return `H ${spec.target}` 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/config/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './component' 3 | export * from './reducer' 4 | export * from './types' 5 | -------------------------------------------------------------------------------- /server/src/config/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/server/types/redux' 2 | 3 | import { types } from './actions' 4 | import type { ConfigConfig } from './types' 5 | 6 | export type State = ConfigConfig 7 | 8 | const initialState = (): State => ({ 9 | autosave: false, 10 | }) 11 | 12 | export const reducer = ( 13 | state: State = initialState(), 14 | action: Action 15 | ): State => { 16 | switch (action.type) { 17 | case types.CONFIG_EDIT: { 18 | const { config } = action 19 | return { ...state, ...config } 20 | } 21 | default: 22 | return state 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/config/schema.ts: -------------------------------------------------------------------------------- 1 | import * as Joi from 'joi' 2 | 3 | const replaceIfNull = (schema: any, defoult: any) => 4 | Joi.alternatives().try( 5 | schema, 6 | Joi.any().valid(null).empty(null).default(defoult) 7 | ) 8 | 9 | const forwardingObject = Joi.object({ 10 | spec: Joi.string().required(), 11 | label: Joi.string(), 12 | autostart: Joi.boolean(), 13 | autoretry: Joi.boolean(), 14 | }) 15 | 16 | const forwarding = Joi.alternatives().try(forwardingObject, Joi.string()) 17 | 18 | const host = Joi.object({ 19 | ssh: Joi.object({ 20 | host: Joi.string(), 21 | config: Joi.object().pattern(/.*/, Joi.string()), 22 | }), 23 | forward: Joi.array() 24 | .items(Joi.object().pattern(/.*/, forwarding).length(1)) 25 | .unique((a, b) => Object.keys(a)[0] === Object.keys(b)[0]), 26 | label: Joi.string(), 27 | autostart: Joi.boolean(), 28 | autoretry: Joi.boolean(), 29 | }) 30 | 31 | const config = Joi.object({ 32 | autosave: Joi.boolean(), 33 | }) 34 | 35 | export const configSchema = Joi.object({ 36 | hosts: Joi.array() 37 | .items(Joi.object().pattern(/.*/, replaceIfNull(host, {})).length(1)) 38 | .unique((a, b) => Object.keys(a)[0] === Object.keys(b)[0]), 39 | config, 40 | }).default(null) // INFO hpello empty yaml file yields undefined 41 | 42 | export type ForwardingSchema = 43 | | { 44 | spec: string 45 | label?: string 46 | autostart?: boolean 47 | autoretry?: boolean 48 | } 49 | | string 50 | 51 | export type HostSchema = { 52 | ssh?: { 53 | host?: string 54 | config?: { [key: string]: string } 55 | } 56 | forward?: { [key: string]: ForwardingSchema }[] 57 | label?: string 58 | autostart?: boolean 59 | autoretry?: boolean 60 | } | null 61 | 62 | export interface ConfigConfigSchema { 63 | autosave?: boolean 64 | } 65 | 66 | export type ConfigSchema = { 67 | hosts?: { [key: string]: HostSchema }[] 68 | config?: ConfigConfigSchema 69 | } | null 70 | -------------------------------------------------------------------------------- /server/src/config/serialize.ts: -------------------------------------------------------------------------------- 1 | import { readFile, writeFile } from 'fs' 2 | import { dump as yamlDump, JSON_SCHEMA, load as yamlLoad } from 'js-yaml' 3 | import { promisify } from 'util' 4 | 5 | import { createLogger } from '@/server/log' 6 | 7 | import type { ConfigSchema } from './schema' 8 | import { configSchema } from './schema' 9 | 10 | const log = createLogger(__filename) 11 | 12 | const readFileAsync = promisify(readFile) 13 | const writeFileAsync = promisify(writeFile) 14 | 15 | export const load = async (path: string): Promise => { 16 | let data 17 | try { 18 | data = await readFileAsync(path) 19 | } catch (err) { 20 | log.error({ err }, 'error while reading config file:', path, { err }) 21 | return null 22 | } 23 | 24 | const object = yamlLoad(data.toString(), { schema: JSON_SCHEMA }) 25 | 26 | const validation = configSchema.validate(object) 27 | if (validation.error) { 28 | throw validation.error 29 | } 30 | 31 | return validation.value 32 | } 33 | 34 | export const save = async ( 35 | config: ConfigSchema, 36 | path: string 37 | ): Promise => { 38 | const validation = configSchema.validate(config) 39 | if (validation.error) { 40 | log.error({ err: validation.error }, 'error while saving config') 41 | return Promise.reject(validation.error) 42 | } 43 | 44 | const yaml = yamlDump(config, { schema: JSON_SCHEMA }).replace( 45 | /\n {0,2}[^ ]/g, 46 | '\n$&' 47 | ) // improve readability 48 | const header = 49 | '# Do not edit this file while SSHMon is running.\n# It will be overwritten on config change.\n\n' 50 | return writeFileAsync(path, `${header}${yaml}`) 51 | } 52 | -------------------------------------------------------------------------------- /server/src/config/types.ts: -------------------------------------------------------------------------------- 1 | import type { AutoconnectConfig } from '@/server/autoconnect' 2 | import type { AutoforwardConfig } from '@/server/autoforward' 3 | import type { ForwardingConfig } from '@/server/forward' 4 | import type { HostConfig } from '@/server/host' 5 | 6 | export type ConfigConfig = { 7 | autosave: boolean 8 | } 9 | 10 | export type ConfigType = { 11 | hosts: { id: string; config: HostConfig }[] 12 | forwardings: { id: string; fwdId: string; config: ForwardingConfig }[] 13 | autoconnects: { id: string; config: AutoconnectConfig }[] 14 | autoforwards: { id: string; fwdId: string; config: AutoforwardConfig }[] 15 | config: ConfigConfig 16 | } 17 | -------------------------------------------------------------------------------- /server/src/engine.ts: -------------------------------------------------------------------------------- 1 | import { API } from './api' 2 | import { Autoconnector } from './autoconnect' 3 | import { Autoforwarder } from './autoforward' 4 | import { Config } from './config' 5 | import { Forwarder } from './forward' 6 | import { createLogger } from './log' 7 | import { store } from './store' 8 | import { System } from './system' 9 | import { disconnectAllHosts } from './utils/disconnect-all-hosts' 10 | import { setupGracefulShutdown } from './utils/graceful-shutdown' 11 | 12 | const log = createLogger(__filename) 13 | 14 | export interface EngineOptions { 15 | configFile?: string 16 | } 17 | 18 | export class Engine { 19 | system = new System({ store }) 20 | forwarder = new Forwarder({ store }) 21 | autoconnector = new Autoconnector({ store }) 22 | autoforwarder = new Autoforwarder({ store }) 23 | config = new Config({ store }) 24 | api = new API({ store }) 25 | 26 | async start(options: EngineOptions) { 27 | this.system.setup() 28 | this.forwarder.setup() 29 | this.autoconnector.setup() 30 | this.autoforwarder.setup() 31 | await this.config.setup({ configPath: options.configFile || null }) 32 | 33 | this.api.setup() 34 | this.api.listen(8377, 'localhost') 35 | } 36 | 37 | async stop() { 38 | log.info('shutting down api...') 39 | await this.api.shutdown() 40 | log.info('disconnecting all hosts...') 41 | await disconnectAllHosts(store) 42 | log.info('engine stopped') 43 | } 44 | } 45 | 46 | export const start = (options: EngineOptions) => { 47 | const engine = new Engine() 48 | engine.start(options) 49 | setupGracefulShutdown(() => engine.stop()) 50 | } 51 | -------------------------------------------------------------------------------- /server/src/forward/actions.ts: -------------------------------------------------------------------------------- 1 | import type { ForwardingConfig, ForwardingParams } from './types' 2 | 3 | export enum types { 4 | FORWARDING_CREATE = 'FORWARDING_CREATE', 5 | FORWARDING_EDIT = 'FORWARDING_EDIT', 6 | FORWARDING_DELETE = 'FORWARDING_DELETE', 7 | FORWARDING_STATE_CHANGE = 'FORWARDING_STATE_CHANGE', 8 | } 9 | 10 | export type ForwardingStatus = 11 | | 'connecting' 12 | | 'connected' 13 | | 'disconnecting' 14 | | 'disconnected' 15 | | 'error' 16 | 17 | interface ForwardingCreateAction { 18 | type: types.FORWARDING_CREATE 19 | id: string 20 | fwdId: string 21 | config: ForwardingConfig 22 | } 23 | 24 | interface ForwardingEditAction { 25 | type: types.FORWARDING_EDIT 26 | id: string 27 | fwdId: string 28 | config: ForwardingConfig 29 | } 30 | 31 | interface ForwardingDeleteAction { 32 | type: types.FORWARDING_DELETE 33 | id: string 34 | fwdId: string 35 | } 36 | 37 | interface ForwardingStateChangeAction { 38 | type: types.FORWARDING_STATE_CHANGE 39 | id: string 40 | fwdId: string 41 | status: ForwardingStatus 42 | params?: ForwardingParams 43 | reason?: string 44 | } 45 | 46 | export type Action = 47 | | ForwardingCreateAction 48 | | ForwardingEditAction 49 | | ForwardingDeleteAction 50 | | ForwardingStateChangeAction 51 | 52 | export const actions = { 53 | forwardingCreate: ( 54 | id: string, 55 | fwdId: string, 56 | config: ForwardingConfig 57 | ): Action => ({ type: types.FORWARDING_CREATE, id, fwdId, config }), 58 | forwardingEdit: ( 59 | id: string, 60 | fwdId: string, 61 | config: ForwardingConfig 62 | ): Action => ({ type: types.FORWARDING_EDIT, id, fwdId, config }), 63 | forwardingDelete: (id: string, fwdId: string): Action => ({ 64 | type: types.FORWARDING_DELETE, 65 | id, 66 | fwdId, 67 | }), 68 | 69 | forwardingConnecting: ( 70 | id: string, 71 | fwdId: string, 72 | params: ForwardingParams, 73 | reason: string 74 | ): Action => ({ 75 | type: types.FORWARDING_STATE_CHANGE, 76 | id, 77 | fwdId, 78 | status: 'connecting', 79 | params, 80 | reason, 81 | }), 82 | forwardingConnected: (id: string, fwdId: string): Action => ({ 83 | type: types.FORWARDING_STATE_CHANGE, 84 | id, 85 | fwdId, 86 | status: 'connected', 87 | }), 88 | forwardingDisconnecting: ( 89 | id: string, 90 | fwdId: string, 91 | reason: string 92 | ): Action => ({ 93 | type: types.FORWARDING_STATE_CHANGE, 94 | id, 95 | fwdId, 96 | status: 'disconnecting', 97 | reason, 98 | }), 99 | forwardingDisconnected: (id: string, fwdId: string): Action => ({ 100 | type: types.FORWARDING_STATE_CHANGE, 101 | id, 102 | fwdId, 103 | status: 'disconnected', 104 | }), 105 | forwardingError: (id: string, fwdId: string): Action => ({ 106 | type: types.FORWARDING_STATE_CHANGE, 107 | id, 108 | fwdId, 109 | status: 'error', 110 | }), 111 | } 112 | -------------------------------------------------------------------------------- /server/src/forward/component.ts: -------------------------------------------------------------------------------- 1 | import type { State, Store } from '@/server/types/redux' 2 | 3 | import { onStateChange } from './utils' 4 | 5 | export class Forwarder { 6 | store: Store 7 | prevState: State 8 | 9 | constructor(params: { store: Store }) { 10 | this.store = params.store 11 | this.prevState = params.store.getState() 12 | } 13 | 14 | setup() { 15 | this.store.subscribe(() => { 16 | const state = this.store.getState() 17 | 18 | process.nextTick(() => { 19 | // prevent recursion 20 | onStateChange(this.prevState, state, this.store.dispatch) 21 | this.prevState = state 22 | }) 23 | }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/src/forward/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './component' 3 | export * from './reducer' 4 | export * from './thunks' 5 | export * from './types' 6 | -------------------------------------------------------------------------------- /server/src/forward/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/server/types/redux' 2 | 3 | import type { ForwardingStatus } from './actions' 4 | import { types } from './actions' 5 | import type { ForwardingConfig, ForwardingParams } from './types' 6 | 7 | export type ForwardingSubState = { 8 | status: ForwardingStatus 9 | params: ForwardingParams | null 10 | reason: string | null 11 | } 12 | 13 | export interface ForwardingState { 14 | id: string 15 | fwdId: string 16 | config: ForwardingConfig 17 | state: ForwardingSubState 18 | } 19 | 20 | export type State = ForwardingState[] 21 | 22 | const initialState = (): State => [] 23 | 24 | const defaultSubState = (): ForwardingSubState => ({ 25 | status: 'disconnected', 26 | params: null, 27 | reason: null, 28 | }) 29 | 30 | export const reducer = ( 31 | state: State = initialState(), 32 | action: Action 33 | ): State => { 34 | switch (action.type) { 35 | case types.FORWARDING_CREATE: { 36 | const { id, fwdId, config } = action 37 | 38 | const forwardingState = { 39 | id, 40 | fwdId, 41 | config, 42 | state: defaultSubState(), 43 | } 44 | 45 | return [...state, forwardingState] 46 | } 47 | case types.FORWARDING_EDIT: { 48 | const { id, fwdId, config } = action 49 | return state.map((x) => 50 | x.id === id && x.fwdId === fwdId ? { ...x, config } : x 51 | ) 52 | } 53 | case types.FORWARDING_DELETE: { 54 | const { id, fwdId } = action 55 | return state.filter((x) => x.id !== id || x.fwdId !== fwdId) 56 | } 57 | case types.FORWARDING_STATE_CHANGE: { 58 | const { id, fwdId, status, params, reason } = action 59 | return state.map((forwardingState) => { 60 | if (forwardingState.id !== id || forwardingState.fwdId !== fwdId) { 61 | return forwardingState 62 | } 63 | 64 | return { 65 | ...forwardingState, 66 | state: { 67 | status, 68 | params: params || forwardingState.state.params, 69 | reason: reason || forwardingState.state.reason, 70 | }, 71 | } 72 | }) 73 | } 74 | default: 75 | return state 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /server/src/forward/ssh.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | 3 | import { createLogger } from '@/server/log' 4 | 5 | import type { ForwardingParams } from './types' 6 | import { fwdTypes } from './types' 7 | 8 | const log = createLogger(__filename) 9 | 10 | const makeForwardingString = (params: ForwardingParams): string => { 11 | switch (params.type) { 12 | case fwdTypes.dynamic: 13 | return `-D${params.bind}` 14 | case fwdTypes.local: 15 | return `-L${params.bind}:${params.target}` 16 | case fwdTypes.remote: 17 | return `-R${params.bind}:${params.target}` 18 | } 19 | } 20 | 21 | function appendMulti(array1: T[], array2: T[]) { 22 | Array.prototype.push.apply(array1, array2) 23 | } 24 | 25 | // FIXME hpello duplicated code 26 | function spawnAndLog(sshCommand: string, args: string[]) { 27 | const process = spawn(sshCommand, args, { detached: true }) 28 | 29 | const processLog = log.child({ childPid: process.pid }) 30 | processLog.debug([sshCommand].concat(args).join(' ')) 31 | process.stdout.on('data', (data) => 32 | processLog.debug({ stream: 'stdout' }, data.toString().trim()) 33 | ) 34 | process.stderr.on('data', (data) => 35 | processLog.error({ stream: 'stderr' }, data.toString().trim()) 36 | ) 37 | process.on('error', (err) => processLog.error({ err, event: 'error' })) 38 | process.on('exit', (code, signal) => 39 | processLog.debug({ event: 'exit', code, signal }, 'process exited') 40 | ) 41 | 42 | return process 43 | } 44 | 45 | type ControlCommand = 'check' | 'forward' | 'cancel' | 'exit' | 'stop' 46 | 47 | export function executeSshControlCommand(params: { 48 | sshCommand: string 49 | controlPath: string 50 | controlCommand: ControlCommand 51 | forwardingParams: ForwardingParams | null 52 | }) { 53 | const { sshCommand, controlPath, controlCommand, forwardingParams } = params 54 | 55 | const args = [] as string[] 56 | appendMulti(args, ['-S', controlPath]) 57 | appendMulti(args, ['-O', controlCommand]) 58 | if (forwardingParams !== null) { 59 | appendMulti(args, [makeForwardingString(forwardingParams)]) 60 | } 61 | args.push('sshmon-host') 62 | 63 | return spawnAndLog(sshCommand, args) 64 | } 65 | -------------------------------------------------------------------------------- /server/src/forward/thunks/connect-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/forward/actions' 4 | import { executeSshControlCommand } from '@/server/forward/ssh' 5 | import type { ForwardingParams, ForwardingSpec } from '@/server/forward/types' 6 | import { fwdTypes } from '@/server/forward/types' 7 | import { createLogger } from '@/server/log' 8 | import type { AsyncThunkAction, Dispatch, GetState } from '@/server/types/redux' 9 | import { ErrorWithCode } from '@/server/utils/error-with-code' 10 | import { makeTmpPath } from '@/server/utils/tmp' 11 | 12 | const log = createLogger(__filename) 13 | 14 | // FIXME hpello put this outside and add some tests 15 | const defaultLocalHost = 'localhost' 16 | const defaultRemoteHost = 'localhost' 17 | 18 | const makeAddress = (address: string, defaultHost: string) => { 19 | // unix socket 20 | if (address.includes('/')) { 21 | return address 22 | } 23 | 24 | // host:port 25 | if (address.includes(':')) { 26 | return address 27 | } 28 | 29 | const port = parseInt(address, 10) 30 | if (!isNaN(port)) { 31 | return `${defaultHost}:${port}` 32 | } 33 | 34 | return null 35 | } 36 | 37 | const makeForwardingParams = async ( 38 | id: string, 39 | fwdId: string, 40 | spec: ForwardingSpec 41 | ): Promise => { 42 | switch (spec.type) { 43 | case fwdTypes.dynamic: 44 | return spec 45 | case fwdTypes.local: { 46 | const target = makeAddress(spec.target, defaultRemoteHost) 47 | if (target === null) { 48 | throw new ErrorWithCode( 49 | 400, 50 | `invalid forwarding spec: ${JSON.stringify(spec)}` 51 | ) 52 | } 53 | return { ...spec, target } 54 | } 55 | case fwdTypes.remote: { 56 | const target = makeAddress(spec.target, defaultLocalHost) 57 | if (target === null) { 58 | throw new ErrorWithCode( 59 | 400, 60 | `invalid forwarding spec: ${JSON.stringify(spec)}` 61 | ) 62 | } 63 | return { ...spec, target } 64 | } 65 | case fwdTypes.http: { 66 | const bind = await makeTmpPath(__filename)(JSON.stringify({ id, fwdId })) 67 | const target = makeAddress(spec.target, defaultRemoteHost) 68 | if (target === null) { 69 | throw new ErrorWithCode( 70 | 400, 71 | `invalid forwarding spec: ${JSON.stringify(spec)}` 72 | ) 73 | } 74 | return { type: fwdTypes.local, bind, target } 75 | } 76 | } 77 | } 78 | 79 | export const forwardingConnect = 80 | (id: string, fwdId: string, reason: string): AsyncThunkAction => 81 | async (dispatch: Dispatch, getState: GetState) => { 82 | const state = getState() 83 | const forwarding = find(state.forwardings, { id, fwdId }) 84 | if (!forwarding) { 85 | throw new ErrorWithCode(404, `forwarding not found: ${id}/${fwdId}`) 86 | } 87 | 88 | const h = find(state.hosts, { id }) 89 | if (!h) { 90 | throw new ErrorWithCode(404, `host not found: ${id}`) 91 | } 92 | 93 | if (h.state.status !== 'connected') { 94 | throw new ErrorWithCode(400, `host is not connected: ${id}`) 95 | } 96 | if (h.state.controlPath === null) { 97 | throw new ErrorWithCode( 98 | 500, 99 | `bad state: ${id} (${JSON.stringify(h.state)})` 100 | ) 101 | } 102 | 103 | const params = await makeForwardingParams(id, fwdId, forwarding.config.spec) 104 | 105 | const process = executeSshControlCommand({ 106 | sshCommand: 'ssh', 107 | controlPath: h.state.controlPath, 108 | controlCommand: 'forward', 109 | forwardingParams: params, 110 | }) 111 | dispatch(actions.forwardingConnecting(id, fwdId, params, reason)) 112 | 113 | process.on('exit', (code) => { 114 | dispatch( 115 | code === 0 116 | ? actions.forwardingConnected(id, fwdId) 117 | : actions.forwardingError(id, fwdId) 118 | ) 119 | }) 120 | } 121 | 122 | export const forwardingDisconnect = 123 | (id: string, fwdId: string, reason: string): AsyncThunkAction => 124 | (dispatch: Dispatch, getState: GetState) => { 125 | const state = getState() 126 | const forwarding = find(state.forwardings, { id, fwdId }) 127 | if (!forwarding) { 128 | throw new ErrorWithCode(404, `forwarding not found: ${id}/${fwdId}`) 129 | } 130 | 131 | const h = find(state.hosts, { id }) 132 | if (!h) { 133 | throw new ErrorWithCode(404, `host not found: ${id}`) 134 | } 135 | 136 | if (h.state.status !== 'connected') { 137 | throw new ErrorWithCode(400, `host is not connected: ${id}`) 138 | } 139 | if (h.state.controlPath === null) { 140 | throw new ErrorWithCode( 141 | 500, 142 | `bad state: ${id} (${JSON.stringify(h.state)})` 143 | ) 144 | } 145 | 146 | if (!['connecting', 'connected'].includes(forwarding.state.status)) { 147 | log.info(`forwarding is not connected: ${id}/${fwdId}`) 148 | return Promise.resolve() 149 | } 150 | if (forwarding.state.params === null) { 151 | throw new ErrorWithCode( 152 | 500, 153 | `bad state: ${id}/${fwdId} (${JSON.stringify(forwarding.state)})` 154 | ) 155 | } 156 | 157 | const process = executeSshControlCommand({ 158 | sshCommand: 'ssh', 159 | controlPath: h.state.controlPath, 160 | controlCommand: 'cancel', 161 | forwardingParams: forwarding.state.params, 162 | }) 163 | dispatch(actions.forwardingDisconnecting(id, fwdId, reason)) 164 | 165 | process.on('exit', (code) => { 166 | dispatch( 167 | code === 0 168 | ? actions.forwardingDisconnected(id, fwdId) 169 | : actions.forwardingError(id, fwdId) 170 | ) 171 | }) 172 | 173 | return Promise.resolve() 174 | } 175 | -------------------------------------------------------------------------------- /server/src/forward/thunks/create-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/forward/actions' 4 | import type { ForwardingConfig } from '@/server/forward/types' 5 | import { createLogger } from '@/server/log' 6 | import type { AsyncThunkAction, Dispatch, GetState } from '@/server/types/redux' 7 | import { ErrorWithCode } from '@/server/utils/error-with-code' 8 | 9 | const log = createLogger(__filename) 10 | 11 | export const forwardingCreate = 12 | (id: string, fwdId: string, config: ForwardingConfig): AsyncThunkAction => 13 | async (dispatch: Dispatch, getState: GetState) => { 14 | const state = getState() 15 | const h = find(state.hosts, { id }) 16 | if (!h) { 17 | log.info(`host not found: ${id}`) 18 | return 19 | } 20 | 21 | const forwarding = find(state.forwardings, { id, fwdId }) 22 | if (forwarding) { 23 | throw new ErrorWithCode(409, `forwarding already exists: ${id}/${fwdId}`) 24 | } 25 | 26 | dispatch(actions.forwardingCreate(id, fwdId, config)) 27 | } 28 | 29 | export const forwardingEdit = 30 | (id: string, fwdId: string, config: ForwardingConfig): AsyncThunkAction => 31 | async (dispatch: Dispatch, getState: GetState) => { 32 | const state = getState() 33 | const h = find(state.hosts, { id }) 34 | if (!h) { 35 | log.info(`host not found: ${id}`) 36 | return 37 | } 38 | 39 | const forwarding = find(state.forwardings, { id, fwdId }) 40 | if (!forwarding) { 41 | throw new ErrorWithCode(404, `forwarding not found: ${id}/${fwdId}`) 42 | } 43 | 44 | dispatch(actions.forwardingEdit(id, fwdId, config)) 45 | } 46 | 47 | export const forwardingDelete = 48 | (id: string, fwdId: string): AsyncThunkAction => 49 | async (dispatch: Dispatch, getState: GetState) => { 50 | const state = getState() 51 | const forwarding = find(state.forwardings, { id, fwdId }) 52 | if (!forwarding) { 53 | log.info(`forwarding not found: ${id}/${fwdId}`) 54 | return 55 | } 56 | 57 | dispatch(actions.forwardingDelete(id, fwdId)) 58 | } 59 | -------------------------------------------------------------------------------- /server/src/forward/thunks/index.ts: -------------------------------------------------------------------------------- 1 | import { forwardingConnect, forwardingDisconnect } from './connect-thunks' 2 | import { 3 | forwardingCreate, 4 | forwardingDelete, 5 | forwardingEdit, 6 | } from './create-thunks' 7 | 8 | export const thunks = { 9 | forwardingCreate, 10 | forwardingEdit, 11 | forwardingDelete, 12 | forwardingConnect, 13 | forwardingDisconnect, 14 | } 15 | -------------------------------------------------------------------------------- /server/src/forward/types.ts: -------------------------------------------------------------------------------- 1 | export enum fwdTypes { 2 | dynamic = 'dynamic', 3 | local = 'local', 4 | remote = 'remote', 5 | http = 'http', 6 | } 7 | 8 | export type ForwardingSpec = 9 | | { type: fwdTypes.dynamic; bind: string } 10 | | { type: fwdTypes.local | fwdTypes.remote; bind: string; target: string } 11 | | { type: fwdTypes.http; target: string } 12 | 13 | export type ForwardingConfig = { 14 | spec: ForwardingSpec 15 | label: string 16 | } 17 | 18 | export type ForwardingParams = 19 | | { type: fwdTypes.dynamic; bind: string } 20 | | { type: fwdTypes.local | fwdTypes.remote; bind: string; target: string } 21 | -------------------------------------------------------------------------------- /server/src/forward/utils.ts: -------------------------------------------------------------------------------- 1 | import { find, flatten, some } from 'lodash' 2 | 3 | import type { HostStatus } from '@/server/host' 4 | import type { Dispatch, State } from '@/server/types/redux' 5 | 6 | import { actions } from './actions' 7 | import type { ForwardingState } from './reducer' 8 | 9 | const getStatus = (state: State, id: string): HostStatus | null => { 10 | const host = find(state.hosts, { id }) 11 | return !host ? null : host.state.status 12 | } 13 | 14 | const getForwardings = (state: State, id: string): ForwardingState[] => { 15 | return state.forwardings.filter((x) => x.id === id) 16 | } 17 | 18 | const hostExist = (state: State, id: string): boolean => 19 | some(state.hosts, { id }) 20 | 21 | const cancelForwardings = ( 22 | prevState: State, 23 | state: State, 24 | dispatch: Dispatch 25 | ) => { 26 | flatten( 27 | state.hosts 28 | .map((x) => x.id) 29 | .filter((id) => getStatus(prevState, id) !== 'disconnected') 30 | .filter((id) => getStatus(state, id) === 'disconnected') 31 | .map((id) => getForwardings(state, id)) 32 | ).forEach(({ id, fwdId }) => { 33 | dispatch(actions.forwardingDisconnected(id, fwdId)) 34 | }) 35 | 36 | flatten( 37 | state.hosts 38 | .map((x) => x.id) 39 | .filter((id) => getStatus(prevState, id) !== 'error') 40 | .filter((id) => getStatus(state, id) === 'error') 41 | .map((id) => getForwardings(state, id)) 42 | ).forEach(({ id, fwdId }) => { 43 | dispatch(actions.forwardingDisconnected(id, fwdId)) 44 | }) 45 | } 46 | 47 | const cleanupForwardings = ( 48 | prevState: State, 49 | state: State, 50 | dispatch: Dispatch 51 | ) => { 52 | flatten( 53 | state.hosts 54 | .map((x) => x.id) 55 | .filter((id) => hostExist(prevState, id)) 56 | .filter((id) => !hostExist(state, id)) 57 | .map((id) => getForwardings(state, id)) 58 | ).forEach(({ id, fwdId }) => { 59 | dispatch(actions.forwardingDelete(id, fwdId)) 60 | }) 61 | } 62 | 63 | export const onStateChange = ( 64 | prevState: State, 65 | state: State, 66 | dispatch: Dispatch 67 | ) => { 68 | cancelForwardings(prevState, state, dispatch) 69 | cleanupForwardings(prevState, state, dispatch) 70 | } 71 | -------------------------------------------------------------------------------- /server/src/host/actions.ts: -------------------------------------------------------------------------------- 1 | import type { HostConfig } from './types' 2 | 3 | export enum types { 4 | HOST_CREATE = 'HOST_CREATE', 5 | HOST_EDIT = 'HOST_EDIT', 6 | HOST_DELETE = 'HOST_DELETE', 7 | HOST_STATE_CHANGE = 'HOST_STATE_CHANGE', 8 | } 9 | 10 | export type HostStatus = 11 | | 'connecting' 12 | | 'connected' 13 | | 'disconnecting' 14 | | 'disconnected' 15 | | 'error' 16 | 17 | interface HostCreateAction { 18 | type: types.HOST_CREATE 19 | id: string 20 | config: HostConfig 21 | } 22 | 23 | interface HostEditAction { 24 | type: types.HOST_EDIT 25 | id: string 26 | config: HostConfig 27 | } 28 | 29 | interface HostDeleteAction { 30 | type: types.HOST_DELETE 31 | id: string 32 | } 33 | 34 | interface HostStateChangeAction { 35 | type: types.HOST_STATE_CHANGE 36 | id: string 37 | status: HostStatus 38 | pid?: number 39 | controlPath?: string 40 | time?: Date 41 | reason?: string 42 | } 43 | 44 | export type Action = 45 | | HostCreateAction 46 | | HostEditAction 47 | | HostDeleteAction 48 | | HostStateChangeAction 49 | 50 | export const actions = { 51 | hostCreate: (id: string, config: HostConfig): Action => ({ 52 | type: types.HOST_CREATE, 53 | id, 54 | config, 55 | }), 56 | hostEdit: (id: string, config: HostConfig): Action => ({ 57 | type: types.HOST_EDIT, 58 | id, 59 | config, 60 | }), 61 | hostDelete: (id: string): Action => ({ type: types.HOST_DELETE, id }), 62 | 63 | hostStateConnecting: ( 64 | id: string, 65 | pid: number, 66 | controlPath: string, 67 | reason: string 68 | ): Action => ({ 69 | type: types.HOST_STATE_CHANGE, 70 | id, 71 | status: 'connecting', 72 | pid, 73 | controlPath, 74 | reason, 75 | }), 76 | hostStateConnected: (id: string, time: Date): Action => ({ 77 | type: types.HOST_STATE_CHANGE, 78 | id, 79 | status: 'connected', 80 | time, 81 | }), 82 | hostStateDisconnecting: (id: string, reason: string): Action => ({ 83 | type: types.HOST_STATE_CHANGE, 84 | id, 85 | status: 'disconnecting', 86 | reason, 87 | }), 88 | hostStateDisconnected: (id: string): Action => ({ 89 | type: types.HOST_STATE_CHANGE, 90 | id, 91 | status: 'disconnected', 92 | }), 93 | hostStateError: (id: string): Action => ({ 94 | type: types.HOST_STATE_CHANGE, 95 | id, 96 | status: 'error', 97 | }), 98 | } 99 | -------------------------------------------------------------------------------- /server/src/host/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './reducer' 3 | export * from './thunks' 4 | export * from './types' 5 | -------------------------------------------------------------------------------- /server/src/host/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/server/types/redux' 2 | 3 | import type { HostStatus } from './actions' 4 | import { types } from './actions' 5 | import type { HostConfig } from './types' 6 | 7 | export interface HostSubState { 8 | status: HostStatus 9 | pid: number | null 10 | controlPath: string | null 11 | time: Date | null 12 | reason: string | null 13 | } 14 | 15 | export interface HostState { 16 | id: string 17 | config: HostConfig 18 | state: HostSubState 19 | } 20 | 21 | export type State = HostState[] 22 | 23 | const initialState = (): State => [] 24 | 25 | const defaultSubState = (): HostSubState => ({ 26 | status: 'disconnected', 27 | pid: null, 28 | controlPath: null, 29 | time: null, 30 | reason: null, 31 | }) 32 | 33 | export const reducer = ( 34 | state: State = initialState(), 35 | action: Action 36 | ): State => { 37 | switch (action.type) { 38 | case types.HOST_CREATE: { 39 | const { id, config } = action 40 | 41 | const hostState = { 42 | id, 43 | config, 44 | state: defaultSubState(), 45 | } 46 | 47 | return [...state, hostState] 48 | } 49 | case types.HOST_EDIT: { 50 | const { id, config } = action 51 | return state.map((x) => (x.id === id ? { ...x, config } : x)) 52 | } 53 | case types.HOST_DELETE: { 54 | const { id } = action 55 | return state.filter((x) => x.id !== id) 56 | } 57 | case types.HOST_STATE_CHANGE: { 58 | const { id, status, pid, controlPath, time, reason } = action 59 | 60 | return state.map((hostState) => { 61 | if (hostState.id !== id) { 62 | return hostState 63 | } 64 | 65 | return { 66 | ...hostState, 67 | state: { 68 | status, 69 | pid: pid || hostState.state.pid, 70 | controlPath: controlPath || hostState.state.controlPath, 71 | time: time || hostState.state.time, 72 | reason: reason || hostState.state.reason, 73 | }, 74 | } 75 | }) 76 | } 77 | default: 78 | return state 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /server/src/host/ssh.ts: -------------------------------------------------------------------------------- 1 | import { spawn } from 'child_process' 2 | 3 | import { createLogger } from '@/server/log' 4 | 5 | const log = createLogger(__filename) 6 | 7 | interface SSHParams { 8 | host: string 9 | config: { [key: string]: string } 10 | } 11 | 12 | const defaultMasterOptions = [ 13 | '-N', 14 | '-T', 15 | '-M', 16 | '-o', 17 | 'ControlPersist=no', 18 | '-o', 19 | 'BatchMode=yes', 20 | '-o', 21 | 'StreamLocalBindUnlink=yes', 22 | '-o', 23 | 'ServerAliveInterval=5', 24 | '-o', 25 | 'ServerAliveCountMax=3', 26 | ] 27 | 28 | function appendMulti(array1: T[], array2: T[]) { 29 | Array.prototype.push.apply(array1, array2) 30 | } 31 | 32 | // FIXME hpello duplicated code 33 | function spawnAndLog(sshCommand: string, args: string[]) { 34 | const process = spawn(sshCommand, args, { detached: true }) 35 | 36 | const processLog = log.child({ childPid: process.pid }) 37 | processLog.debug([sshCommand].concat(args).join(' ')) 38 | process.stdout.on('data', (data) => 39 | processLog.debug({ stream: 'stdout' }, data.toString().trim()) 40 | ) 41 | process.stderr.on('data', (data) => 42 | processLog.error({ stream: 'stderr' }, data.toString().trim()) 43 | ) 44 | process.on('error', (err) => processLog.error({ err, event: 'error' })) 45 | process.on('exit', (code, signal) => 46 | processLog.debug({ event: 'exit', code, signal }, 'process exited') 47 | ) 48 | 49 | return process 50 | } 51 | 52 | export function spawnSshMaster(params: { 53 | sshCommand: string 54 | controlPath: string 55 | sshParams: SSHParams 56 | }) { 57 | const { sshCommand, controlPath, sshParams } = params 58 | 59 | const args = defaultMasterOptions.concat() 60 | appendMulti(args, ['-S', controlPath]) 61 | 62 | const { host, config } = sshParams 63 | Object.keys(config) 64 | .filter((k) => k.toLowerCase() !== 'controlpath') 65 | .forEach((key) => { 66 | appendMulti(args, ['-o', `${key}=${config[key]}`]) 67 | }) 68 | args.push(host) 69 | 70 | return spawnAndLog(sshCommand, args) 71 | } 72 | -------------------------------------------------------------------------------- /server/src/host/thunks/connect-thunks.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'chokidar' 2 | import { access } from 'fs' 3 | import { find } from 'lodash' 4 | import { promisify } from 'util' 5 | 6 | import { actions } from '@/server/host/actions' 7 | import { spawnSshMaster } from '@/server/host/ssh' 8 | import type { HostConfig } from '@/server/host/types' 9 | import { createLogger } from '@/server/log' 10 | import type { AsyncThunkAction, Dispatch, GetState } from '@/server/types/redux' 11 | import { ErrorWithCode } from '@/server/utils/error-with-code' 12 | import { makeTmpPath } from '@/server/utils/tmp' 13 | 14 | const log = createLogger(__filename) 15 | 16 | const extractConfigControlPath = (hostConfig: HostConfig): string | null => { 17 | const sshConfig = hostConfig.ssh.config 18 | const key = Object.keys(sshConfig).find( 19 | (k) => k.toLowerCase() === 'controlpath' 20 | ) 21 | return key ? sshConfig[key] : null 22 | } 23 | 24 | const accessAsync = promisify(access) 25 | const fileExists = (path: string): Promise => 26 | accessAsync(path) 27 | .then(() => true) 28 | .catch(() => false) 29 | 30 | export const hostConnect = 31 | (id: string, reason: string): AsyncThunkAction => 32 | async (dispatch: Dispatch, getState: GetState) => { 33 | const state = getState() 34 | const host = find(state.hosts, { id }) 35 | if (!host) { 36 | throw new ErrorWithCode(404, `host not found: ${id}`) 37 | } 38 | 39 | if (!['disconnected', 'error'].includes(host.state.status)) { 40 | log.info(`host not down: ${id}`) 41 | return 42 | } 43 | 44 | const controlPath = 45 | extractConfigControlPath(host.config) || 46 | (await makeTmpPath(__filename)(id)) 47 | 48 | if (await fileExists(controlPath)) { 49 | log.error(`control path already exists: ${controlPath}`) 50 | dispatch(actions.hostStateError(id)) 51 | return 52 | } 53 | 54 | const process = spawnSshMaster({ 55 | sshCommand: 'ssh', 56 | controlPath, 57 | sshParams: host.config.ssh, 58 | }) 59 | // @ts-expect-error Argument of type 'number | undefined' is not assignable to parameter of type 'number'. 60 | dispatch(actions.hostStateConnecting(id, process.pid, controlPath, reason)) 61 | 62 | const watcher = watch(controlPath, { useFsEvents: false }) 63 | .on('error', (err) => log.error({ err }, 'watcher error')) 64 | .once('add', () => { 65 | watcher.close() 66 | dispatch(actions.hostStateConnected(id, new Date())) 67 | }) 68 | 69 | process.on('exit', (code) => { 70 | watcher.close() 71 | dispatch( 72 | code === 0 || code === null 73 | ? actions.hostStateDisconnected(id) 74 | : actions.hostStateError(id) 75 | ) 76 | }) 77 | } 78 | 79 | export const hostDisconnect = 80 | (id: string, reason: string): AsyncThunkAction => 81 | (dispatch: Dispatch, getState: GetState) => { 82 | const state = getState() 83 | const host = find(state.hosts, { id }) 84 | if (!host) { 85 | throw new ErrorWithCode(404, `host not found: ${id}`) 86 | } 87 | 88 | if (!['connecting', 'connected'].includes(host.state.status)) { 89 | log.info('host is not connected:', id) 90 | return Promise.resolve() 91 | } 92 | if (host.state.pid === null) { 93 | throw new ErrorWithCode( 94 | 500, 95 | `bad state: ${id} (${JSON.stringify(host.state)})` 96 | ) 97 | } 98 | 99 | process.kill(host.state.pid) 100 | dispatch(actions.hostStateDisconnecting(id, reason)) 101 | 102 | return Promise.resolve() 103 | } 104 | -------------------------------------------------------------------------------- /server/src/host/thunks/create-thunks.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | 3 | import { actions } from '@/server/host/actions' 4 | import type { HostConfig } from '@/server/host/types' 5 | import { createLogger } from '@/server/log' 6 | import type { AsyncThunkAction, Dispatch, GetState } from '@/server/types/redux' 7 | import { ErrorWithCode } from '@/server/utils/error-with-code' 8 | 9 | const log = createLogger(__filename) 10 | 11 | export const hostCreate = 12 | (id: string, config: HostConfig): AsyncThunkAction => 13 | async (dispatch: Dispatch, getState: GetState) => { 14 | const state = getState() 15 | const host = find(state.hosts, { id }) 16 | if (host) { 17 | throw new ErrorWithCode(409, `host already exists: ${id}`) 18 | } 19 | 20 | dispatch(actions.hostCreate(id, config)) 21 | } 22 | 23 | export const hostEdit = 24 | (id: string, config: HostConfig): AsyncThunkAction => 25 | async (dispatch: Dispatch, getState: GetState) => { 26 | const state = getState() 27 | const host = find(state.hosts, { id }) 28 | if (!host) { 29 | throw new ErrorWithCode(404, `host not found: ${id}`) 30 | } 31 | 32 | dispatch(actions.hostEdit(id, config)) 33 | } 34 | 35 | export const hostDelete = 36 | (id: string): AsyncThunkAction => 37 | async (dispatch: Dispatch, getState: GetState) => { 38 | const state = getState() 39 | const host = find(state.hosts, { id }) 40 | if (!host) { 41 | log.info(`host not found: ${id}`) 42 | return 43 | } 44 | if (!['disconnected', 'error'].includes(host.state.status)) { 45 | throw new ErrorWithCode(400, `host not down: ${id}`) 46 | } 47 | 48 | dispatch(actions.hostDelete(id)) 49 | } 50 | -------------------------------------------------------------------------------- /server/src/host/thunks/index.ts: -------------------------------------------------------------------------------- 1 | import { hostConnect, hostDisconnect } from './connect-thunks' 2 | import { hostCreate, hostDelete, hostEdit } from './create-thunks' 3 | 4 | export const thunks = { 5 | hostCreate, 6 | hostEdit, 7 | hostDelete, 8 | hostConnect, 9 | hostDisconnect, 10 | } 11 | -------------------------------------------------------------------------------- /server/src/host/types.ts: -------------------------------------------------------------------------------- 1 | export type HostConfig = { 2 | ssh: { 3 | host: string 4 | config: { [key: string]: string } 5 | } 6 | label: string 7 | } 8 | -------------------------------------------------------------------------------- /server/src/log/index.ts: -------------------------------------------------------------------------------- 1 | import { createLogger as bunyanCreateLogger, stdSerializers } from 'bunyan' 2 | import { spawn } from 'child_process' 3 | import { dirname, format, isAbsolute, join, parse, relative } from 'path' 4 | 5 | // spawn a bunyan process 6 | const bunyanBin = join(__dirname, '../../node_modules/bunyan/bin/bunyan') 7 | const bunyanOpts = ['--level', 'info'] 8 | if (process.stderr.isTTY) { 9 | bunyanOpts.push('--color') 10 | } else { 11 | bunyanOpts.push('-0') 12 | } 13 | // optionally override bunyan args 14 | if (process.env.BUNYAN_OPTS !== undefined) { 15 | Array.prototype.push.apply(bunyanOpts, process.env.BUNYAN_OPTS.split(/\s/)) 16 | } 17 | 18 | const bunyan = spawn(process.execPath, [bunyanBin].concat(bunyanOpts), { 19 | detached: true, 20 | stdio: ['pipe', process.stderr, process.stderr], 21 | }) 22 | bunyan.on('error', (err) => console.error('bunyan process error:', err)) // tslint:disable-line no-console 23 | 24 | const log = bunyanCreateLogger({ 25 | name: 'sshmon', 26 | level: 'trace', 27 | serializers: { err: stdSerializers.err }, 28 | stream: bunyan.stdin, 29 | }) 30 | 31 | // find basename of a file relative to root dir 32 | const makeScope = (scopeOrFilename: string): string => { 33 | if (!isAbsolute(scopeOrFilename)) { 34 | return scopeOrFilename 35 | } 36 | 37 | const rootDir = dirname(__dirname) 38 | const relativeFilename = relative(rootDir, scopeOrFilename) 39 | 40 | const { dir, name } = parse(relativeFilename) 41 | return format({ dir, name }) 42 | } 43 | 44 | export const createLogger = (scopeOrFilename: string) => { 45 | const scope = makeScope(scopeOrFilename) 46 | return log.child({ scope }) 47 | } 48 | -------------------------------------------------------------------------------- /server/src/log/middleware.ts: -------------------------------------------------------------------------------- 1 | import type { Action, Dispatch, Middleware } from '@/server/types/redux' 2 | 3 | import { createLogger } from '.' 4 | 5 | const log = createLogger('redux') 6 | 7 | // @ts-expect-error FIXME hpello https://github.com/gaearon/redux-thunk/issues/82 8 | export const logMiddleware: Middleware = 9 | () => (next: Dispatch) => (action: Action) => { 10 | if (action.type !== 'SYSTEM_ADD_STATS') { 11 | log.debug({ action }) 12 | } 13 | next(action) 14 | } 15 | -------------------------------------------------------------------------------- /server/src/reducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | 3 | import type { 4 | Action as AutoconnectAction, 5 | State as AutoconnectState, 6 | } from './autoconnect' 7 | import { reducer as autoconnects } from './autoconnect' 8 | import type { 9 | Action as AutoforwardAction, 10 | State as AutoforwardState, 11 | } from './autoforward' 12 | import { reducer as autoforwards } from './autoforward' 13 | import type { Action as ConfigAction, State as ConfigState } from './config' 14 | import { reducer as config } from './config' 15 | import type { Action as ForwardAction, State as ForwardState } from './forward' 16 | import { reducer as forwardings } from './forward' 17 | import type { Action as HostAction, State as HostState } from './host' 18 | import { reducer as hosts } from './host' 19 | import type { Action as SystemAction, State as SystemState } from './system' 20 | import { reducer as system } from './system' 21 | 22 | export type State = { 23 | hosts: HostState 24 | forwardings: ForwardState 25 | autoconnects: AutoconnectState 26 | autoforwards: AutoforwardState 27 | system: SystemState 28 | config: ConfigState 29 | } 30 | 31 | export type Action = 32 | | HostAction 33 | | ForwardAction 34 | | AutoconnectAction 35 | | AutoforwardAction 36 | | SystemAction 37 | | ConfigAction 38 | | { type: '__any_other_action_type__' } 39 | 40 | export const reducer = combineReducers({ 41 | hosts, 42 | forwardings, 43 | autoconnects, 44 | autoforwards, 45 | system, 46 | config, 47 | } as any) // FIXME hpello https://github.com/reactjs/redux/issues/2709 48 | -------------------------------------------------------------------------------- /server/src/store.ts: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux' 2 | import thunk from 'redux-thunk' 3 | 4 | import { logMiddleware } from './log/middleware' 5 | import { reducer } from './reducer' 6 | 7 | const makeMiddleware = () => { 8 | return applyMiddleware(thunk, logMiddleware) 9 | } 10 | 11 | export const store = createStore(reducer, makeMiddleware()) 12 | -------------------------------------------------------------------------------- /server/src/system/actions.ts: -------------------------------------------------------------------------------- 1 | import type { SystemInfo, SystemStats } from './types' 2 | 3 | export enum types { 4 | SYSTEM_ADD_INFO = 'SYSTEM_ADD_INFO', 5 | SYSTEM_ADD_STATS = 'SYSTEM_ADD_STATS', 6 | } 7 | 8 | interface SystemAddInfoAction { 9 | type: types.SYSTEM_ADD_INFO 10 | info: SystemInfo 11 | } 12 | 13 | interface SystemAddStatsAction { 14 | type: types.SYSTEM_ADD_STATS 15 | stats: SystemStats 16 | } 17 | 18 | export type Action = SystemAddInfoAction | SystemAddStatsAction 19 | 20 | export const actions = { 21 | systemAddInfo: (info: SystemInfo): Action => ({ 22 | type: types.SYSTEM_ADD_INFO, 23 | info, 24 | }), 25 | systemAddStats: (stats: SystemStats): Action => ({ 26 | type: types.SYSTEM_ADD_STATS, 27 | stats, 28 | }), 29 | } 30 | -------------------------------------------------------------------------------- /server/src/system/component.ts: -------------------------------------------------------------------------------- 1 | import type { Store } from '@/server/types/redux' 2 | 3 | import { actions } from './actions' 4 | import { getSystemInfo, getSystemStats } from './utils' 5 | 6 | const SYSTEM_GET_STATS_INTERVAL = 10 * 1000 7 | 8 | export class System { 9 | store: Store 10 | 11 | constructor(params: { store: Store }) { 12 | this.store = params.store 13 | } 14 | 15 | setup() { 16 | const info = getSystemInfo() 17 | this.store.dispatch(actions.systemAddInfo(info)) 18 | 19 | const startTime = Date.now() 20 | getSystemStats(startTime).then((stats) => 21 | this.store.dispatch(actions.systemAddStats(stats)) 22 | ) 23 | setInterval( 24 | () => 25 | getSystemStats(startTime).then((stats) => 26 | this.store.dispatch(actions.systemAddStats(stats)) 27 | ), 28 | SYSTEM_GET_STATS_INTERVAL 29 | ) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /server/src/system/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions' 2 | export * from './component' 3 | export * from './reducer' 4 | -------------------------------------------------------------------------------- /server/src/system/reducer.ts: -------------------------------------------------------------------------------- 1 | import type { Action } from '@/server/types/redux' 2 | 3 | import { types } from './actions' 4 | import type { SystemInfo, SystemStats } from './types' 5 | 6 | export interface SystemState { 7 | info: SystemInfo | null 8 | stats: SystemStats | null 9 | } 10 | 11 | export type State = SystemState 12 | 13 | const initialState = (): State => ({ 14 | info: null, 15 | stats: null, 16 | }) 17 | 18 | export const reducer = ( 19 | state: State = initialState(), 20 | action: Action 21 | ): State => { 22 | switch (action.type) { 23 | case types.SYSTEM_ADD_INFO: { 24 | const { info } = action 25 | return { ...state, info } 26 | } 27 | case types.SYSTEM_ADD_STATS: { 28 | const { stats } = action 29 | return { ...state, stats } 30 | } 31 | default: 32 | return state 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /server/src/system/types.ts: -------------------------------------------------------------------------------- 1 | export type SystemInfo = { 2 | arch: string 3 | homeDir: string 4 | hostName: string 5 | nodeVersion: string 6 | pid: number 7 | platform: string 8 | totalCPUs: number 9 | totalMemoryBytes: number 10 | user: string 11 | version: string 12 | } 13 | 14 | export type SystemStats = { 15 | cpuUsage: number 16 | memoryUsageBytes: number 17 | uptimeSeconds: number 18 | } 19 | -------------------------------------------------------------------------------- /server/src/system/utils.ts: -------------------------------------------------------------------------------- 1 | import * as os from 'os' 2 | 3 | import type { SystemInfo, SystemStats } from './types' 4 | 5 | export const getSystemInfo = (): SystemInfo => { 6 | const arch = os.arch() 7 | const hostName = os.hostname() 8 | const homeDir = os.homedir() 9 | const totalCPUs = os.cpus().length 10 | const totalMemoryBytes = os.totalmem() 11 | const user = os.userInfo().username 12 | 13 | const { pid, platform } = process 14 | const nodeVersion = process.version 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-var-requires -- get version dynamically 17 | const { version } = require('../../../package.json') 18 | 19 | return { 20 | arch, 21 | homeDir, 22 | hostName, 23 | nodeVersion, 24 | pid, 25 | platform, 26 | totalCPUs, 27 | totalMemoryBytes, 28 | user, 29 | version, 30 | } 31 | } 32 | 33 | const setTimeoutAsync = (timeout: number) => 34 | new Promise((resolve) => setTimeout(resolve, timeout)) 35 | 36 | const CPU_USAGE_WINDOW = 1000 37 | 38 | export const getSystemStats = async ( 39 | startTime: number 40 | ): Promise => { 41 | const startUsageTime = process.cpuUsage() 42 | await setTimeoutAsync(CPU_USAGE_WINDOW) 43 | const usage = process.cpuUsage(startUsageTime) 44 | 45 | const cpuUsage = (usage.system + usage.user) / CPU_USAGE_WINDOW 46 | const memoryUsageBytes = process.memoryUsage().rss 47 | const uptimeSeconds = (Date.now() - startTime) / 1000 48 | 49 | return { cpuUsage, memoryUsageBytes, uptimeSeconds } 50 | } 51 | -------------------------------------------------------------------------------- /server/src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | Middleware as ReduxMiddleware, 3 | MiddlewareAPI as ReduxMiddlewareAPI, 4 | Unsubscribe, 5 | } from 'redux' 6 | import type { 7 | ThunkAction as ReduxThunkAction, 8 | ThunkDispatch as ReduxThunkDispatch, 9 | } from 'redux-thunk' 10 | 11 | import type { Action as _Action, State as _State } from '@/server/reducer' 12 | 13 | export type Action = _Action 14 | export type State = _State 15 | export type Dispatch = ReduxThunkDispatch 16 | export type ThunkAction = ReduxThunkAction 17 | export type AsyncThunkAction = ReduxThunkAction< 18 | Promise, 19 | State, 20 | any, 21 | Action 22 | > 23 | 24 | export type GetState = () => State 25 | export interface Middleware extends ReduxMiddleware { 26 | (api: MiddlewareAPI): (next: Dispatch) => Dispatch 27 | } 28 | export type MiddlewareAPI = ReduxMiddlewareAPI 29 | export interface Store { 30 | dispatch: Dispatch 31 | getState(): State 32 | subscribe(listener: () => void): Unsubscribe 33 | } 34 | -------------------------------------------------------------------------------- /server/src/utils/disconnect-all-hosts.ts: -------------------------------------------------------------------------------- 1 | import { thunks as hostThunks } from '@/server/host' 2 | import type { Store } from '@/server/types/redux' 3 | 4 | const DISCONNECT_REASON = 'shutdown' 5 | 6 | export const disconnectAllHosts = async (store: Store) => { 7 | store 8 | .getState() 9 | .hosts.forEach((host) => 10 | store.dispatch(hostThunks.hostDisconnect(host.id, DISCONNECT_REASON)) 11 | ) 12 | const allDisconnected = () => { 13 | return store 14 | .getState() 15 | .hosts.map((host) => 16 | ['disconnected', 'error'].includes(host.state.status) 17 | ) 18 | .reduce((acc, hostIsDown) => acc && hostIsDown, true) 19 | } 20 | 21 | return new Promise((resolve) => { 22 | let done = false 23 | const listener = () => { 24 | if (!done && allDisconnected()) { 25 | done = true 26 | resolve() 27 | } 28 | } 29 | 30 | listener() 31 | store.subscribe(listener) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /server/src/utils/error-with-code.ts: -------------------------------------------------------------------------------- 1 | export class ErrorWithCode extends Error { 2 | code: number 3 | constructor(code: number, message?: string) { 4 | super(message) 5 | this.name = 'ErrorWithCode' 6 | this.code = code 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/utils/graceful-shutdown.ts: -------------------------------------------------------------------------------- 1 | import { createLogger } from '@/server/log' 2 | 3 | const log = createLogger(__filename) 4 | 5 | export const setupGracefulShutdown = (cleanup: () => Promise) => { 6 | let exiting = false 7 | const gracefulExit = async (code: number) => { 8 | try { 9 | if (exiting) { 10 | log.error('shutdown already requested') 11 | return 12 | } 13 | exiting = true 14 | await cleanup() 15 | } catch (err) { 16 | log.error({ err }, 'error during shutdown') 17 | } 18 | 19 | process.exit(code) 20 | } 21 | 22 | process.on('unhandledRejection', (reason) => { 23 | log.error('unhandled rejection:', reason) 24 | gracefulExit(1) 25 | }) 26 | 27 | process.on('uncaughtException', (err) => { 28 | log.error({ err }, 'uncaught exception') 29 | gracefulExit(1) 30 | }) 31 | 32 | process.on('SIGTERM', () => { 33 | log.info('received SIGTERM') 34 | gracefulExit(128 + 15) 35 | }) 36 | process.on('SIGINT', () => { 37 | log.info('received SIGINT') 38 | gracefulExit(128 + 2) 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /server/src/utils/server-url.ts: -------------------------------------------------------------------------------- 1 | import type { Server } from 'http' 2 | 3 | export const formatURL = (server: Server): string => { 4 | const a = server.address() 5 | if (typeof a === 'string') { 6 | return a 7 | } 8 | 9 | // @ts-expect-error apparently address can be null 10 | const { address, port, family } = a 11 | 12 | const host = family === 'IPv6' ? `[${address}]` : address 13 | return `http://${host}:${port}` 14 | } 15 | -------------------------------------------------------------------------------- /server/src/utils/tmp.ts: -------------------------------------------------------------------------------- 1 | import { find } from 'lodash' 2 | import { join } from 'path' 3 | import { dir } from 'tmp' 4 | 5 | import { createLogger } from '@/server/log' 6 | 7 | const log = createLogger(__filename) 8 | 9 | const dirAsync = (options: any): Promise => 10 | new Promise((resolve, reject) => { 11 | dir(options, (err, path) => { 12 | if (err) { 13 | reject(err) 14 | return 15 | } 16 | resolve(path) 17 | }) 18 | }) 19 | const tmpDir = dirAsync({ prefix: 'sshmon-' }) 20 | tmpDir.then((path) => log.debug('temporary directory:', path)) 21 | 22 | const knownPaths: { scope: string; id: string; path: string }[] = [] 23 | 24 | // make short ids to ensure short unix paths 25 | let numIds = 0 26 | const nextId = () => `${numIds++}` // tslint:disable-line no-increment-decrement 27 | 28 | export const makeTmpPath = 29 | (scopeOrFilename: string) => 30 | async (id: string): Promise => { 31 | const scope = scopeOrFilename 32 | const knownPath = find(knownPaths, { scope, id }) 33 | const path = knownPath ? knownPath.path : nextId() 34 | if (!knownPath) { 35 | knownPaths.push({ scope, id, path }) 36 | } 37 | 38 | const tmpDirPath = await tmpDir 39 | return join(tmpDirPath, path) 40 | } 41 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "lib": ["es2018"] 5 | }, 6 | "include": ["src"] 7 | } 8 | -------------------------------------------------------------------------------- /test/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: '../.eslintrc.cjs', 3 | env: { 4 | mocha: true, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /test/Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | @for SCENARIO in $(shell ls scenarios/) ; do \ 3 | ( \ 4 | cd scenarios/$$SCENARIO && \ 5 | docker compose up -d --build && \ 6 | docker compose exec -T client yarn test ; \ 7 | STATUS=$$? ; docker compose down -v && exit $$STATUS \ 8 | ) || exit 1 ; \ 9 | done && echo 'All tests OK' 10 | -------------------------------------------------------------------------------- /test/docker/client/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:18.14.2-alpine as server 2 | WORKDIR /opt/app 3 | COPY server/package.json server/yarn.lock ./ 4 | RUN yarn --frozen-lockfile 5 | 6 | FROM node:18.14.2-alpine as gui 7 | WORKDIR /opt/app 8 | COPY gui/package.json gui/yarn.lock ./ 9 | RUN yarn --frozen-lockfile 10 | 11 | 12 | FROM node:18.14.2-alpine as test 13 | WORKDIR /opt/app 14 | COPY test/package.json test/yarn.lock ./ 15 | RUN yarn --frozen-lockfile 16 | COPY test/tsconfig.json ./ 17 | 18 | FROM node:18.14.2-alpine 19 | 20 | RUN apk add --update --no-cache openssh 21 | 22 | COPY test/docker/client/ssh/id_rsa /root/.ssh/ 23 | RUN chmod 600 /root/.ssh/id_rsa 24 | COPY test/docker/client/ssh/known_hosts /root/.ssh/ 25 | 26 | WORKDIR /opt/sshmon 27 | 28 | COPY --from=server /opt/app ./server 29 | COPY --from=gui /opt/app ./gui 30 | COPY package.json yarn.lock ./ 31 | RUN yarn --frozen-lockfile 32 | 33 | COPY tsconfig.json ./ 34 | COPY server/ server/ 35 | COPY gui/ gui/ 36 | 37 | RUN yarn deploy 38 | 39 | WORKDIR /opt/test 40 | COPY --from=test /opt/app ./ 41 | 42 | RUN apk add --update --no-cache bash 43 | 44 | RUN (echo 'trap "exit 143" SIGTERM' \ 45 | && echo 'sleep 1d & wait') > /sleep.sh 46 | CMD ["sh", "/sleep.sh"] 47 | -------------------------------------------------------------------------------- /test/docker/client/ssh/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAtpfs3lOl4m9elLaKB1akqYKcCR0Nye/xrOZbtS8YJj72SkG1 3 | lKmXLDtuXaD4+st/jGTZvycFyMSqtt442FUhvUjujURZ91a1rEvErCs7BPkfTj2f 4 | kC/OdCQ4YDzl6MF2I9UxWggwHiJxxv1LSmxJRMiTO5x5Sz2BZ16qlqkQZ2NHN5L2 5 | 57W/pJ8cPSbrJBXTnXhS57qH8zXfd4RZmmEppFzV+k+oQw7Xzmj2Ovcra1y4pgsW 6 | xQDL+b81oOetAylJRW4D1xcv2qOQDu0mPPtW1UYgSNfgOvFxhP/ld7RNpZrZWyVI 7 | Xsx4G+MZl4gf9TMd9928R5p+nqIPKZcqYhnugwIDAQABAoIBACxPcgwCAJ+Sp0Ev 8 | iw981zjLmVIc2tvGfr+Wp2qwowuk+9/JUmdhQG5bfcz643D5MVrAnpbkwWKXV1ZJ 9 | V0IoZqeoqztc+vgq8qt9N50QYKI0UoSKL86ty0wj/gpcvO5fBGxtsCxJEGQ/t5yy 10 | mzLUVHN8uT4eGtFHe7+6GWu2W2S7Kcge9FLoe8tZ3fAe4ILsH7xavDMl7xWQ05O5 11 | QLTaE3q5CwYV8/R049t3hD7oIb9pLW1EqEJwxrXOZsCfq1vjPTU6lzwTDnZY1DYG 12 | l4sf6u1mv2vkF/4SRigdfSpeOIOULdtvdTOAObWsnePaSRVr03rJSCPOz7//0HcN 13 | lc453AECgYEA5lOcm9fogC/0fGr7Ly6UEvzryNMPhOgAkiH48l+Kn09A+8LbzQbf 14 | xF/iggp9GX9jEOcyDOX553F24d0tG84UJCrY8ZSx0SfcqhbmBRg2H2hbJ/qDPu76 15 | /BweVruDiiq07IUhQTYP7wC+lcIpna49WOJ+HGsHK+RxbqYkztADf/ECgYEAyvI+ 16 | ho6QvuEAfrHFeoN37EX7Z1igKLfD1cKJh3QY2loO4fT9e0K82XpyXrWvoP7bksfU 17 | DmSMJ2NfC+zVKLcqX/IFrO/Q2q8Nz5Sy84fOIhekthMJ4SITPFMwSAqO+pDXvK5l 18 | oJvFmDTLmr9YonmeD6pJba/og2wpJpWBmnqoCbMCgYEAka5Y5AmQOWQbk4ZnEdS4 19 | O7pVuFQnAL1tfwCV4Vek6lbKl3MMa4Xpit/K+M2BitsT4eEZuybLBiSyOPIYUfnq 20 | q2Weik6umchIvhx1qMtDkGIFJihBU1D81vFCD0HTVVTl0qPyALkdIuKpqTeB8wX+ 21 | L3Yype7cPlrjlETqCxVu4UECgYBQE6NuLmSYnGk4lGZqP+Oau8ZF8edICbJg7uCb 22 | LMj9pxlGOQenkh635SOpkSDacpUq5mQaxbuzcvc22l0FMriqoPFyWjHbh8T4SiEO 23 | DGDefNvF5983EN3sKJrdYiUmYu5tCZcZ9zzRIMvRpHyRp1Ehtzw/5m/lI1FdYxJ/ 24 | ZLRXFwKBgD/Bt6m9NQa8x5VYr6KD1HNEHB/1S0kgHQiu6oOFaEk6HETxkVLjqz6C 25 | vlVWl6Fxo9HCPSizG5GRNEdnnymt8q98OZCQZFRPivOhey8R0W2XSP6mwqgAeauy 26 | 5NwZpxbmdghguxgbPwdnM9oepSOAPUwHVeu/i6mlJWsCoSS0UWZ/ 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/docker/client/ssh/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC2l+zeU6Xib16UtooHVqSpgpwJHQ3J7/Gs5lu1LxgmPvZKQbWUqZcsO25doPj6y3+MZNm/JwXIxKq23jjYVSG9SO6NRFn3VrWsS8SsKzsE+R9OPZ+QL850JDhgPOXowXYj1TFaCDAeInHG/UtKbElEyJM7nHlLPYFnXqqWqRBnY0c3kvbntb+knxw9JuskFdOdeFLnuofzNd93hFmaYSmkXNX6T6hDDtfOaPY69ytrXLimCxbFAMv5vzWg560DKUlFbgPXFy/ao5AO7SY8+1bVRiBI1+A68XGE/+V3tE2lmtlbJUhezHgb4xmXiB/1Mx333bxHmn6eog8plypiGe6D 2 | -------------------------------------------------------------------------------- /test/docker/client/ssh/known_hosts: -------------------------------------------------------------------------------- 1 | server ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRwBDr6Z824YBfIZaRhtNC+mf7DCD8YxVXcwnSM1aGVkgABcpiu/XBputtL5rf+FYd1SykyypDMRnYOvmvFfloh5E9wl62A6vhdCz1HwbYGHWbiGymcj7m8ep3HRNYCAEM/n0FnseCFbiSK6eHO197WkOr5v/oGO4incZuKpcrjef35/OTslt0FyCXCQwktFVSspAyFXALXVTNlNj/1LJBjEBpm0k43O5YCUceDnGPtv25WjAyitv/uwD+SoAeLj1g8QXu9ggDkKDRLNRNUB1GC1f3q4TL+Q5GElaQ1NJ6mfyrFfqjV1cpjzm4dpRCjjQcYgIga47+iwDtTDzTCOTz 2 | -------------------------------------------------------------------------------- /test/docker/server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.7 2 | 3 | RUN apk add --update --no-cache openssh 4 | 5 | COPY ssh/id_rsa /root/.ssh/ 6 | RUN chmod 600 /root/.ssh/id_rsa 7 | COPY ssh/authorized_keys /root/.ssh/ 8 | # unlock account for ssh connection to prevent 9 | # 'User root not allowed because account is locked' 10 | RUN sed -i s/root:!/root:*/g /etc/shadow 11 | 12 | # http server 13 | RUN apk add --update --no-cache busybox busybox-extras # httpd 14 | RUN echo 'Hello from server' > hello 15 | 16 | RUN (echo 'trap "exit 143" SIGTERM' \ 17 | && echo '(httpd && /usr/sbin/sshd -D -h /root/.ssh/id_rsa) & wait') > /run.sh 18 | CMD ["sh", "/run.sh"] 19 | -------------------------------------------------------------------------------- /test/docker/server/ssh/authorized_keys: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC2l+zeU6Xib16UtooHVqSpgpwJHQ3J7/Gs5lu1LxgmPvZKQbWUqZcsO25doPj6y3+MZNm/JwXIxKq23jjYVSG9SO6NRFn3VrWsS8SsKzsE+R9OPZ+QL850JDhgPOXowXYj1TFaCDAeInHG/UtKbElEyJM7nHlLPYFnXqqWqRBnY0c3kvbntb+knxw9JuskFdOdeFLnuofzNd93hFmaYSmkXNX6T6hDDtfOaPY69ytrXLimCxbFAMv5vzWg560DKUlFbgPXFy/ao5AO7SY8+1bVRiBI1+A68XGE/+V3tE2lmtlbJUhezHgb4xmXiB/1Mx333bxHmn6eog8plypiGe6D 2 | -------------------------------------------------------------------------------- /test/docker/server/ssh/id_rsa: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEA0cAQ6+mfNuGAXyGWkYbTQvpn+wwg/GMVV3MJ0jNWhlZIAAXK 3 | Yrv1wabrbS+a3/hWHdUspMsqQzEZ2Dr5rxX5aIeRPcJetgOr4XQs9R8G2Bh1m4hs 4 | pnI+5vHqdx0TWAgBDP59BZ7HghW4kiunhztfe1pDq+b/6BjuIp3GbiqXK43n9+fz 5 | k7JbdBcglwkMJLRVUrKQMhVwC11UzZTY/9SyQYxAaZtJONzuWAlHHg5xj7b9uVow 6 | Morb/7sA/kqAHi49YPEF7vYIA5Cg0SzUTVAdRgtX96uEy/kORhJWkNTSepn8qxX6 7 | o1dXKY85uHaUQo40HGICIGuO/osA7Uw80wjk8wIDAQABAoIBAEAGleO9Y4lYGlxv 8 | n0t60WAfmb/rOuJvyGLyTawpWZ8WFVJUcivjXignsLOalXcKkhb3LHJ9RcBO66my 9 | jubgGUrMHwywGUMMKdpfIR4Nw4QRK003vpqsQwEyFTUY3f/AKbnysO0XQ6U0xitw 10 | QeoouIOp0QBXOgR6H67XZGvaelyjnX4grcc0+1f1Wl8hKZ0Wocwox5//BqyBIuGR 11 | Buo04Q2go12h1DxKD9kEplSjVYfbU0ybQ7IDnnOvNpzUeNaj9baDZ66jSolGqYS3 12 | tqI4zZ4bAC9OlYb2lyq3mVFipweRSy9CkzN7uHGgswR5J4KMpVWRQNmtJSNfVakM 13 | Li+KemkCgYEA/dMadG6dH2oU+prnlqIHrxniW91P5GTb7a3nKSuGOaiKAS6bMjpb 14 | MMtHGQlV8XCYznSKsjbkvCfEk5d4itpHFZ+Kejtd6T0ZM988l9TT1mfXYpP7AZUU 15 | bIYUscRa+2urhasxSV0hClG8lfXHh7j4DyDWaaZevKXG/Jmnl8emny8CgYEA04xD 16 | PcExtpG4672PcCHwtEM/g4VGqBqMnuVgnktyh3YKAH9BiMtrDMle31NkE+wSxJUu 17 | FQuxLMO1pWmlrC5oKwnHG50i3GQYT0BrEXYgEKy4/Yl7PdlIcfm+6dMrRnOgGbQt 18 | gBhtGNUYYwTuPWL5/9fzKuYOHdQVGNXJ5KkyxX0CgYBlJ6pVWHfZuycdMHq291/k 19 | koY5Yhuiw+c586I6MBRQCjDlg7Np8wPAkGKHQcc0b1EWhi94EpRZv+eBgk4R2CeY 20 | IHrJd7tXZngSyhaV08WQntWhfaP833LCRTUeG0i/BgvXO4Bq4mh2eliT0t1v43FI 21 | ZifM9vaua8q/hGTcAj8PFQKBgQDQSAAINEeK9ix4RhmMkI4aHT/ScQzhA8rBwxr+ 22 | n0/y2R/9300ZrxYF79mtzb/x8XOFA0/svqdBaKtWYg8Q2FwNS8IyiOLC1PYuSUFh 23 | XskxQa2dSpgBjAXM2dTDCPtJkRvnUsOdo+7+DQjGrRsXa3SzFd4/tWPesGnZGtx9 24 | eq0XTQKBgBbGuc/5BmGCsyLOwgzB6aQmGSB4yulzHGzeCSy6GImfEdStLiGgimrL 25 | QZV8VTXMz5ZPiiCc7LgR46kXlnzxfTO5NiGt+P4epQh5lGGVb+4N0CTSNFeQ9adm 26 | vcvSkh+gyTwSAa7S6TGa6/f6rfAhPLKhKNVuiw+/pZlKvHpzsZTQ 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /test/docker/server/ssh/id_rsa.pub: -------------------------------------------------------------------------------- 1 | ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDRwBDr6Z824YBfIZaRhtNC+mf7DCD8YxVXcwnSM1aGVkgABcpiu/XBputtL5rf+FYd1SykyypDMRnYOvmvFfloh5E9wl62A6vhdCz1HwbYGHWbiGymcj7m8ep3HRNYCAEM/n0FnseCFbiSK6eHO197WkOr5v/oGO4incZuKpcrjef35/OTslt0FyCXCQwktFVSspAyFXALXVTNlNj/1LJBjEBpm0k43O5YCUceDnGPtv25WjAyitv/uwD+SoAeLj1g8QXu9ggDkKDRLNRNUB1GC1f3q4TL+Q5GElaQ1NJ6mfyrFfqjV1cpjzm4dpRCjjQcYgIga47+iwDtTDzTCOTz hippo@hippo-imac 2 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sshmon-test", 3 | "version": "", 4 | "license": "MIT", 5 | "dependencies": { 6 | "axios": "^1.8.2", 7 | "chai": "^4.1.2" 8 | }, 9 | "devDependencies": { 10 | "@types/chai": "^4.3.4", 11 | "@types/mocha": "^10.0.1", 12 | "@types/node": "^18.15.2", 13 | "mocha": "^10.2.0", 14 | "ts-node": "^10.9.1", 15 | "typescript": "^4.9.5" 16 | }, 17 | "scripts": { 18 | "test": "mocha -r ts-node/register *.ts" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /test/scenarios/01-basic/config.yml: -------------------------------------------------------------------------------- 1 | hosts: 2 | - server: 3 | autostart: true 4 | autoretry: true 5 | forward: 6 | - local-forwarding: 7 | spec: L 1234 80 8 | autostart: true 9 | - http-forwarding: 10 | spec: H 80 11 | autostart: true 12 | -------------------------------------------------------------------------------- /test/scenarios/01-basic/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | client: 5 | build: 6 | context: ../../.. 7 | dockerfile: test/docker/client/Dockerfile 8 | environment: 9 | BUNYAN_OPTS: -l debug 10 | command: /opt/sshmon/build/sshmon 11 | volumes: 12 | - ./config.yml:/root/.sshmon/config.yml:ro 13 | - ./test.ts:/opt/test/test.ts:ro 14 | 15 | server: 16 | build: 17 | context: ../../docker/server 18 | -------------------------------------------------------------------------------- /test/scenarios/01-basic/test.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { assert } from 'chai' 3 | 4 | const REQUEST_TIMEOUT = 5000 5 | 6 | describe('sshmon', () => { 7 | it('access local forwarded service', async () => { 8 | const url = 'http://localhost:1234/hello' 9 | const response = await axios.request({ url, timeout: REQUEST_TIMEOUT }) 10 | assert.equal(response.status, 200) 11 | assert.equal(response.data, 'Hello from server\n') 12 | }) 13 | 14 | it('access http forwarded service', async () => { 15 | const url = 'http://localhost:8377/proxy/server/http-forwarding/hello' 16 | const response = await axios.request({ url, timeout: REQUEST_TIMEOUT }) 17 | assert.equal(response.status, 200) 18 | assert.equal(response.data, 'Hello from server\n') 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es2018"], 4 | "target": "es5", 5 | "sourceMap": true, 6 | "strict": true, 7 | "noImplicitReturns": true, 8 | "noUnusedLocals": true, 9 | "noUnusedParameters": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "lib": ["es2018", "dom"], 5 | "target": "es5", 6 | "sourceMap": true, 7 | "strict": true, 8 | "noImplicitReturns": true, 9 | "noUnusedLocals": true, 10 | "noUnusedParameters": true, 11 | "baseUrl": ".", 12 | "paths": { 13 | "@/server/*": ["server/src/*"], 14 | "@/gui/*": ["gui/src/*"] 15 | } 16 | } 17 | } 18 | --------------------------------------------------------------------------------