├── .ci └── versioning.py ├── .github ├── renovate.json └── workflows │ ├── build.yaml │ └── renovate.yaml ├── .gitignore ├── .python-version ├── LICENSE ├── Makefile ├── README.md ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── sshconf.py └── tests ├── include_svu_host ├── test_config ├── test_config2 ├── test_config_include_specific └── test_sshconf.py /.ci/versioning.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import versiontag 4 | 5 | version = versiontag.get_version(pypi=True).strip() 6 | 7 | with open("sshconf.py", "r") as f: 8 | text = f.read() 9 | 10 | text = text.replace("0.0.dev0", version) 11 | 12 | with open("sshconf.py", "w") as f: 13 | f.write(text) 14 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "baseBranches": ["master"], 3 | "rebaseWhen": "conflicted", 4 | "labels": ["dependencies"], 5 | "automergeStrategy": "merge-commit", 6 | "prHourlyLimit": 2 7 | } 8 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: CI_CD 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | concurrency: 8 | group: >- 9 | ${{ github.workflow }}- 10 | ${{ github.ref_type }}- 11 | ${{ github.event.pull_request.number || github.sha }} 12 | cancel-in-progress: true 13 | 14 | env: 15 | FLIT_USERNAME: ${{ secrets.FLIT_USERNAME }} 16 | FLIT_PASSWORD: ${{ secrets.FLIT_PASSWORD }} 17 | FLIT_INDEX_URL: ${{ secrets.FLIT_INDEX_URL }} 18 | 19 | jobs: 20 | test: 21 | runs-on: ${{ matrix.platform }} 22 | strategy: 23 | matrix: 24 | platform: ["ubuntu-latest"] 25 | python-version: ["3.12"] 26 | steps: 27 | - uses: actions/checkout@v4 28 | with: 29 | fetch-depth: 0 30 | 31 | - name: Setup Python ${{ matrix.python-version }} 32 | uses: actions/setup-python@v5 33 | with: 34 | python-version: ${{ matrix.python-version }} 35 | 36 | - name: Run tests 37 | run: | 38 | python -m pip install --upgrade pip 39 | make version test 40 | 41 | deploy: 42 | runs-on: "ubuntu-latest" 43 | needs: test 44 | strategy: 45 | matrix: 46 | platform: ["ubuntu-latest"] 47 | python-version: ["3.12"] 48 | steps: 49 | - uses: actions/checkout@v4 50 | with: 51 | fetch-depth: 0 52 | 53 | - name: Setup Python ${{ matrix.python-version }} 54 | uses: actions/setup-python@v5 55 | with: 56 | python-version: ${{ matrix.python-version }} 57 | 58 | - name: Dynamic versioning 59 | run: | 60 | make version build 61 | 62 | - name: Publish testpypi 63 | run: | 64 | make publish 65 | 66 | - name: Publish pypi 67 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') 68 | run: | 69 | export FLIT_INDEX_URL=${{ secrets.PROD_FLIT_INDEX_URL }} 70 | export FLIT_USERNAME=${{ secrets.PROD_FLIT_USERNAME }} 71 | export FLIT_PASSWORD=${{ secrets.PROD_FLIT_PASSWORD }} 72 | make publish 73 | -------------------------------------------------------------------------------- /.github/workflows/renovate.yaml: -------------------------------------------------------------------------------- 1 | 2 | name: Renovate 3 | on: 4 | schedule: 5 | # The "*" (#42, asterisk) character has special semantics in YAML, so this 6 | # string has to be quoted. 7 | - cron: '0 0 * * *' 8 | 9 | concurrency: renovate 10 | jobs: 11 | renovate: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4.1.6 16 | - name: Validate Renovate JSON 17 | run: jq type .github/renovate.json 18 | - uses: actions/create-github-app-token@v1 19 | id: app-token 20 | with: 21 | app-id: ${{ secrets.APP_ID }} 22 | private-key: ${{ secrets.PRIVATE_KEY }} 23 | - name: Self-hosted Renovate 24 | uses: renovatebot/github-action@v40.1.12 25 | env: 26 | # Repository taken from variable to keep configuration file generic 27 | RENOVATE_REPOSITORIES: ${{ github.repository }} 28 | # Onboarding not needed for self hosted 29 | RENOVATE_ONBOARDING: "false" 30 | # Username for GitHub authentication (should match GitHub App name + [bot]) 31 | RENOVATE_USERNAME: "sorend-renovate-bot[bot]" 32 | # Git commit author used, must match GitHub App 33 | RENOVATE_GIT_AUTHOR: "sorend-renovate-bot <12314142+sorend-renovate-bot[bot]@users.noreply.github.com>" 34 | # Use GitHub API to create commits (this allows for signed commits from GitHub App) 35 | RENOVATE_PLATFORM_COMMIT: "true" 36 | # Override schedule if set 37 | RENOVATE_FORCE: ${{ github.event.inputs.overrideSchedule == 'true' && '{''schedule'':null}' || '' }} 38 | LOG_LEVEL: ${{ inputs.logLevel || 'info' }} 39 | with: 40 | configurationFile: .github/renovate.json 41 | renovate-version: full 42 | token: ${{ steps.app-token.outputs.token }} 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | __pycache__ 3 | venv 4 | .cache 5 | *.egg-info 6 | .eggs 7 | dist 8 | build 9 | .pytest_cache 10 | .coverage 11 | coverage.xml 12 | *~ 13 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | sshconf 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Søren A. Davidsen 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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | FLIT_INDEX_URL ?= https://test.pypi.org/legacy/ 3 | FLIT_USERNAME = __token__ 4 | FLIT_PASSWORD ?= dummy 5 | 6 | all: build 7 | 8 | deps: 9 | pip install versiontag flit 10 | 11 | version: deps 12 | python .ci/versioning.py 13 | 14 | local_install: deps 15 | pip install -e '.[test]' 16 | 17 | wheel: local_install 18 | flit build --format wheel 19 | flit build --format sdist 20 | 21 | build: wheel 22 | 23 | publish: 24 | @FLIT_USERNAME=$(FLIT_USERNAME) FLIT_PASSWORD=$(FLIT_PASSWORD) FLIT_INDEX_URL=$(FLIT_INDEX_URL) flit publish --format wheel 25 | @FLIT_USERNAME=$(FLIT_USERNAME) FLIT_PASSWORD=$(FLIT_PASSWORD) FLIT_INDEX_URL=$(FLIT_INDEX_URL) flit publish --format sdist 26 | 27 | test: local_install 28 | tox 29 | 30 | clean: 31 | rm -rf dist .pytest_cache build .eggs fylearn.egg-info htmlcov .tox *.whl *~ 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | sshconf 3 | =========== 4 | 5 | [![PyPI version](https://badge.fury.io/py/sshconf.svg)](https://pypi.org/project/sshconf/) 6 | [![Build Status](https://github.com/sorend/sshconf/actions/workflows/build.yaml/badge.svg)](https://github.com/sorend/sshconf/actions/workflows/build.yaml) 7 | [![codecov](https://codecov.io/gh/sorend/sshconf/branch/master/graph/badge.svg)](https://codecov.io/gh/sorend/sshconf) 8 | 9 | 10 | Sshconf is a library for reading and modifying your ssh/config file in a non-intrusive way, meaning 11 | your file should look more or less the same after modifications. Idea is to keep it simple, 12 | so you can modify it for your needs. 13 | 14 | Read more about ssh config files here: [Create SSH config file on Linux](https://www.cyberciti.biz/faq/create-ssh-config-file-on-linux-unix/) 15 | 16 | 17 | Installation and usage 18 | --------------------------- 19 | 20 | Install through pip is the most easy way. You can install from the Git source directly: 21 | 22 | ```bash 23 | pip install sshconf 24 | ``` 25 | 26 | Below is some example use: 27 | 28 | ```python 29 | from sshconf import read_ssh_config, empty_ssh_config_file 30 | from os.path import expanduser 31 | 32 | c = read_ssh_config(expanduser("~/.ssh/config")) 33 | print("hosts", c.hosts()) 34 | 35 | # assuming you have a host "svu" 36 | print("svu host", c.host("svu")) # print the settings 37 | c.set("svu", Hostname="ssh.svu.local", Port=1234) 38 | print("svu host now", c.host("svu")) 39 | c.unset("svu", "port") 40 | print("svu host now", c.host("svu")) 41 | 42 | c.add("newsvu", Hostname="ssh-new.svu.local", Port=22, User="stud1234", 43 | RemoteForward=["localhost:2022 localhost:22", "localhost:2025 localhost:25"]) 44 | print("newsvu", c.host("newsvu")) 45 | 46 | c.add("oldsvu", before_host="newsvu", Hostname="ssh-old.svu.local", Port=22, User="Stud1234") 47 | 48 | c.rename("newsvu", "svu-new") 49 | print("svu-new", c.host("svu-new")) 50 | 51 | # overwrite existing file(s) 52 | c.save() 53 | 54 | # write all to a new file 55 | c.write(expanduser("~/.ssh/newconfig")) 56 | 57 | # creating a new config file. 58 | c2 = empty_ssh_config_file() 59 | c2.add("svu", Hostname="ssh.svu.local", User="teachmca", Port=22) 60 | c2.write("newconfig") 61 | 62 | c2.remove("svu") # remove 63 | ``` 64 | 65 | A few things to note: 66 | - `save()` overwrites the files you read from. 67 | - `write()` writes a new config file. If you used `Include` in the read configuration, output will contain everything in one file. 68 | - indent for new lines is auto-probed from existing config lines, and defaults to two spaces. 69 | 70 | 71 | About 72 | ----- 73 | 74 | sshconf is created at the Department of Computer Science at Sri Venkateswara University, Tirupati, INDIA by a student as part of his projects. 75 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "sshconf" 3 | authors = [ 4 | {name = "Søren A D", email = "soren@hamisoke.com"}, 5 | ] 6 | dependencies = [] 7 | license = {file = "LICENSE"} 8 | dynamic = ["version", "description"] 9 | classifiers = [ 10 | 'License :: OSI Approved :: MIT License', 11 | 'Programming Language :: Python :: 3', 12 | 'Topic :: Software Development :: Libraries :: Python Modules', 13 | 'Topic :: Software Development :: Libraries', 14 | ] 15 | requires-python = ">=3.5" 16 | readme = "README.md" 17 | keywords = [ 18 | 'ssh', 'config' 19 | ] 20 | 21 | [project.urls] 22 | homepage = "https://github.com/sorend/sshconf" 23 | 24 | [project.optional-dependencies] 25 | test = [ 26 | "tox", 27 | "tox-gh-actions", 28 | ] 29 | 30 | [build-system] 31 | requires = ["flit_core >=3.2,<4"] 32 | build-backend = "flit_core.buildapi" 33 | 34 | [tool.flit.sdist] 35 | exclude = [".gitignore", ".github", ".ci", "codecov.yml", "Makefile"] 36 | 37 | [tool.pytest.ini_options] 38 | addopts = "-v --cov-fail-under=60 --cov=sshconf" 39 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | 2 | [pytest] 3 | addopts = --cov-fail-under=60 --cov=sshconf 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [aliases] 5 | test = pytest 6 | 7 | [flake8] 8 | max-line-length = 120 9 | exclude = .git, setup.py, __pycache__, tests 10 | 11 | [tox:tox] 12 | envlist = clean, py312, coverage 13 | 14 | [gh-actions] 15 | python = 16 | 3.12: py312, coverage 17 | 18 | [testenv] 19 | deps = 20 | pytest 21 | pytest-cov 22 | commands = pytest {posargs} 23 | 24 | [testenv:clean] 25 | deps = coverage 26 | skip_install = true 27 | commands = coverage erase 28 | 29 | [testenv:coverage] 30 | passenv = TOXENV,CI,GITHUB,GITHUB_*,CODECOV_* 31 | deps = codecov 32 | skip_install = true 33 | commands = codecov 34 | -------------------------------------------------------------------------------- /sshconf.py: -------------------------------------------------------------------------------- 1 | """Lightweight SSH config library.""" 2 | 3 | __version__ = "0.0.dev0" 4 | 5 | import os 6 | import re 7 | import glob 8 | from collections import defaultdict, Counter 9 | 10 | 11 | # taken from "man ssh" 12 | KNOWN_PARAMS = ( 13 | "AddKeysToAgent", 14 | "AddressFamily", 15 | "BatchMode", 16 | "BindAddress", 17 | "CanonicalDomains", 18 | "CanonicalizeFallbackLocal", 19 | "CanonicalizeHostname", 20 | "CanonicalizeMaxDots", 21 | "CanonicalizePermittedCNAMEs", 22 | "CertificateFile", 23 | "ChallengeResponseAuthentication", 24 | "CheckHostIP", 25 | "Cipher", 26 | "Ciphers", 27 | "ClearAllForwardings", 28 | "Compression", 29 | "CompressionLevel", 30 | "ConnectionAttempts", 31 | "ConnectTimeout", 32 | "ControlMaster", 33 | "ControlPath", 34 | "ControlPersist", 35 | "DynamicForward", 36 | "EscapeChar", 37 | "ExitOnForwardFailure", 38 | "FingerprintHash", 39 | "ForwardAgent", 40 | "ForwardX11", 41 | "ForwardX11Timeout", 42 | "ForwardX11Trusted", 43 | "GatewayPorts", 44 | "GlobalKnownHostsFile", 45 | "GSSAPIAuthentication", 46 | "GSSAPIKeyExchange", 47 | "GSSAPIClientIdentity", 48 | "GSSAPIDelegateCredentials", 49 | "GSSAPIRenewalForcesRekey", 50 | "GSSAPITrustDns", 51 | "GSSAPIKexAlgorithms", 52 | "HashKnownHosts", 53 | "Host", 54 | "HostbasedAuthentication", 55 | "HostbasedKeyTypes", 56 | "HostKeyAlgorithms", 57 | "HostKeyAlias", 58 | "HostName", 59 | "IdentitiesOnly", 60 | "IdentityAgent", 61 | "IdentityFile", 62 | "Include", 63 | "IPQoS", 64 | "KbdInteractiveAuthentication", 65 | "KbdInteractiveDevices", 66 | "KexAlgorithms", 67 | "LocalCommand", 68 | "LocalForward", 69 | "LogLevel", 70 | "MACs", 71 | "Match", 72 | "NoHostAuthenticationForLocalhost", 73 | "NumberOfPasswordPrompts", 74 | "PasswordAuthentication", 75 | "PermitLocalCommand", 76 | "PKCS11Provider", 77 | "Port", 78 | "PreferredAuthentications", 79 | "Protocol", 80 | "ProxyCommand", 81 | "ProxyJump", 82 | "ProxyUseFdpass", 83 | "PubkeyAcceptedKeyTypes", 84 | "PubkeyAuthentication", 85 | "RekeyLimit", 86 | "RemoteForward", 87 | "RequestTTY", 88 | "RhostsRSAAuthentication", 89 | "RSAAuthentication", 90 | "SendEnv", 91 | "ServerAliveInterval", 92 | "ServerAliveCountMax", 93 | "StreamLocalBindMask", 94 | "StreamLocalBindUnlink", 95 | "StrictHostKeyChecking", 96 | "TCPKeepAlive", 97 | "Tunnel", 98 | "TunnelDevice", 99 | "UpdateHostKeys", 100 | "UsePrivilegedPort", 101 | "User", 102 | "UserKnownHostsFile", 103 | "VerifyHostKeyDNS", 104 | "VisualHostKey", 105 | "XAuthLocation" 106 | ) 107 | 108 | known_params = [x.lower() for x in KNOWN_PARAMS] 109 | 110 | 111 | class ConfigLine: 112 | """Holds configuration for a line in ssh config.""" 113 | 114 | def __init__(self, line, host=None, key=None, value=None): 115 | self.line = line 116 | self.host = host 117 | self.key = key 118 | self.value = value 119 | 120 | def __repr__(self): 121 | return "'%s' host=%s key=%s value=%s" % (self.line, self.host, self.key, self.value) 122 | 123 | 124 | def read_ssh_config_file(path): 125 | """ 126 | Read ssh config file and return parsed SshConfigFile 127 | """ 128 | with open(path, "r") as fh_: 129 | lines = fh_.read().splitlines() 130 | return SshConfigFile(lines) 131 | 132 | 133 | def empty_ssh_config_file(): 134 | """ 135 | Creates a new empty ssh configuration. 136 | """ 137 | return SshConfigFile([]) 138 | 139 | 140 | def _key_value(line): 141 | upto_comment = line.split("#")[0] 142 | return [x.strip() for x in re.split(r"\s+", upto_comment.strip(), 1)] 143 | 144 | 145 | def _remap_key(key): 146 | """ Change key into correct casing if we know the parameter """ 147 | if key in KNOWN_PARAMS: 148 | return key 149 | if key.lower() in known_params: 150 | return KNOWN_PARAMS[known_params.index(key.lower())] 151 | return key 152 | 153 | 154 | def _indent(s): 155 | return s[0: len(s) - len(s.lstrip())] 156 | 157 | 158 | def _find_insert_idx(before_host, lines): 159 | first_host_idx = next(idx for idx, x in enumerate(lines) if x.host == before_host) 160 | for i in reversed(range(first_host_idx)): 161 | if lines[i].host is not None: 162 | return i+1 163 | return 0 164 | 165 | 166 | class SshConfigFile(object): 167 | """ 168 | Class for manipulating SSH configuration. 169 | """ 170 | 171 | def __init__(self, lines): 172 | self.lines_ = [] 173 | self.hosts_ = [] 174 | self.parse(lines) 175 | 176 | def parse(self, lines): 177 | """Parse lines from ssh config file""" 178 | cur_entry = None 179 | indents = [] 180 | for line in lines: 181 | kv_ = _key_value(line) 182 | if len(kv_) > 1: 183 | key, value = kv_ 184 | if key.lower() == "host": 185 | cur_entry = value 186 | self.hosts_.append(value) 187 | else: 188 | indents.append(_indent(line)) 189 | self.lines_.append(ConfigLine(line=line, host=cur_entry, key=key, value=value)) 190 | else: 191 | self.lines_.append(ConfigLine(line=line)) 192 | # use most popular indent as indent for file, default ' ' 193 | counter = Counter(indents) 194 | popular = list(reversed(sorted(counter.items(), key=lambda e: e[1]))) 195 | self.indent = popular[0][0] if len(popular) > 0 else ' ' 196 | 197 | def hosts(self): 198 | """ 199 | Return the hosts found in the configuration. 200 | 201 | Returns 202 | ------- 203 | Tuple of Host entries (including "*" if found) 204 | """ 205 | return tuple(self.hosts_) 206 | 207 | def host(self, host): 208 | """ 209 | Return the configuration of a specific host as a dictionary. 210 | 211 | Dictionary always contains lowercase versions of the attribute names. 212 | 213 | Parameters 214 | ---------- 215 | host : the host to return values for. 216 | 217 | Returns 218 | ------- 219 | dict of key value pairs, excluding "Host", empty map if host is not found. 220 | """ 221 | if host in self.hosts_: 222 | vals = defaultdict(list) 223 | for k, value in [(x.key.lower(), x.value) for x in self.lines_ 224 | if x.host == host and x.key.lower() != "host"]: 225 | vals[k].append(value) 226 | 227 | def flatten(x): 228 | return x[0] if len(x) == 1 else x 229 | return {k: flatten(v) for k, v in vals.items()} 230 | return {} 231 | 232 | def set(self, host, **kwargs): 233 | """ 234 | Set configuration values for an existing host. 235 | Overwrites values for existing settings, or adds new settings. 236 | 237 | Parameters 238 | ---------- 239 | host : the Host to modify. 240 | **kwargs : The new configuration parameters 241 | """ 242 | self.__check_host_args(host, kwargs) 243 | 244 | for key, values in kwargs.items(): 245 | if type(values) not in [list, tuple]: # pylint: disable=unidiomatic-typecheck 246 | values = [values] 247 | 248 | lower_key = key.lower() 249 | update_idx = [idx for idx, x in enumerate(self.lines_) 250 | if x.host == host and x.key.lower() == lower_key] 251 | extra_remove = [] 252 | for idx in update_idx: 253 | if values: # values available, update the line 254 | value = values.pop() 255 | self.lines_[idx].line = self._new_line(self.lines_[idx].key, value) 256 | self.lines_[idx].value = value 257 | else: # no more values available, remove the line 258 | extra_remove.append(idx) 259 | 260 | for idx in reversed(sorted(extra_remove)): 261 | del self.lines_[idx] 262 | 263 | if values: 264 | mapped_key = _remap_key(key) 265 | max_idx = max([idx for idx, line in enumerate(self.lines_) if line.host == host]) 266 | for value in values: 267 | self.lines_.insert(max_idx + 1, ConfigLine(line=self._new_line(mapped_key, value), 268 | host=host, key=mapped_key, 269 | value=value)) 270 | 271 | def unset(self, host, *args): 272 | """ 273 | Removes settings for a host. 274 | 275 | Parameters 276 | ---------- 277 | host : the host to remove settings from. 278 | *args : list of settings to removes. 279 | """ 280 | self.__check_host_args(host, args) 281 | remove_idx = [idx for idx, x in enumerate(self.lines_) 282 | if x.host == host and x.key.lower() in args] 283 | for idx in reversed(sorted(remove_idx)): 284 | del self.lines_[idx] 285 | 286 | def __check_host_args(self, host, keys): 287 | """Checks parameters""" 288 | if host not in self.hosts_: 289 | raise ValueError("Host %s: not found" % host) 290 | 291 | if "host" in [x.lower() for x in keys]: 292 | raise ValueError("Cannot modify Host value") 293 | 294 | def rename(self, old_host, new_host): 295 | """ 296 | Renames a host configuration. 297 | 298 | Parameters 299 | ---------- 300 | old_host : the host to rename. 301 | new_host : the new host value 302 | """ 303 | if new_host in self.hosts_: 304 | raise ValueError("Host %s: already exists." % new_host) 305 | for line in self.lines_: # update lines 306 | if line.host == old_host: 307 | line.host = new_host 308 | if line.key.lower() == "host": 309 | line.value = new_host 310 | line.line = "Host %s" % new_host 311 | self.hosts_[self.hosts_.index(old_host)] = new_host # update host cache 312 | 313 | def add(self, host, before_host=None, **kwargs): 314 | """ 315 | Add another host to the SSH configuration. 316 | 317 | Parameters 318 | ---------- 319 | host: The Host entry to add. 320 | **kwargs: The parameters for the host (without "Host" parameter itself) 321 | """ 322 | if host in self.hosts_: 323 | raise ValueError("Host %s: exists (use update)." % host) 324 | 325 | if before_host is not None and before_host not in self.hosts_: 326 | raise ValueError("Host %s: not found, cannot insert before it." % host) 327 | 328 | def kwargs_to_lines(kv): 329 | lines = [] 330 | for k, v in kv.items(): 331 | if type(v) not in [list, tuple]: 332 | v = [v] 333 | mapped_k = _remap_key(k) 334 | for value in v: 335 | new_line = self._new_line(mapped_k, value) 336 | lines.append(ConfigLine(line=new_line, host=host, key=mapped_k, value=value)) 337 | return lines 338 | 339 | new_lines = [ 340 | ConfigLine(line="", host=None), 341 | ConfigLine(line="Host %s" % host, host=host, key="Host", value=host) 342 | ] + kwargs_to_lines(kwargs) + [ 343 | ConfigLine(line="", host=None) 344 | ] 345 | 346 | if before_host is not None: 347 | insert_idx = _find_insert_idx(before_host, self.lines_) 348 | for idx, l in enumerate(new_lines): 349 | self.lines_.insert(insert_idx + idx, l) 350 | self.hosts_.insert(self.hosts_.index(before_host), host) 351 | else: 352 | self.lines_.extend(new_lines) 353 | self.hosts_.append(host) 354 | 355 | def remove(self, host): 356 | """ 357 | Removes a host from the SSH configuration. 358 | 359 | Parameters 360 | ---------- 361 | host : The host to remove 362 | """ 363 | if host not in self.hosts_: 364 | raise ValueError("Host %s: not found." % host) 365 | # remove lines, including comments inside the host lines 366 | host_lines = [idx for idx, x in enumerate(self.lines_) if x.host == host] 367 | remove_range = reversed(range(min(host_lines), max(host_lines) + 1)) 368 | for idx in remove_range: 369 | del self.lines_[idx] 370 | self.hosts_.remove(host) 371 | 372 | def config(self, filter_includes=False): 373 | """ 374 | Return the configuration as a string. 375 | """ 376 | def the_filter(k): 377 | if filter_includes and k is not None and k.lower() == "include": 378 | return False 379 | else: 380 | return True 381 | 382 | return "\n".join([x.line for x in self.lines_ if the_filter(x.key)]) 383 | 384 | def write(self, path): 385 | """ 386 | Writes ssh config file 387 | 388 | Parameters 389 | ---------- 390 | path : The file to write to 391 | """ 392 | with open(path, "w") as fh_: 393 | fh_.write(self.config()) 394 | 395 | def _new_line(self, key, value): 396 | return "%s%s %s" % (self.indent, key, str(value)) 397 | 398 | 399 | def _resolve_includes(base_path, path): 400 | search_path = os.path.join(base_path, os.path.expanduser(path)) 401 | return glob.glob(search_path) 402 | 403 | 404 | def read_ssh_config(master_path): 405 | """Read SSH config from master file and process include directives""" 406 | base_path = os.path.dirname(master_path) 407 | master_config = read_ssh_config_file(master_path) 408 | configs = [] 409 | queue = [(master_path, master_config)] 410 | while len(queue) > 0: 411 | cur_path, cur_config = queue.pop() 412 | cur_includes = [x.value for x in cur_config.lines_ if x.key is not None and x.key.lower() == "include"] 413 | configs.append((cur_path, cur_config)) 414 | for cur_include in cur_includes: 415 | for new_path in _resolve_includes(base_path, cur_include): 416 | new_config = read_ssh_config_file(new_path) 417 | queue.append((new_path, new_config)) 418 | 419 | return SshConfig(configs) 420 | 421 | 422 | class SshConfig(object): 423 | """Class for manipulating set of ssh config files""" 424 | 425 | def __init__(self, configs): 426 | self.configs_ = configs 427 | 428 | def hosts(self): 429 | """ 430 | Return the hosts found in the configuration. 431 | 432 | Returns 433 | ------- 434 | Tuple of Host entries (including "*" if found) 435 | """ 436 | hosts = [] 437 | for p, c in self.configs_: 438 | hosts.extend(c.hosts()) 439 | return tuple(hosts) 440 | 441 | def host(self, host): 442 | """ 443 | Return the configuration of a specific host as a dictionary. 444 | 445 | Dictionary always contains lowercase versions of the attribute names. 446 | 447 | Parameters 448 | ---------- 449 | host : the host to return values for. 450 | 451 | Returns 452 | ------- 453 | dict of key value pairs, excluding "Host", empty map if host is not found. 454 | """ 455 | for p, c in self.configs_: 456 | if host in c.hosts_: 457 | return c.host(host) 458 | return {} 459 | 460 | def set(self, host, **kwargs): 461 | """ 462 | Set configuration values for an existing host. 463 | Overwrites values for existing settings, or adds new settings. 464 | 465 | Parameters 466 | ---------- 467 | host : the Host to modify. 468 | **kwargs : The new configuration parameters 469 | """ 470 | for p, c in self.configs_: 471 | if host in c.hosts_: 472 | c.set(host, **kwargs) 473 | return 474 | raise ValueError("Host %s: not found" % host) 475 | 476 | def unset(self, host, *args): 477 | """ 478 | Removes settings for a host. 479 | 480 | Parameters 481 | ---------- 482 | host : the host to remove settings from. 483 | *args : list of settings to removes. 484 | """ 485 | for p, c in self.configs_: 486 | if host in c.hosts_: 487 | c.unset(host, *args) 488 | return 489 | raise ValueError("Host %s: not found" % host) 490 | 491 | def rename(self, old_host, new_host): 492 | """ 493 | Renames a host configuration. 494 | 495 | Parameters 496 | ---------- 497 | old_host : the host to rename. 498 | new_host : the new host value 499 | """ 500 | if new_host in self.hosts(): 501 | raise ValueError("Host %s: already exists." % new_host) 502 | for p, c in self.configs_: 503 | if old_host in c.hosts_: 504 | c.rename(old_host, new_host) 505 | 506 | def add(self, host, before_host=None, **kwargs): 507 | """ 508 | Add another host to the SSH configuration. 509 | 510 | Parameters 511 | ---------- 512 | host: The Host entry to add. 513 | **kwargs: The parameters for the host (without "Host" parameter itself) 514 | """ 515 | self.configs_[0][1].add(host, before_host=before_host, **kwargs) 516 | 517 | def remove(self, host): 518 | """ 519 | Removes a host from the SSH configuration. 520 | 521 | Parameters 522 | ---------- 523 | host : The host to remove 524 | """ 525 | for p, c in self.configs_: 526 | if host in c.hosts_: 527 | c.remove(host) 528 | return 529 | raise ValueError("Host %s: not found" % host) 530 | 531 | def config(self): 532 | """ 533 | Return the configuration as a string (without includes). 534 | """ 535 | return "\n".join([c.config(True) for p, c in self.configs_]) 536 | 537 | def write(self, path): 538 | """ 539 | Write configuration to a new files 540 | 541 | Parameters 542 | ---------- 543 | path : The file to write to 544 | """ 545 | with open(path, "w") as fh_: 546 | fh_.write(self.config()) 547 | 548 | def save(self): 549 | """ 550 | Saves (updated) ssh configuration 551 | """ 552 | for p, c in self.configs_: 553 | c.write(p) 554 | -------------------------------------------------------------------------------- /tests/include_svu_host: -------------------------------------------------------------------------------- 1 | # comment 2 2 | Host svuincluded 3 | Hostname www.svuniversity.ac.in 4 | # within-host-comment 5 | Port 22 6 | ProxyCommand nc -w 300 -x localhost:9050 %h %p 7 | -------------------------------------------------------------------------------- /tests/test_config: -------------------------------------------------------------------------------- 1 | 2 | # comment 3 | Host * 4 | User something 5 | 6 | # comment 2 7 | Host svu 8 | Hostname www.svuniversity.ac.in 9 | # within-host-comment 10 | Port 22 11 | ProxyCommand nc -w 300 -x localhost:9050 %h %p 12 | 13 | # another comment 14 | # bla bla 15 | -------------------------------------------------------------------------------- /tests/test_config2: -------------------------------------------------------------------------------- 1 | 2 | Host foo 3 | HostName foo.bar.baz 4 | User me 5 | IdentityFile /home/me/.ssh/work_rsa 6 | IdentitiesOnly yes 7 | LocalForward 10002 127.0.0.1:3306 # mysql 8 | LocalForward 58999 127.0.0.1:8999 # the_app 9 | -------------------------------------------------------------------------------- /tests/test_config_include_specific: -------------------------------------------------------------------------------- 1 | 2 | # comment 3 | Host * 4 | User something 5 | 6 | Include include_svu_host 7 | 8 | # another comment 9 | # bla bla 10 | -------------------------------------------------------------------------------- /tests/test_sshconf.py: -------------------------------------------------------------------------------- 1 | import sshconf 2 | import pytest 3 | import os 4 | 5 | test_config = os.path.join(os.path.dirname(__file__), "test_config") 6 | test_config2 = os.path.join(os.path.dirname(__file__), "test_config2") 7 | test_config_include_specific = os.path.join(os.path.dirname(__file__), "test_config_include_specific") 8 | 9 | 10 | def test_parsing(): 11 | c = sshconf.read_ssh_config(test_config) 12 | assert len(c.hosts()) == 2 13 | assert c.host("*")["user"] == "something" 14 | assert c.host("svu")["proxycommand"] == "nc -w 300 -x localhost:9050 %h %p" 15 | 16 | s1 = c.config().splitlines() 17 | s2 = open(test_config).readlines() 18 | assert len(s1) == len(s2) 19 | 20 | 21 | def test_set(): 22 | c = sshconf.read_ssh_config(test_config) 23 | 24 | c.set("svu", Compression="no", Port=2222) 25 | 26 | print(c.config()) 27 | print("svu", c.host('svu')) 28 | 29 | assert " Compression no" in c.config() 30 | assert " Port 2222" in c.config() 31 | 32 | 33 | def test_set_host_failed(): 34 | c = sshconf.read_ssh_config(test_config) 35 | 36 | with pytest.raises(ValueError): 37 | c.set("svu", Host="svu-new") 38 | 39 | 40 | def test_rename(): 41 | 42 | c = sshconf.read_ssh_config(test_config) 43 | 44 | assert c.host("svu")["hostname"] == "www.svuniversity.ac.in" 45 | 46 | hosts = c.hosts() 47 | 48 | c.rename("svu", "svu-new") 49 | 50 | hosts2 = c.hosts() 51 | 52 | assert hosts.index("svu") == hosts2.index("svu-new") # order is the same 53 | 54 | assert "Host svu-new" in c.config() 55 | assert "Host svu\n" not in c.config() 56 | assert "svu" not in c.hosts() 57 | assert "svu-new" in c.hosts() 58 | 59 | c.set("svu-new", Port=123, HostName="www.svuniversity.ac.in") # has to be success 60 | assert c.host("svu-new")["port"] == 123 61 | assert c.host("svu-new")["hostname"] == "www.svuniversity.ac.in" # still same 62 | 63 | with pytest.raises(ValueError): # we can't refer to the renamed host 64 | c.set("svu", Port=123) 65 | 66 | 67 | def test_update_fail(): 68 | c = sshconf.read_ssh_config(test_config) 69 | 70 | with pytest.raises(ValueError): 71 | c.set("notfound", Port=1234) 72 | 73 | 74 | def test_add(): 75 | 76 | c = sshconf.read_ssh_config(test_config) 77 | 78 | hosts = list(c.hosts()) 79 | 80 | c.add("venkateswara", Hostname="venkateswara.onion", User="other", Port=22, 81 | ProxyCommand="nc -w 300 -x localhost:9050 %h %p") 82 | 83 | hosts2 = list(c.hosts()) 84 | 85 | assert hosts + ["venkateswara"] == hosts2 86 | 87 | assert "venkateswara" in c.hosts() 88 | assert c.host("venkateswara")["proxycommand"] == "nc -w 300 -x localhost:9050 %h %p" 89 | 90 | assert "Host venkateswara" in c.config() 91 | 92 | with pytest.raises(ValueError): 93 | c.add("svu") 94 | 95 | with pytest.raises(ValueError): 96 | c.add("venkateswara") 97 | 98 | 99 | def test_add_before_host(): 100 | 101 | c = sshconf.read_ssh_config(test_config) 102 | 103 | hosts = list(c.hosts()) 104 | 105 | c.add("venkateswara", before_host="svu", Hostname="venkateswara.onion", User="other", Port=22, 106 | ProxyCommand="nc -w 300 -x localhost:9050 %h %p") 107 | 108 | hosts2 = list(c.hosts()) 109 | 110 | c.add("venkateswara2", before_host="*", Hostname="venkateswara.onion", User="other", Port=22, 111 | ProxyCommand="nc -w 300 -x localhost:9050 %h %p") 112 | 113 | hosts3 = list(c.hosts()) 114 | 115 | new_config = c.config() 116 | 117 | print("new config", new_config) 118 | 119 | assert hosts == ["*", "svu"] 120 | assert hosts2 == ["*", "venkateswara", "svu"] 121 | assert hosts3 == ["venkateswara2", "*", "venkateswara", "svu"] 122 | 123 | assert "venkateswara" in c.hosts() 124 | 125 | assert "Host venkateswara" in new_config 126 | 127 | with pytest.raises(ValueError): # cant add before a host that is not found 128 | c.add("svucs", before_host="not-found-host", Hostname="venkateswara.onion", User="other", Port=22, 129 | ProxyCommand="nc -w 300 -x localhost:9050 %h %p") 130 | 131 | with pytest.raises(ValueError): # its there now 132 | c.add("venkateswara") 133 | 134 | 135 | def test_save(): 136 | import tempfile 137 | tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-4123") 138 | try: 139 | c = sshconf.read_ssh_config(test_config) 140 | 141 | c.set("svu", Hostname="ssh.svuniversity.ac.in", User="mca") 142 | c.write(tc) 143 | 144 | c2 = sshconf.read_ssh_config(tc) 145 | assert c2.host("svu")["hostname"] == "ssh.svuniversity.ac.in" 146 | assert c2.host("svu")["user"] == "mca" 147 | 148 | assert c.config() == c2.config() 149 | 150 | finally: 151 | os.remove(tc) 152 | 153 | 154 | def test_empty(): 155 | import tempfile 156 | tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-123") 157 | try: 158 | c = sshconf.empty_ssh_config_file() 159 | c.add("svu33", HostName="ssh33.svu.local", User="mca", Port=22) 160 | c.write(tc) 161 | c2 = sshconf.read_ssh_config(tc) 162 | assert 1 == len(c2.hosts()) 163 | assert c2.host("svu33")["hostname"] == "ssh33.svu.local" 164 | finally: 165 | os.remove(tc) 166 | 167 | 168 | def test_mapping_set_existing_key(): 169 | c = sshconf.read_ssh_config(test_config) 170 | c.set("svu", Hostname="ssh.svuniversity.ac.in", User="mca", proxycommand="nc --help") 171 | 172 | print(c.config()) 173 | 174 | assert "Hostname ssh.svuniversity.ac.in" in c.config() 175 | assert "User mca" in c.config() 176 | assert "ProxyCommand nc --help" in c.config() 177 | 178 | 179 | def test_mapping_set_existing_key_multi_values(): 180 | c = sshconf.read_ssh_config(test_config) 181 | c.set("svu", Hostname="ssh.svuniversity.ac.in", User="mca", 182 | remoteforward=["localhost:3322 localhost:22", 183 | "localhost:10809 172.26.176.1:10809"]) 184 | print(c.config()) 185 | 186 | assert "Hostname ssh.svuniversity.ac.in" in c.config() 187 | assert "User mca" in c.config() 188 | assert "RemoteForward localhost:3322 localhost:22" in c.config() 189 | assert "RemoteForward localhost:10809 172.26.176.1:10809" in c.config() 190 | 191 | 192 | def test_mapping_set_new_key(): 193 | c = sshconf.read_ssh_config(test_config) 194 | 195 | c.set("svu", forwardAgent='yes', unknownpropertylikethis='noway') 196 | 197 | assert "Hostname www.svuniversity.ac.in" in c.config() # old parameters 198 | assert "Port 22" in c.config() 199 | assert "ForwardAgent yes" in c.config() # new parameter has been properly cased 200 | assert "unknownpropertylikethis noway" in c.config() 201 | 202 | 203 | def test_mapping_add_new_keys(): 204 | c = sshconf.read_ssh_config(test_config) 205 | c.add("svu-new", forwardAgent="yes", unknownpropertylikethis="noway", Hostname="ssh.svuni.local", 206 | user="mmccaa") 207 | 208 | assert "Host svu-new" in c.config() 209 | assert "ForwardAgent yes" in c.config() 210 | assert "unknownpropertylikethis noway" in c.config() 211 | assert "HostName ssh.svuni.local" in c.config() 212 | 213 | assert "forwardagent" in c.host("svu-new") 214 | assert "unknownpropertylikethis" in c.host("svu-new") 215 | assert "hostname" in c.host("svu-new") 216 | assert "user" in c.host("svu-new") 217 | 218 | 219 | def test_remove(): 220 | c = sshconf.read_ssh_config(test_config) 221 | 222 | c.add("abc", forwardAgent="yes", unknownpropertylikethis="noway", Hostname="ssh.svuni.local", 223 | user="mmccaa") 224 | 225 | c.add("def", forwardAgent="yes", unknownpropertylikethis="noway", Hostname="ssh.svuni.local", 226 | user="mmccaa") 227 | 228 | config1 = c.config() 229 | hosts = list(c.hosts()) 230 | c.remove("abc") 231 | config2 = c.config() 232 | hosts2 = list(c.hosts()) 233 | c.remove("svu") 234 | config3 = c.config() 235 | hosts3 = list(c.hosts()) 236 | 237 | assert "abc" in hosts 238 | assert "abc" not in hosts2 239 | 240 | assert "Host abc" in config1 241 | assert "Host abc" not in config2 242 | assert "Host svu" not in config3 243 | 244 | assert hosts2 == ["*", "svu", "def"] # test order 245 | assert hosts3 == ["*", "def"] 246 | 247 | assert "# within-host-comment" not in config3 248 | 249 | 250 | def test_read_duplicate_keys(): 251 | 252 | c = sshconf.read_ssh_config(test_config2) 253 | 254 | host = c.host('foo') 255 | assert 5 == len(host.keys()) 256 | assert "localforward" in host 257 | assert 2 == len(host["localforward"]) 258 | 259 | 260 | def test_set_duplicate_keys(): 261 | 262 | c = sshconf.read_ssh_config(test_config2) 263 | 264 | lfs = c.host('foo')['localforward'] 265 | 266 | assert type(lfs) is list 267 | assert len(lfs) == 2 268 | lfs.append("1234 localhost:4321") 269 | 270 | c.set('foo', localforward=lfs) 271 | 272 | import tempfile 273 | tc = os.path.join(tempfile.gettempdir(), "temp_ssh_config-tudk") 274 | try: 275 | c.write(tc) 276 | 277 | d = sshconf.read_ssh_config(tc) 278 | 279 | host2 = d.host('foo') 280 | assert len(host2["localforward"]) == 3 281 | finally: 282 | os.remove(tc) 283 | 284 | 285 | def test_mapping_remove_existing_key(): 286 | c = sshconf.read_ssh_config(test_config) 287 | 288 | svu = c.host('svu') 289 | print(svu) 290 | c.unset("svu", 'proxycommand') 291 | 292 | print(c.config()) 293 | assert "ProxyCommand" not in c.config() 294 | svu2 = c.host('svu') 295 | assert 'proxycommand' not in svu2 296 | assert 'hostname' in svu2 297 | assert 'port' in svu2 298 | 299 | 300 | def test_read_included_specific(): 301 | c = sshconf.read_ssh_config(test_config_include_specific) 302 | 303 | hosts = c.hosts() 304 | print("hosts", hosts) 305 | 306 | assert 'svuincluded' in hosts 307 | 308 | h = c.host("svuincluded") 309 | print(h) 310 | print(c.config()) 311 | 312 | 313 | def test_save_with_included(tmpdir): 314 | conf = tmpdir.join("config") 315 | incl = tmpdir.join("included_host") 316 | 317 | conf.write(""" 318 | Host svu.local 319 | Hostname ssh.svu.local 320 | User something 321 | 322 | Include included_host 323 | """) 324 | 325 | incl.write(""" 326 | Host svu.included 327 | Hostname ssh.svu.included 328 | User whatever 329 | """) 330 | 331 | c = sshconf.read_ssh_config(conf) 332 | 333 | hosts = c.hosts() 334 | print("hosts", hosts) 335 | 336 | assert 'svu.local' in hosts 337 | assert 'svu.included' in hosts 338 | 339 | h = c.host("svu.included") 340 | print(h) 341 | 342 | c.set("svu.included", Hostname="ssh2.svu.included", Port="1234") 343 | h = c.host("svu.included") 344 | print(h) 345 | print(c.config()) 346 | 347 | c.save() 348 | assert "ssh2.svu.included" not in conf.read() 349 | assert "ssh2.svu.included" in incl.read() 350 | 351 | 352 | def test_read_included_glob(tmpdir): 353 | conf = tmpdir.join("config") 354 | incl = tmpdir.mkdir("conf.d").join("included_host") 355 | 356 | conf.write(""" 357 | Host svu.local 358 | Hostname ssh.svu.local 359 | User something 360 | 361 | Include conf.d/* 362 | """) 363 | 364 | incl.write(""" 365 | Host svu.included 366 | Hostname ssh.svu.included 367 | User whatever 368 | """) 369 | 370 | c = sshconf.read_ssh_config(conf) 371 | 372 | hosts = c.hosts() 373 | print("hosts", hosts) 374 | 375 | assert 'svu.local' in hosts 376 | assert 'svu.included' in hosts 377 | --------------------------------------------------------------------------------