31 Commits

Author SHA1 Message Date
mike
300d583be5 xMerge branch 'dev' 2025-10-31 15:47:21 -07:00
mike
74a59f57e4 hooks 2025-10-31 15:46:26 -07:00
mike
cddbee877b hooks 2025-10-31 15:45:18 -07:00
mike
14443cb675 tweak 2025-10-31 15:41:03 -07:00
mike
5152121d2b tweak 2025-10-31 15:40:41 -07:00
mike
026e07c51b tweak README 2025-10-31 15:39:46 -07:00
mike
2bd6fa418b Update README 2025-10-31 15:38:50 -07:00
mike
74b66c959f Bump pyenv/pipenv 2025-10-31 14:09:25 -07:00
mike
8b2a34d5d7 Merge branch 'dev' 2025-10-31 14:04:10 -07:00
Mike
3b11b46a39 Might be done upgrading 2024-07-06 20:43:11 -07:00
Mike
1dcb08352a refactor/upgrade work 2024-07-06 20:01:45 -07:00
Mike
e96c505367 Environment initializer 2024-07-06 17:36:51 -07:00
Mike
ec03d095d9 move main script 2024-07-06 17:36:45 -07:00
Mike
c8086b0901 Start using pipenv 2024-07-06 17:36:33 -07:00
Mike
83d2b7bc9b start git ignore 2024-07-06 17:36:26 -07:00
58ea1d8d79 noop to test hooks 2020-01-02 10:36:32 -08:00
b3d4729432 Noop to test hook 2019-08-06 16:55:07 -07:00
c660fbeec5 Merge branch 'dev' 2018-10-02 02:27:14 -07:00
19f3be3c91 Merge branch 'dev' 2018-05-27 04:35:36 -07:00
47a725ed4d Merge branch 'dev' 2018-05-27 04:04:18 -07:00
e00775cd67 Updates 2018-05-27 04:03:47 -07:00
e7560cde20 Merge branch 'master' into dev 2018-05-27 03:56:03 -07:00
c0df2ddda8 Merge branch 'master' of https://github.com/mikeperalta1/connection-specific-ssh-config 2018-05-27 03:55:51 -07:00
bbdbae9a9f Update README.md 2018-05-27 03:54:54 -07:00
ba64d74721 Update README.md 2018-05-27 03:50:45 -07:00
0c5a52bacf Update README.md 2018-05-27 03:49:48 -07:00
d748e2ae53 Update README.md 2018-05-27 03:45:02 -07:00
5003f3fd05 Update README.md 2018-05-27 03:28:41 -07:00
32ff8327f4 Add exec perms 2018-05-27 02:41:12 -07:00
cfdc943087 Add files via upload 2018-05-27 02:37:12 -07:00
59d72e1bf0 Initial commit 2018-05-27 02:36:31 -07:00
11 changed files with 820 additions and 323 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.idea/

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14.0

13
Pipfile Normal file
View File

@@ -0,0 +1,13 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
pyaml = "*"
[dev-packages]
[requires]
python_version = "3.14"

109
Pipfile.lock generated Normal file
View File

@@ -0,0 +1,109 @@
{
"_meta": {
"hash": {
"sha256": "635a55e0b494b99462b0df92263ceefd638deacc42eb60bfa9c8a8ff02ed66a3"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.14"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"pyaml": {
"hashes": [
"sha256:ce5d7867cc2b455efdb9b0448324ff7b9f74d99f64650f12ca570102db6b985f",
"sha256:e113a64ec16881bf2b092e2beb84b7dcf1bd98096ad17f5f14e8fb782a75d99b"
],
"index": "pypi",
"markers": "python_version >= '3.8'",
"version": "==25.7.0"
},
"pyyaml": {
"hashes": [
"sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c",
"sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a",
"sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3",
"sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956",
"sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6",
"sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c",
"sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65",
"sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a",
"sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0",
"sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b",
"sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1",
"sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6",
"sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7",
"sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e",
"sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007",
"sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310",
"sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4",
"sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9",
"sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295",
"sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea",
"sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0",
"sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e",
"sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac",
"sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9",
"sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7",
"sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35",
"sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb",
"sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b",
"sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69",
"sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5",
"sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b",
"sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c",
"sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369",
"sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd",
"sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824",
"sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198",
"sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065",
"sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c",
"sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c",
"sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764",
"sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196",
"sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b",
"sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00",
"sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac",
"sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8",
"sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e",
"sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28",
"sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3",
"sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5",
"sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4",
"sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b",
"sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf",
"sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5",
"sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702",
"sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8",
"sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788",
"sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da",
"sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d",
"sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc",
"sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c",
"sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba",
"sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f",
"sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917",
"sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5",
"sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26",
"sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f",
"sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b",
"sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be",
"sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c",
"sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3",
"sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6",
"sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926",
"sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0"
],
"markers": "python_version >= '3.8'",
"version": "==6.0.3"
}
},
"develop": {}
}

158
README.md
View File

@@ -7,49 +7,91 @@ The Linux SSH client has many configuration options to choose from, and sometime
Written by Mike Peralta and released to the public via GPLv3 on Sunday, May 27th, 2018 Written by Mike Peralta and released to the public via GPLv3 on Sunday, May 27th, 2018
## Requirements ## Requirements
* Linux (possibly others)
* Python 3
* NetworkManager and nmcli (tested on v1.10.6)
* One of the following: root access, the ability to place executable files inside NetworkManager's dispatcher directory, or the ability to call this script whenever connection details change
Note: This script has only been tested with NetworkManager and nmcli 1.10.6, but should work with any other daemon or application that can call this script with the required parameters. * Linux (possibly others)
* Python 3
* pyenv
* pipenv
* NetworkManager and nmcli (tested on v1.46)
* One of the following: root access, the ability to place executable files inside NetworkManager's dispatcher directory, or the ability to call this script whenever connection details change
Note: This script has only been tested with NetworkManager and nmcli 1.46, but should work with any other daemon or application that can call this script with the required parameters.
You can also try using this script with a different daemon (or your own custom script). All you need to do is call this script whenever some change in the network occurs, with the following parameters: You can also try using this script with a different daemon (or your own custom script). All you need to do is call this script whenever some change in the network occurs, with the following parameters:
```connection-specific-ssh-config <interface> <command> <ini_path>``` ```bash
$ cd path/to/connection-specific-ssh-config && pipenv run python main.py --config <config_path> --adapter <adapter> --command <command>
```
* Where *config_path* is the path to a yaml configuration file, explained below
* Where *adapter* is the name of the adapter/interface that has changed states
* Where *command* is the type of change occurring. Currently, the script only does anything when receiving the `up` command.
* Where *interface* is the name of whatever interface just changed
* Where *command* is the type of change occurring. Currently, the script only does anything when receiving the "up" command.
* Where *ini_path* is the path to an ini configuration file, explained below
## Installation (NetworkManager) ## Installation (NetworkManager)
1. Copy this script somewhere safe, such as ```/path/to/connection-specific-ssh-config```
2. Move your old ssh configuration file (typically at *~/.ssh/config*) to a safe backup, like: This section to help with installation.
```mv ~/.ssh/config config-backup``` ### Python Environment
3. Create as many customized ssh configuration files as you need 1. Install [pyenv](https://github.com/pyenv/pyenv)
2. Install [pipenv](https://pipenv.pypa.io/en/latest/)
4. Create a configuration file somewhere safe, such as ```/path/to/connection-specific-ssh-config.ini``` (explained in detail further below) Setting up the *pipenv* environment is pretty straight forward:
5. Open a terminal and navigate to NetworkManager's dispatcher directory, often found here:
```cd /etc/NetworkManager/dispatcher.d``` ```bash
$ cd /path/to/connection-specific-ssh-config
$ pyenv update
$ pyenv install
$ pip install --upgrade pip pipenv
$ pipenv sync
```
6. Create a short bash script inside the dispatcher directory that will launch connection-specific-ssh-config. For instance, you might name this file ```/etc/NetworkManager/dispatcher.d/99-launch-connection-specific-ssh-config``` You should then be able to get a help menu with:
7. Inside your launcher script, put the following contents: ```bash
$ cd /path/to/connection-specific-ssh-config
$ pipenv run python main.py --help
```
``` ### SSH Config Preparation
#!/bin/bash
/path/to/connection-specific-ssh-config "$1" "$2" "/path/to/connection-specific-ssh-config.ini"
```
This will take the two important variables which NetworkManager has passed along to your launcher script, and pass them along to connection-specific-ssh-config
8. Repeat steps 2-7 for each additional user who would like connection based ssh configuration files. 1. Move your old ssh configuration file (typically at *~/.ssh/config*) to a safe backup, like:
* ```mv ~/.ssh/config config-backup```
2. Create as many customized ssh configuration files as you need, such as `config-remote` or `config-home`. Do not name any of them `config`.
### Configuration File
1. Create a configuration file somewhere safe, such as ```/path/to/connection-specific-ssh-config.yaml``` (explained in detail further below)
### Network Manager Dispatch
1. Open a terminal and navigate to NetworkManager's dispatcher directory, often found here:
* ```cd /etc/NetworkManager/dispatcher.d```
* There may be subdirectories underneath. Just ignore them.
2. Create a short bash script inside the dispatcher directory that will launch *connection-specific-ssh-config*. For instance, you might name this file ```/etc/NetworkManager/dispatcher.d/99-launch-connection-specific-ssh-config```
3. Inside your launcher script, put the following contents:
#!/bin/bash
cd /path/to/connection-specific-ssh-config && pipenv run python main.py --config /path/to/your/config.yaml --interface "$1" --command "$2"
* This will take the two important variables which NetworkManager has passed along to your launcher script, and give them to *connection-specific-ssh-config*
4. Repeat steps 1-3 for each additional user who would like connection based ssh configuration files.
## Example Scenarios ## Example Scenarios
@@ -64,44 +106,46 @@ Why would you use this script? Suppose the following scenarios:
Each of these scenarios can be solved by the connection-specific-ssh-config script. Simply create as many ssh configuration files as you need, and each can be dynamically symlinked as you connec to different networks. Each of these scenarios can be solved by the connection-specific-ssh-config script. Simply create as many ssh configuration files as you need, and each can be dynamically symlinked as you connec to different networks.
## Configuration File ## Configuration File
Configuration files for connection-specific-ssh-config are just standard python/php style ini files, and are relatively simple: Configuration files for connection-specific-ssh-config are written in YAML and are relatively simple:
* You need a section called "config", which currently only holds one variable: * You need a section called `options`, which holds two variables:
* *ssh_dir*, which specifies the directory your ssh client looks for configuration files (typically *~/.ssh/*)
* You then need one or more sections which specify targets that associate ssh configuration files with network connection names 1. `ssh-dir`, which specifies your ssh configuration directory (typically *~/.ssh/*)
* The key *adapter* can specify one adapter to watch 2. `default-target`, which specifies the default target to use if the currently connected wifi access point does not match anything configured.
* The key *adapters* can specify multiple adapters (must be written in JSON format)
* The key *ssid* can specify one connection name
* The key *ssids* can specify multiple connection names (must be written in JSON format)
* The key *ssh_config_name* specifies the name of the ssh configuration file that should apply if this target activates
* You can then optionally add a "default" section, which specifies which default ssh configuration file to fallback to, if any * You then need one or more sections which specify targets that associate ssh configuration files with network connection names:
* The key `adapter` can specify one adapter to watch
* The key `adapters` can specify multiple adapters, as a list
* The key `ssid` can specify one connection name
* The key `ssids` can specify multiple connection names, as a list
* The key *config-file-name* specifies the name of the ssh configuration file that should apply if this target activates
Here's an example configuration file to help you get started: Here's an example configuration file to help you get started:
```yaml
options:
default-target: remote
ssh-dir: /home/your-username/.ssh
targets:
home:
adapters:
- wlo1
ssids:
- Some Wifi AP Name
- Some Other Wifi AP Name
config-file-name: config-home
remote:
adapters:
- wlo1
config-file-name: config-remote
``` ```
[config] The above configuration file will instruct the script to use the ssh config file */home/your-username/.ssh/config-home* while you're connected to one of the access points named in the `ssids` list of the `home` target. Otherwise, the default config file */home/your-username/.ssh/config-remote* would be used. All of this would only apply to the adapter `wlo1`, but you could specify more adapters.
ssh_dir = /home/mike/.ssh
[default]
adapter = wlan0
ssh_config_name = config-default
[Wifi Work]
adapters = ["wlan0", "wlan1"]
ssids = ["Work Connection - Main Office", "Work Connection - Garys Office"]
ssh_config_name = config-work
[Wifi Home]
adapter = wlan0
ssid = My Home Connection (Oh Joy)
ssh_config_name = config-home
```
The above configuration file will instruct the script to use the ssh configuration file */home/mike/.ssh/config-work* while you're connected to one of your work connections *Work Connection - Main Office* or *Work Connection - Garys Office*, on either of your wireless adapters *wlan0* or *wlan1*. When you get home and connect to your *My Home Connection (Oh Joy)* connection on *wlan0*, the config file */home/mike/.ssh/config-home* will be used instead. Finally, the configuration file */home/mike/.ssh/config-default* will be used when *wlan0* connects to some undefined network.
The script works by simply creating a symlink where your original ssh configuration file was (typically *~/.ssh/config*), pointing to the ssh configuration determined to be the one you want active. Note that this of course means the script will fail (for safety reasons), if your original ssh configuration file still exists as a normal file. The script works by simply creating a symlink where your original ssh configuration file was (typically *~/.ssh/config*), pointing to the ssh configuration determined to be the one you want active. Note that this of course means the script will fail (for safety reasons), if your original ssh configuration file still exists as a normal file.

View File

@@ -1,266 +0,0 @@
#!/usr/bin/env python3
#
import configparser
import json
import logging
import os
import re
import subprocess
import sys
import syslog
#
class SSHConfiger:
#
_action_interface = None
_action_command = None
#
_config_file_path = None
_targets = None
_config = None
#
def __init__(self, action_interface, action_command, config_file_path):
# Grab args
self._action_interface = action_interface
self._action_command = action_command
self._config_file_path = config_file_path
#
def log(self, s):
#
print ("[SSHConfiger]", s)
syslog.syslog("[SSHConfiger] " + s)
#
def complain(self, s):
#
syslog.syslog(syslog.LOG_ERR, "[SSHConfiger] " + s)
print("[SSHConfiger]", s, file=sys.stderr)
#
def die(self, s):
#
self.complain(s)
raise Exception(s)
def quit(self, s):
#
self.log("Quitting because: " + s)
sys.exit(0)
#
def run(self):
#
self.log("Running for interface \""+ self._action_interface +"\": " + self._action_command)
# Only run if an interface is coming up
if ( self._action_command != "up" ):
self.quit("We don't need to run for action command: " + str(self._action_command))
# Parse the config
self.parse_config()
# Grab the specified ssh directory
if ( "ssh_dir" not in self._config.keys() ):
self.die("Config file needs key \"ssh_dir\" inside \"config\" section")
ssh_dir = self._config["ssh_dir"]
self.log("SSH Dir: " + str(ssh_dir))
# Determine which ssh config file we should use
ssh_config_name = self.determine_ssh_config_name()
self.log("Determined ssh config name: " + str(ssh_config_name))
if not ssh_config_name:
self.die("Unable to determine appropriate ssh config name; Quitting")
# Make paths
ssh_config_path_link = os.path.join(ssh_dir, "config")
ssh_config_path_target = os.path.join(ssh_dir, ssh_config_name)
self.log("Selecting source config file \"" + ssh_config_path_target + "\" for link \"" + ssh_config_path_link + "\"")
# Don't run unless current ssh config is a symlink or not a file (for safety)
self.require_symlink_or_none(ssh_config_path_link)
# Set the symlink
try:
os.unlink(ssh_config_path_link)
except:
pass
os.symlink(ssh_config_path_target, ssh_config_path_link)
#
self.log("Finished")
#
def require_symlink_or_none(self, file_path):
#
if ( not os.path.isfile(file_path) or os.path.islink(file_path) ):
return True
#
self.die("For safety, we cannot continue if the target link exists and is a file (" + file_path + ")")
#
def determine_ssh_config_name(self):
#
self.log("Attempting to determine SSH config name")
# Check each section
found_ssh_config_name = None
for section_name in self._targets:
#
section = self._targets[section_name]
self.log("Examining section: " + str(section_name))
# Don't examine default if anything is picked already
if section_name == "default" and found_ssh_config_name:
self.log("Skipping default section because we've already found at least one match: " + str(found_ssh_config_name))
continue
# Check the interface
interface_matched = False
if (
# Matches, if current interface found in adapters
self._action_interface in section["adapters"]
# Can also match if we're in section "default"
or section_name == "default"
):
interface_matched = True
if not interface_matched:
self.log("Section \"" + str(section_name) + "\" didn't match any interfaces; Skipping")
continue
# Grab the SSID this adapter is currently connected to
interface_ssid = self.get_interface_ssid(self._action_interface)
if not interface_ssid:
self.log("Interface \"" + str(interface_ssid) + "\" isn't connected to anything ... ")
self.log("Interface \"" + str(self._action_interface) + "\" is currently connected to: \"" + str(interface_ssid) + "\"")
# Must also match at least one SSID,
# OR we're in the default section
if interface_ssid not in section["ssids"] and section_name != "default":
self.log("Did not find SSID \"" + interface_ssid + "\" in section ssids: " + str(section["ssids"]))
continue
# Found a match!
found_ssh_config_name = section["ssh_config_name"]
self.log("Found matching ssh config name: " + str(found_ssh_config_name))
# Didn't find anything? Go default ...
if (not found_ssh_config_name):
if ( "default" in self._targets.keys() ):
if ( "ssh_config_name" in self._targets["default"].keys() ):
found_ssh_config_name = self._targets["default"]["ssh_config_name"]
self.log("No matches found; Defaulting to:" + found_ssh_config_name)
#
return found_ssh_config_name
#
def get_interface_ssid(self, interface):
#
p = subprocess.Popen(["nmcli", "dev", "show", interface], stdout=subprocess.PIPE)
(stdout, stderr) = p.communicate()
stdout = stdout.decode()
#
r = re.compile("""^GENERAL.CONNECTION:\s+(?P<ssid>[^\s].+)$""", re.MULTILINE)
match = r.search(stdout)
return match.group("ssid")
#
def parse_config(self):
#
self.log("Parsing config: " + self._config_file_path)
parser = configparser.ConfigParser()
parser.read( self._config_file_path )
# Attempt to grab global config
if ( "config" not in parser.sections() ):
self.die("config section not found")
config = parser["config"]
# Targets
targets = {}
for section in parser.sections():
# Skip the config section
if ( section == "config" ):
continue
#
self.log("Parsing config target: \"" + section + "\"")
target = {
"adapters" : [],
"ssids" : []
}
# Adapters
if ( parser.has_option(section, "adapters") ):
target["adapters"] = json.loads( parser[section]["adapters"] )
if ( parser.has_option(section, "adapter") ):
target["adapters"].append( parser[section]["adapter"] )
# SSIDs
if ( parser.has_option(section, "ssids") ):
target["ssids"] = json.loads( parser[section]["ssids"] )
if ( parser.has_option(section, "ssid") ):
target["ssids"].append( parser[section]["ssid"] )
# ssh_config_name
if ( parser.has_option(section, "ssh_config_name") ):
target["ssh_config_name"] = parser[section]["ssh_config_name"]
else:
raise Exception("ssh_config_name key missing from section: " + section)
#
targets[section] = target
self.log("Parsed config for config target \"" + section + "\": " + str(target))
#
self._config = config
self._targets = targets
return True
# Main Entry
if (__name__ == "__main__"):
# Script name
script_name = sys.argv[0]
# Network Manager action info
action_interface = sys.argv[1]
action_command = sys.argv[2]
# Config file
config_file_path = sys.argv[3]
#
configger = SSHConfiger(action_interface, action_command, config_file_path)
configger.run()

20
domain/Logger.py Normal file
View File

@@ -0,0 +1,20 @@
import sys
import syslog
class Logger:
@staticmethod
def log(s):
print("[SSHConfiger]", s)
syslog.syslog("[SSHConfiger] " + s)
#
@staticmethod
def complain(s):
syslog.syslog(syslog.LOG_ERR, "[SSHConfiger] " + s)
print("[SSHConfiger]", s, file=sys.stderr)

196
domain/SSHConfigChanger.py Executable file
View File

@@ -0,0 +1,196 @@
from domain.config.Config import Config
from domain.config.Config import Target
from domain.Logger import Logger
import os
from pathlib import Path
import re
import subprocess
import sys
class SSHConfigChanger:
def __init__(
self,
action_interface, action_command,
config_file_path,
dry_run: bool = False,
):
self.__logger = Logger(
)
self.__config = Config(
logger=self.__logger,
file_path=config_file_path
)
if dry_run:
self.__logger.complain(f"Dry run enabled at runtime")
self.__config.dry_run = True
# Grab args
self.__action_interface = action_interface
self.__action_command = action_command
def die(self, s):
#
self.__logger.complain(s)
raise Exception(s)
def quit(self, s):
#
self.__logger.log("Quitting because: " + s)
sys.exit(0)
#
def run(self):
self.__logger.log(
f"Running for interface {self.__action_interface}: {self.__action_command}"
)
# Only run if an interface is coming up
if self.__action_command != "up":
self.quit(f"We don't need to run for action command: {self.__action_command}")
# Determine which ssh config file we should use
ssh_config_target = self.determine_ssh_config_target()
if ssh_config_target is None:
self.die("Unable to determine appropriate ssh config name; Quitting")
self.__logger.log(
f"Determined ssh config name: {ssh_config_target.name}"
)
# Make paths
ssh_config_path_link = self.__config.ssh_dir / self.__config.default_normal_ssh_config_file_name
ssh_config_path_target = self.__config.ssh_dir / ssh_config_target.ssh_config_file_name
self.__logger.log(
f"Selecting source config file \"{ssh_config_path_target}\""
f" for link \"{ssh_config_path_link}\""
)
# Don't run unless current ssh config is a symlink or not a file (for safety)
self.require_symlink_or_none(ssh_config_path_link)
if self.__config.dry_run:
self.__logger.complain(
f"Dry run enabled; Won't unlink existing symlink: {ssh_config_path_link}"
)
self.__logger.complain(
f"Dry run enabled; Won't create symlink: {ssh_config_path_link} ==> {ssh_config_path_target}"
)
else:
# Set the symlink
if ssh_config_path_link.exists():
try:
ssh_config_path_link.unlink()
except FileNotFoundError:
pass
ssh_config_path_link.symlink_to(ssh_config_path_target, target_is_directory=False)
#
self.__logger.log("Finished")
#
def require_symlink_or_none(self, file_path: Path):
if file_path.is_file() and file_path.exists() and not file_path.is_symlink():
self.die(
f"For safety, refusing to continue because the target ssh config file exists and is not a symlink:"
f" {file_path}"
)
def determine_ssh_config_target(self) -> Target:
#
self.__logger.log("Attempting to determine SSH config name")
# Start off by assuming the default target
# noinspection PyTypeChecker
selected_target = None
selected_target: Target
# Check each section
for target_name in self.__config.targets:
self.__logger.log(f"Examining target: {target_name}")
if selected_target is not None and target_name == selected_target.name:
self.__logger.log(f"Ignoring target because it is already selected: {target_name}")
continue
target = self.__config.targets[target_name]
# Matches, if current interface found in adapters
if self.__action_interface not in target.adapters:
self.__logger.log(
f"Target \"{target_name}\" didn't match any interfaces; Skipping"
)
continue
# Grab the SSID this adapter is currently connected to
interface_ssid = self.get_interface_ssid(self.__action_interface)
if not interface_ssid:
self.__logger.log(
f"Interface \"{interface_ssid}\" isn't connected to anything; Done looking"
)
break
self.__logger.log(
f"Interface \"{self.__action_interface}\" is currently connected to: \"{interface_ssid}\""
)
# Must also match at least one SSID
if interface_ssid in target.ssids:
self.__logger.log(
f"Found SSID \"{interface_ssid}\" in target {target_name}"
)
# Only override selected target if this one has less SSIDs,
# or there is no currently selected target
if selected_target is None:
self.__logger.log(
f"Found first suitable target: {target_name}"
)
selected_target = target
if len(target.ssids) < len(selected_target.ssids):
self.__logger.log(
f"Target \"{target_name}\""
f" seems to be a better match than \"{selected_target.name}\""
f" because it has fewer specified SSIDs"
f" ({len(target.ssids)} vs. {len(selected_target.ssids)})"
)
selected_target = target
if selected_target is None:
selected_target = self.__config.targets[self.__config.default_target_name]
self.__logger.log(f"No suitable target found; Defaulting to: {selected_target.name}")
return selected_target
@staticmethod
def get_interface_ssid(interface_name):
#
p = subprocess.Popen(["nmcli", "dev", "show", interface_name], stdout=subprocess.PIPE)
(stdout, stderr) = p.communicate()
stdout = stdout.decode()
#
r = re.compile(
pattern=r"^GENERAL.CONNECTION:\s+(?P<ssid>[^\s].+)$",
flags=re.MULTILINE
)
match = r.search(stdout)
return match.group("ssid")

278
domain/config/Config.py Normal file
View File

@@ -0,0 +1,278 @@
from domain.Logger import Logger
import os
from pathlib import Path
import yaml
class Target:
def __init__(self, logger: Logger, name):
self.__logger = logger
self.__name = name
self.__data = {}
self.__adapters_names = []
self.__ssids = []
# noinspection PyTypeChecker
self.__ssh_config_file_name: str = None
def __str__(self):
s = ""
s += f"Target: {self.__name}"
s += f"\n> SSH config file name: {self.__ssh_config_file_name}"
s += f"\n> Adapters: "
if len(self.__adapters_names) > 0:
s += ", ".join(self.__adapters_names)
else:
s += "[none]"
s += f"\n> SSIDs: "
if len(self.__ssids) > 0:
s += ", ".join(self.__ssids)
else:
s += "[none]"
return s
def log(self, s):
self.__logger.log(
f"[Target::{self.__name}] {s}"
)
def complain(self, s):
self.__logger.complain(
f"[Target::{self.__name}] {s}"
)
def consume_data(self, data: dict):
assert isinstance(data, dict), (
f"Data should be a dict (found: {type(data).__name__}) "
)
self.__data = data
assert "config-file-name" in self.__data.keys(), (
f"Name of ssh config file must be present at config-file-name"
)
config_file_name = self.__data["config-file-name"]
assert isinstance(config_file_name, str), (
f"config-file-name must be a string, but got: {type(config_file_name).__name__}"
)
self.__ssh_config_file_name = config_file_name
if "adapters" in self.__data.keys():
adapters = self.__data["adapters"]
if isinstance(adapters, list):
pass
elif isinstance(adapters, str):
adapters = [adapters]
else:
raise AssertionError(f"Unsupported adapters data type: {type(adapters).__name__}")
self.__adapters_names.extend(adapters)
if "adapter" in self.__data.keys():
adapters = self.__data["adapter"]
if isinstance(adapters, list):
pass
elif isinstance(adapters, str):
adapters = [adapters]
else:
raise AssertionError(f"Unsupported adapter data type: {type(adapters).__name__}")
self.__adapters_names.extend(adapters)
if "ssids" in self.__data.keys():
ssids = self.__data["ssids"]
if isinstance(ssids, list):
pass
elif isinstance(ssids, str):
ssids = [ssids]
else:
raise AssertionError(f"Unsupported ssids data type: {type(ssids).__name__}")
self.__ssids.extend(ssids)
if "ssid" in self.__data.keys():
ssids = self.__data["ssid"]
if isinstance(ssids, list):
pass
elif isinstance(ssids, str):
ssids = [ssids]
else:
raise AssertionError(f"Unsupported ssid data type: {type(ssids).__name__}")
self.__ssids.extend(ssids)
assert len(self.__adapters_names) > 0, (
f"At least one adapter must be configured at target-name::adapters"
)
@property
def name(self) -> str:
return self.__name
@property
def ssh_config_file_name(self) -> str:
return self.__ssh_config_file_name
@property
def adapters(self) -> list[str]:
return self.__adapters_names
@property
def ssids(self) -> list[str]:
return self.__ssids
class Config:
__DEFAULT_NORMAL_SSH_CONFIG_FILE_NAME = "config"
__DEFAULT_SSH_DIRECTORY_NAME = ".ssh"
def __init__(self, logger: Logger, file_path: str):
self.__logger = logger
if isinstance(file_path, str):
file_path = Path(file_path)
elif isinstance(file_path, Path):
pass
else:
raise AssertionError("File path should be a string or Path object")
self.__file_path = file_path
self.__data = {}
self.__dry_run = False
self.__ssh_dir = Path(os.path.expanduser("~")) / self.__DEFAULT_SSH_DIRECTORY_NAME
# noinspection PyTypeChecker
self.__default_target_name: str = None
self.__targets = {}
self._load_config()
self._consume_config()
print(self)
def __str__(self):
s = ""
s += "*** Config ***"
s += "\n Dry run: " + "True" if self.__dry_run else "False"
for target in self.__targets.values():
s += "\n" + str(target)
return s
def _load_config(self):
assert self.__file_path.exists(), "Config file must exist"
with open(self.__file_path) as f:
self.__data = yaml.safe_load(f)
def _consume_config(self):
assert isinstance(self.__data, dict), (
f"Config data must be a dict"
)
assert "options" in self.__data.keys(), (
f"Options key missing from config"
)
options = self.__data["options"]
assert isinstance(options, dict), "Config options must be a dict!"
if "dry-run" in options.keys():
d = options["dry-run"]
assert isinstance(d, bool), "options::dry-run must be a bool"
if d:
self.__logger.complain(f"Dry run enabled in config")
self.__dry_run = d
if "ssh-dir" in options.keys():
ssh_dir = Path(options["ssh-dir"])
assert ssh_dir.exists(), f"options::ssh-dir must be a valid directory"
self.__ssh_dir = ssh_dir
self.__logger.log(f"Found ssh dir: {self.__ssh_dir}")
else:
self.__logger.log(f"options::ssh-dir not found")
assert "default-target" in options.keys(), (
f"Must specify the name of the default target at options::default-target"
)
default_target_name = options["default-target"]
assert isinstance(default_target_name, str), (
f"Default target name must be a string but got: {type(default_target_name).__name__}"
)
self.__default_target_name = default_target_name
self.__targets = {}
assert "targets" in self.__data.keys(), "Config should specify targets"
targets = self.__data["targets"]
assert isinstance(targets, dict), "Targets should be a dict, where each key is one target"
for target_name in targets.keys():
self.__logger.log(f"Parsing target: {target_name}")
try:
t = Target(
logger=self.__logger,
name=target_name,
)
t.consume_data(data=targets[target_name])
except AssertionError as e:
self.__logger.complain(
f"Failed to parse target \"{target_name}\""
f"\n{e}"
)
raise e
self.__targets[target_name] = t
if self.__default_target_name not in self.__targets.keys():
raise AssertionError(
f"Default target specified as {self.__default_target_name} but was not found in dict of targets"
)
@property
def default_normal_ssh_config_file_name(self) -> str:
return self.__DEFAULT_NORMAL_SSH_CONFIG_FILE_NAME
@property
def file_path(self) -> Path:
return self.__file_path
@property
def dry_run(self) -> bool:
return self.__dry_run
@dry_run.setter
def dry_run(self, b: bool):
self.__dry_run = b
@property
def default_target_name(self) -> str:
return self.__default_target_name
@property
def ssh_dir(self) -> Path | None:
return self.__ssh_dir
@property
def targets(self) -> [Target]:
return self.__targets

41
init-environment Executable file
View File

@@ -0,0 +1,41 @@
#!/bin/bash
log()
{
echo "[Connection Specific SSH Config - Init Env] $1"
}
complain()
{
echo "[Connection Specific SSH Config - Init Env] $1" 1>&2
}
die()
{
complain "Fatal: $1"
exit 1
}
SCRIPT_PATH=$(readlink -f "$0")
SCRIPT_DIR=$(dirname "$SCRIPT_PATH")
SCRIPT_NAME=$(basename "$SCRIPT_PATH")
log "Begin ${SCRIPT_NAME}"
log "Script path: ${SCRIPT_PATH}"
log "Script dir: ${SCRIPT_DIR}"
log "PATH: ${PATH}"
log "PWD before switching: $(pwd)"
cd "${SCRIPT_DIR}" || die "Failed to switch to project directory: ${SCRIPT_DIR}"
log "PWD after switching: $(pwd)"
log "Ensuring python installation with pyenv"
pyenv versions
pyenv install --skip-existing || die "Failed to ensure python installation with pyenv"
log "Installing/upgrading pip and pipenv"
pip install --upgrade pip pipenv || die "Failed to install/upgrade pip and pipenv"
log "Syncing pip environment with pipenv"
pipenv --rm # Don't die because this will return an error if the env didn't already exist
pipenv install || die "Failed to install pip environment with pipenv"
pipenv sync || die "Failed to sync pip environment with pipenv"

58
main.py Normal file
View File

@@ -0,0 +1,58 @@
from domain.SSHConfigChanger import SSHConfigChanger
import argparse
def main():
parser = argparse.ArgumentParser(
prog="Mike's SSH Config Changer"
)
parser.add_argument(
"--dry-run", "-d",
dest="dry_run",
default=False,
action="store_true",
help="Pass this flag to print what would be changed, without actually changing anything"
)
parser.add_argument(
"--config", "-c",
dest="config_file",
required=True,
help="Specify path to configuration file",
)
parser.add_argument(
"--interface", "--action-interface",
dest="action_interface",
required=True,
help="Specify the interface specified by NetworkManager's action"
)
parser.add_argument(
"--command", "--action-command",
dest="action_command",
required=True,
help="Specify the command specified by NetworkManager's action"
)
args = parser.parse_args()
configger = SSHConfigChanger(
dry_run=args.dry_run,
action_interface=args.action_interface,
action_command=args.action_command,
config_file_path=args.config_file,
)
configger.run()
# Main Entry
if __name__ == "__main__":
main()