graphical-ssh 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphical_ssh-0.1.1.dist-info/METADATA +151 -0
- graphical_ssh-0.1.1.dist-info/RECORD +18 -0
- graphical_ssh-0.1.1.dist-info/WHEEL +4 -0
- graphical_ssh-0.1.1.dist-info/entry_points.txt +3 -0
- graphical_ssh-0.1.1.dist-info/licenses/LICENSE +21 -0
- gssh/__init__.py +3 -0
- gssh/__main__.py +43 -0
- gssh/config.py +245 -0
- gssh/connect.py +156 -0
- gssh/data/__init__.py +0 -0
- gssh/data/gssh.desktop +10 -0
- gssh/data/gssh.svg +26 -0
- gssh/host_editor.py +174 -0
- gssh/linter.py +402 -0
- gssh/main_window.py +384 -0
- gssh/models.py +91 -0
- gssh/py.typed +0 -0
- gssh/terminal.py +490 -0
gssh/data/gssh.svg
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
|
2
|
+
<!-- Terminal background -->
|
|
3
|
+
<rect x="8" y="16" width="112" height="96" rx="8" fill="#1e1e1e" stroke="#3a3a3a" stroke-width="2"/>
|
|
4
|
+
<!-- Title bar -->
|
|
5
|
+
<rect x="8" y="16" width="112" height="20" rx="8" fill="#2d2d2d"/>
|
|
6
|
+
<rect x="8" y="28" width="112" height="8" fill="#2d2d2d"/>
|
|
7
|
+
<!-- Window dots -->
|
|
8
|
+
<circle cx="20" cy="26" r="4" fill="#e06c75"/>
|
|
9
|
+
<circle cx="34" cy="26" r="4" fill="#e5c07b"/>
|
|
10
|
+
<circle cx="48" cy="26" r="4" fill="#98c379"/>
|
|
11
|
+
<!-- Prompt > -->
|
|
12
|
+
<text x="20" y="64" font-family="monospace" font-size="16" fill="#61afef" font-weight="bold">></text>
|
|
13
|
+
<!-- ssh text -->
|
|
14
|
+
<text x="36" y="64" font-family="monospace" font-size="14" fill="#abb2bf">ssh</text>
|
|
15
|
+
<!-- Connection indicator -->
|
|
16
|
+
<line x1="20" y1="80" x2="100" y2="80" stroke="#3a3a3a" stroke-width="1"/>
|
|
17
|
+
<circle cx="28" cy="92" r="3" fill="#98c379"/>
|
|
18
|
+
<text x="38" y="96" font-family="monospace" font-size="10" fill="#5c6370">connected</text>
|
|
19
|
+
<!-- Key icon bottom-right -->
|
|
20
|
+
<g transform="translate(88, 88)">
|
|
21
|
+
<circle cx="8" cy="8" r="6" fill="none" stroke="#61afef" stroke-width="2"/>
|
|
22
|
+
<line x1="14" y1="8" x2="24" y2="8" stroke="#61afef" stroke-width="2"/>
|
|
23
|
+
<line x1="20" y1="8" x2="20" y2="12" stroke="#61afef" stroke-width="2"/>
|
|
24
|
+
<line x1="24" y1="8" x2="24" y2="14" stroke="#61afef" stroke-width="2"/>
|
|
25
|
+
</g>
|
|
26
|
+
</svg>
|
gssh/host_editor.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Dialog for adding and editing SSH host entries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from PyQt6.QtWidgets import (
|
|
6
|
+
QComboBox,
|
|
7
|
+
QDialog,
|
|
8
|
+
QDialogButtonBox,
|
|
9
|
+
QFileDialog,
|
|
10
|
+
QFormLayout,
|
|
11
|
+
QHBoxLayout,
|
|
12
|
+
QLineEdit,
|
|
13
|
+
QPlainTextEdit,
|
|
14
|
+
QPushButton,
|
|
15
|
+
QVBoxLayout,
|
|
16
|
+
QWidget,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from .config import HostEntry
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class HostEditorDialog(QDialog):
|
|
23
|
+
"""Modal dialog for editing a single HostEntry.
|
|
24
|
+
|
|
25
|
+
Pass an existing HostEntry to edit, or None to create a new one.
|
|
26
|
+
Call result() after accept to get the updated HostEntry.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, host: HostEntry | None = None, parent: QWidget | None = None) -> None:
|
|
30
|
+
super().__init__(parent)
|
|
31
|
+
self.setWindowTitle("Edit Host" if host else "Add Host")
|
|
32
|
+
self.setMinimumWidth(500)
|
|
33
|
+
self._host = host
|
|
34
|
+
self._build_ui()
|
|
35
|
+
if host:
|
|
36
|
+
self._populate(host)
|
|
37
|
+
|
|
38
|
+
def _build_ui(self) -> None:
|
|
39
|
+
layout = QVBoxLayout(self)
|
|
40
|
+
|
|
41
|
+
form = QFormLayout()
|
|
42
|
+
|
|
43
|
+
self.alias_edit = QLineEdit()
|
|
44
|
+
self.alias_edit.setPlaceholderText("myserver (or *.example.com)")
|
|
45
|
+
form.addRow("Alias / Pattern:", self.alias_edit)
|
|
46
|
+
|
|
47
|
+
self.hostname_edit = QLineEdit()
|
|
48
|
+
self.hostname_edit.setPlaceholderText("192.168.1.100")
|
|
49
|
+
form.addRow("Host Name:", self.hostname_edit)
|
|
50
|
+
|
|
51
|
+
self.user_edit = QLineEdit()
|
|
52
|
+
self.user_edit.setPlaceholderText("root")
|
|
53
|
+
form.addRow("User:", self.user_edit)
|
|
54
|
+
|
|
55
|
+
self.port_edit = QLineEdit()
|
|
56
|
+
self.port_edit.setPlaceholderText("22")
|
|
57
|
+
form.addRow("Port:", self.port_edit)
|
|
58
|
+
|
|
59
|
+
# IdentityFile with browse button
|
|
60
|
+
key_layout = QHBoxLayout()
|
|
61
|
+
self.identity_edit = QLineEdit()
|
|
62
|
+
self.identity_edit.setPlaceholderText("~/.ssh/id_ed25519")
|
|
63
|
+
browse_btn = QPushButton("Browse…")
|
|
64
|
+
browse_btn.clicked.connect(self._browse_key)
|
|
65
|
+
key_layout.addWidget(self.identity_edit)
|
|
66
|
+
key_layout.addWidget(browse_btn)
|
|
67
|
+
form.addRow("Identity File:", key_layout)
|
|
68
|
+
|
|
69
|
+
self.proxyjump_edit = QLineEdit()
|
|
70
|
+
self.proxyjump_edit.setPlaceholderText("jumpbox (optional)")
|
|
71
|
+
form.addRow("Proxy Jump:", self.proxyjump_edit)
|
|
72
|
+
|
|
73
|
+
# ForwardAgent combo
|
|
74
|
+
self.forwardagent_combo = QComboBox()
|
|
75
|
+
self.forwardagent_combo.addItems(["(default)", "yes", "no"])
|
|
76
|
+
form.addRow("Forward Agent:", self.forwardagent_combo)
|
|
77
|
+
|
|
78
|
+
layout.addLayout(form)
|
|
79
|
+
|
|
80
|
+
# Extra options text area
|
|
81
|
+
self.extra_edit = QPlainTextEdit()
|
|
82
|
+
self.extra_edit.setPlaceholderText("One option per line:\nCompression yes\nServerAliveInterval 60")
|
|
83
|
+
self.extra_edit.setMaximumHeight(120)
|
|
84
|
+
form2 = QFormLayout()
|
|
85
|
+
form2.addRow("Extra Options:", self.extra_edit)
|
|
86
|
+
layout.addLayout(form2)
|
|
87
|
+
|
|
88
|
+
# Buttons
|
|
89
|
+
buttons = QDialogButtonBox(
|
|
90
|
+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
|
91
|
+
)
|
|
92
|
+
buttons.accepted.connect(self._on_accept)
|
|
93
|
+
buttons.rejected.connect(self.reject)
|
|
94
|
+
layout.addWidget(buttons)
|
|
95
|
+
|
|
96
|
+
def _browse_key(self) -> None:
|
|
97
|
+
start_dir = self.identity_edit.text() or "~/.ssh"
|
|
98
|
+
path, _ = QFileDialog.getOpenFileName(
|
|
99
|
+
self, "Select SSH Key File", start_dir, "All Files (*)"
|
|
100
|
+
)
|
|
101
|
+
if path:
|
|
102
|
+
self.identity_edit.setText(path)
|
|
103
|
+
|
|
104
|
+
def _populate(self, host: HostEntry) -> None:
|
|
105
|
+
self.alias_edit.setText(host.pattern)
|
|
106
|
+
self.hostname_edit.setText(host.hostname)
|
|
107
|
+
self.user_edit.setText(host.user)
|
|
108
|
+
self.port_edit.setText(host.port)
|
|
109
|
+
self.identity_edit.setText(host.identity_file)
|
|
110
|
+
self.proxyjump_edit.setText(host.proxy_jump)
|
|
111
|
+
|
|
112
|
+
fa = host.forward_agent.lower()
|
|
113
|
+
idx = self.forwardagent_combo.findText(fa if fa in ("yes", "no") else "(default)")
|
|
114
|
+
self.forwardagent_combo.setCurrentIndex(max(0, idx))
|
|
115
|
+
|
|
116
|
+
extras = host.extra_options()
|
|
117
|
+
if extras:
|
|
118
|
+
lines = [f"{k} {v}" for k, v in extras.items()]
|
|
119
|
+
self.extra_edit.setPlainText("\n".join(lines))
|
|
120
|
+
|
|
121
|
+
def _on_accept(self) -> None:
|
|
122
|
+
alias = self.alias_edit.text().strip()
|
|
123
|
+
if not alias:
|
|
124
|
+
self.alias_edit.setFocus()
|
|
125
|
+
return
|
|
126
|
+
self.accept()
|
|
127
|
+
|
|
128
|
+
def result_host(self) -> HostEntry:
|
|
129
|
+
"""Build a HostEntry from the dialog fields. Call after accept()."""
|
|
130
|
+
options: dict[str, str] = {}
|
|
131
|
+
|
|
132
|
+
hostname = self.hostname_edit.text().strip()
|
|
133
|
+
if hostname:
|
|
134
|
+
options["hostname"] = hostname
|
|
135
|
+
|
|
136
|
+
user = self.user_edit.text().strip()
|
|
137
|
+
if user:
|
|
138
|
+
options["user"] = user
|
|
139
|
+
|
|
140
|
+
port = self.port_edit.text().strip()
|
|
141
|
+
if port:
|
|
142
|
+
options["port"] = port
|
|
143
|
+
|
|
144
|
+
identity = self.identity_edit.text().strip()
|
|
145
|
+
if identity:
|
|
146
|
+
options["identityfile"] = identity
|
|
147
|
+
|
|
148
|
+
proxy = self.proxyjump_edit.text().strip()
|
|
149
|
+
if proxy:
|
|
150
|
+
options["proxyjump"] = proxy
|
|
151
|
+
|
|
152
|
+
fa = self.forwardagent_combo.currentText()
|
|
153
|
+
if fa in ("yes", "no"):
|
|
154
|
+
options["forwardagent"] = fa
|
|
155
|
+
|
|
156
|
+
# Parse extra options
|
|
157
|
+
for line in self.extra_edit.toPlainText().splitlines():
|
|
158
|
+
stripped = line.strip()
|
|
159
|
+
if not stripped or stripped.startswith("#"):
|
|
160
|
+
continue
|
|
161
|
+
parts = stripped.split(None, 1)
|
|
162
|
+
if len(parts) == 2:
|
|
163
|
+
options[parts[0].lower()] = parts[1].strip()
|
|
164
|
+
elif len(parts) == 1:
|
|
165
|
+
options[parts[0].lower()] = ""
|
|
166
|
+
|
|
167
|
+
# Preserve raw_trailing if editing
|
|
168
|
+
trailing = self._host.raw_trailing if self._host else []
|
|
169
|
+
|
|
170
|
+
return HostEntry(
|
|
171
|
+
pattern=self.alias_edit.text().strip(),
|
|
172
|
+
options=options,
|
|
173
|
+
raw_trailing=trailing,
|
|
174
|
+
)
|
gssh/linter.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""SSH config linter — catches errors no GUI manager checks.
|
|
2
|
+
|
|
3
|
+
Validates ~/.ssh/config entries for common mistakes: unknown options,
|
|
4
|
+
deprecated directives, missing or insecure identity files, port range
|
|
5
|
+
issues, ProxyJump references to undefined hosts, and overlapping Host
|
|
6
|
+
patterns that cause first-match-wins conflicts.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import fnmatch
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from .config import HostEntry, SSHConfig
|
|
18
|
+
|
|
19
|
+
__all__ = ["LintFinding", "lint_config", "severity_icon"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Known ssh_config options — generated from `ssh -G` on OpenSSH 10.0p2
|
|
23
|
+
# plus options that only work in config files (Include, Match, ControlPath,
|
|
24
|
+
# LocalForward, RemoteForward, DynamicForward, ProxyJump, ProxyCommand).
|
|
25
|
+
_KNOWN_OPTIONS = {
|
|
26
|
+
# Core connection
|
|
27
|
+
"host", "hostname", "user", "port", "addressfamily",
|
|
28
|
+
"bindaddress", "connecttimeout", "connectionattempts",
|
|
29
|
+
# Identity and auth
|
|
30
|
+
"identityfile", "identityagent", "certificatefile",
|
|
31
|
+
"identitiesonly", "addkeystoagent",
|
|
32
|
+
"pubkeyauthentication", "pubkeyacceptedalgorithms",
|
|
33
|
+
"hostbasedauthentication", "hostbasedacceptedalgorithms",
|
|
34
|
+
"passwordauthentication", "kbdinteractiveauthentication",
|
|
35
|
+
"kbdinteractivedevices", "numberofpasswordprompts",
|
|
36
|
+
"preferredauthentications", "requiredrsasize",
|
|
37
|
+
"casignaturealgorithms", "securitykeyprovider",
|
|
38
|
+
# Proxy and jumping
|
|
39
|
+
"proxyjump", "proxycommand", "proxyusefdpass",
|
|
40
|
+
# Forwarding
|
|
41
|
+
"forwardagent", "localforward", "remoteforward",
|
|
42
|
+
"dynamicforward", "gatewayports", "exitonforwardfailure",
|
|
43
|
+
"clearallforwardings", "permitremoteopen",
|
|
44
|
+
# X11
|
|
45
|
+
"forwardx11", "forwardx11trusted", "forwardx11timeout",
|
|
46
|
+
"xauthlocation",
|
|
47
|
+
# Control master
|
|
48
|
+
"controlmaster", "controlpath", "controlpersist",
|
|
49
|
+
# Host key checking
|
|
50
|
+
"stricthostkeychecking", "userknownhostsfile",
|
|
51
|
+
"globalknownhostsfile", "checkhostip", "verifyhostkeydns",
|
|
52
|
+
"visualhostkey", "updatehostkeys", "revokedhostkeys",
|
|
53
|
+
# Keepalive and timeout
|
|
54
|
+
"serveraliveinterval", "serveralivecountmax",
|
|
55
|
+
"tcpkeepalive",
|
|
56
|
+
# Compression
|
|
57
|
+
"compression",
|
|
58
|
+
# GSSAPI
|
|
59
|
+
"gssapiauthentication", "gssapidelegatecredentials",
|
|
60
|
+
"gssapikexalgorithms", "gssapikeyexchange",
|
|
61
|
+
"gssapirenewalforcesrekey", "gssapitrustdns",
|
|
62
|
+
# Canonicalization
|
|
63
|
+
"canonicalizehostname", "canonicaldomains",
|
|
64
|
+
"canonicalizefallbacklocal", "canonicalizemaxdots",
|
|
65
|
+
# Crypto
|
|
66
|
+
"ciphers", "macs", "kexalgorithms",
|
|
67
|
+
"hostkeyalgorithms",
|
|
68
|
+
# Logging
|
|
69
|
+
"loglevel", "logverbose", "syslogfacility",
|
|
70
|
+
# Session
|
|
71
|
+
"requesttty", "sessiontype", "permitlocalcommand",
|
|
72
|
+
"localcommand", "remotecommand", "stdinnull",
|
|
73
|
+
"escapechar", "enableescapecommandline",
|
|
74
|
+
# Tunnel
|
|
75
|
+
"tunnel", "tunneldevice",
|
|
76
|
+
# Fork
|
|
77
|
+
"forkafterauthentication",
|
|
78
|
+
# Misc
|
|
79
|
+
"batchmode", "hashknownhosts", "nohostauthenticationforlocalhost",
|
|
80
|
+
"enablesshkeysign", "fingerprinthash", "ipqos",
|
|
81
|
+
"obscurekeystroketiming", "rekeylimit", "channeltimeout",
|
|
82
|
+
"sendenv", "setenv", "tag",
|
|
83
|
+
"streamlocalbindmask", "streamlocalbindunlink",
|
|
84
|
+
# Config-file-only directives (not in ssh -G output)
|
|
85
|
+
"include", "match",
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Deprecated options (removed or superseded in modern OpenSSH)
|
|
89
|
+
_DEPRECATED_OPTIONS = {
|
|
90
|
+
"cipher": "Use 'Ciphers' instead (plural).",
|
|
91
|
+
"protocol": "Protocol option is deprecated; SSHv1 is no longer supported.",
|
|
92
|
+
"fallbacktotrusted": "Deprecated; use 'ForwardX11Trusted'.",
|
|
93
|
+
"rsaauthentication": "Deprecated; use 'PubkeyAuthentication'.",
|
|
94
|
+
"rhostsrsaauthentication": "Removed in OpenSSH 8.0.",
|
|
95
|
+
"challengeresponseauthentication": "Deprecated; use 'KbdInteractiveAuthentication'.",
|
|
96
|
+
"useprivilegedport": "Deprecated.",
|
|
97
|
+
"compressionlevel": "Deprecated; Compression is a yes/no option now.",
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
# Options where "none" is a valid value meaning "disable"
|
|
101
|
+
_NONE_VALUE_OPTIONS = {"identityfile", "proxyjump", "certificatefile",
|
|
102
|
+
"remotecommand", "localcommand"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class LintFinding:
|
|
107
|
+
"""A single lint finding for an SSH config entry."""
|
|
108
|
+
severity: str # "error" | "warning" | "info"
|
|
109
|
+
host_alias: str
|
|
110
|
+
option: str
|
|
111
|
+
message: str
|
|
112
|
+
suggestion: str = ""
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def severity_icon(severity: str) -> str:
|
|
116
|
+
"""Return a display icon for a severity level."""
|
|
117
|
+
if severity == "error":
|
|
118
|
+
return "✗"
|
|
119
|
+
if severity == "warning":
|
|
120
|
+
return "⚠"
|
|
121
|
+
return "ℹ"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def lint_config(config: SSHConfig, config_path: Path | None = None) -> list[LintFinding]:
|
|
125
|
+
"""Lint an SSHConfig and return all findings.
|
|
126
|
+
|
|
127
|
+
Runs all checks and returns a list sorted by severity (errors first).
|
|
128
|
+
"""
|
|
129
|
+
findings: list[LintFinding] = []
|
|
130
|
+
|
|
131
|
+
all_aliases = {h.alias for h in config.hosts}
|
|
132
|
+
|
|
133
|
+
for host in config.hosts:
|
|
134
|
+
findings.extend(_check_unknown_options(host))
|
|
135
|
+
findings.extend(_check_deprecated_options(host))
|
|
136
|
+
findings.extend(_check_identity_file(host, config_path))
|
|
137
|
+
findings.extend(_check_port(host))
|
|
138
|
+
findings.extend(_check_proxyjump(host, all_aliases))
|
|
139
|
+
|
|
140
|
+
findings.extend(_check_pattern_overlaps(config))
|
|
141
|
+
|
|
142
|
+
# Sort: errors first, then warnings, then info
|
|
143
|
+
severity_order = {"error": 0, "warning": 1, "info": 2}
|
|
144
|
+
findings.sort(key=lambda f: (severity_order.get(f.severity, 3), f.host_alias))
|
|
145
|
+
return findings
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _check_unknown_options(host: HostEntry) -> list[LintFinding]:
|
|
149
|
+
"""Check for options not in the known ssh_config set."""
|
|
150
|
+
findings = []
|
|
151
|
+
for key in host.options:
|
|
152
|
+
# Skip deprecated options — they're checked separately
|
|
153
|
+
if key in _DEPRECATED_OPTIONS:
|
|
154
|
+
continue
|
|
155
|
+
if key not in _KNOWN_OPTIONS:
|
|
156
|
+
findings.append(LintFinding(
|
|
157
|
+
severity="warning",
|
|
158
|
+
host_alias=host.alias,
|
|
159
|
+
option=key,
|
|
160
|
+
message=f"Unknown option '{key}' — may be ignored by ssh.",
|
|
161
|
+
suggestion="Check ssh_config(5) for valid options.",
|
|
162
|
+
))
|
|
163
|
+
return findings
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _check_deprecated_options(host: HostEntry) -> list[LintFinding]:
|
|
167
|
+
"""Check for deprecated options."""
|
|
168
|
+
findings = []
|
|
169
|
+
for key in host.options:
|
|
170
|
+
if key in _DEPRECATED_OPTIONS:
|
|
171
|
+
findings.append(LintFinding(
|
|
172
|
+
severity="warning",
|
|
173
|
+
host_alias=host.alias,
|
|
174
|
+
option=key,
|
|
175
|
+
message=f"Deprecated option '{key}'.",
|
|
176
|
+
suggestion=_DEPRECATED_OPTIONS[key],
|
|
177
|
+
))
|
|
178
|
+
return findings
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _check_identity_file(host: HostEntry, config_path: Path | None) -> list[LintFinding]:
|
|
182
|
+
"""Check IdentityFile exists and has secure permissions."""
|
|
183
|
+
findings = []
|
|
184
|
+
idfile = host.identity_file
|
|
185
|
+
if not idfile:
|
|
186
|
+
return findings
|
|
187
|
+
|
|
188
|
+
# "none" is a valid value meaning "don't use an identity file"
|
|
189
|
+
if idfile.lower() == "none":
|
|
190
|
+
return findings
|
|
191
|
+
|
|
192
|
+
expanded = os.path.expanduser(idfile)
|
|
193
|
+
|
|
194
|
+
if not os.path.exists(expanded):
|
|
195
|
+
findings.append(LintFinding(
|
|
196
|
+
severity="warning",
|
|
197
|
+
host_alias=host.alias,
|
|
198
|
+
option="identityfile",
|
|
199
|
+
message=f"IdentityFile '{idfile}' does not exist.",
|
|
200
|
+
suggestion="Generate a key with ssh-keygen or fix the path.",
|
|
201
|
+
))
|
|
202
|
+
return findings
|
|
203
|
+
|
|
204
|
+
# Check permissions — private key should not be group/world readable
|
|
205
|
+
try:
|
|
206
|
+
st = os.stat(expanded)
|
|
207
|
+
if st.st_mode & 0o077:
|
|
208
|
+
findings.append(LintFinding(
|
|
209
|
+
severity="error",
|
|
210
|
+
host_alias=host.alias,
|
|
211
|
+
option="identityfile",
|
|
212
|
+
message=f"IdentityFile '{idfile}' has insecure permissions ({oct(st.st_mode & 0o777)}).",
|
|
213
|
+
suggestion="Run: chmod 600 <keyfile>",
|
|
214
|
+
))
|
|
215
|
+
except OSError:
|
|
216
|
+
pass
|
|
217
|
+
|
|
218
|
+
return findings
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _check_port(host: HostEntry) -> list[LintFinding]:
|
|
222
|
+
"""Check port is a valid number in range."""
|
|
223
|
+
findings = []
|
|
224
|
+
port = host.port
|
|
225
|
+
if not port:
|
|
226
|
+
return findings
|
|
227
|
+
|
|
228
|
+
try:
|
|
229
|
+
p = int(port)
|
|
230
|
+
if p < 1 or p > 65535:
|
|
231
|
+
findings.append(LintFinding(
|
|
232
|
+
severity="error",
|
|
233
|
+
host_alias=host.alias,
|
|
234
|
+
option="port",
|
|
235
|
+
message=f"Port {port} is out of range (1-65535).",
|
|
236
|
+
))
|
|
237
|
+
except ValueError:
|
|
238
|
+
findings.append(LintFinding(
|
|
239
|
+
severity="error",
|
|
240
|
+
host_alias=host.alias,
|
|
241
|
+
option="port",
|
|
242
|
+
message=f"Port '{port}' is not a valid number.",
|
|
243
|
+
))
|
|
244
|
+
return findings
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# IPv6 address regex (simplified — matches [2001:db8::1] or bare 2001:db8::1)
|
|
248
|
+
_IPV6_RE = re.compile(r"^\[?[0-9a-fA-F:]+\]?$")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _check_proxyjump(host: HostEntry, all_aliases: set[str]) -> list[LintFinding]:
|
|
252
|
+
"""Check ProxyJump references a defined host."""
|
|
253
|
+
findings = []
|
|
254
|
+
proxy = host.proxy_jump
|
|
255
|
+
if not proxy:
|
|
256
|
+
return findings
|
|
257
|
+
|
|
258
|
+
# "none" disables proxyjump
|
|
259
|
+
if proxy.lower() == "none":
|
|
260
|
+
return findings
|
|
261
|
+
|
|
262
|
+
# ProxyJump can be a chain: jump1,jump2,...
|
|
263
|
+
# Each can be: hostalias, user@host, host:port, user@host:port,
|
|
264
|
+
# [ipv6]:port, ssh://user@host:port
|
|
265
|
+
jumps = proxy.split(",")
|
|
266
|
+
for jump in jumps:
|
|
267
|
+
jump = jump.strip()
|
|
268
|
+
jump_host = _parse_proxyjump_host(jump)
|
|
269
|
+
if not jump_host:
|
|
270
|
+
continue
|
|
271
|
+
if jump_host in all_aliases:
|
|
272
|
+
continue
|
|
273
|
+
# It might be a real hostname (not an alias) — that's fine
|
|
274
|
+
# Only warn if it looks like it should be an alias
|
|
275
|
+
if "*" not in jump_host and not _looks_like_hostname(jump_host):
|
|
276
|
+
findings.append(LintFinding(
|
|
277
|
+
severity="info",
|
|
278
|
+
host_alias=host.alias,
|
|
279
|
+
option="proxyjump",
|
|
280
|
+
message=f"ProxyJump host '{jump_host}' is not defined in ssh_config.",
|
|
281
|
+
suggestion=f"Add a 'Host {jump_host}' entry or verify the hostname.",
|
|
282
|
+
))
|
|
283
|
+
return findings
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_proxyjump_host(jump: str) -> str:
|
|
287
|
+
"""Extract the hostname from a ProxyJump destination string.
|
|
288
|
+
|
|
289
|
+
Handles: hostalias, user@host, host:port, user@host:port,
|
|
290
|
+
[ipv6]:port, user@[ipv6]:port, ssh://user@host:port
|
|
291
|
+
"""
|
|
292
|
+
# Strip ssh:// prefix
|
|
293
|
+
if jump.startswith("ssh://"):
|
|
294
|
+
jump = jump[6:]
|
|
295
|
+
# Strip user@
|
|
296
|
+
if "@" in jump:
|
|
297
|
+
jump = jump.split("@", 1)[1]
|
|
298
|
+
# Handle bracketed IPv6: [2001:db8::1] or [2001:db8::1]:2222
|
|
299
|
+
if jump.startswith("["):
|
|
300
|
+
end = jump.find("]")
|
|
301
|
+
if end != -1:
|
|
302
|
+
return jump[1:end]
|
|
303
|
+
return jump[1:] # malformed, but extract what we can
|
|
304
|
+
# Handle IPv6 without brackets (no dots, multiple colons)
|
|
305
|
+
if jump.count(":") >= 2:
|
|
306
|
+
return jump
|
|
307
|
+
# Handle host:port
|
|
308
|
+
if ":" in jump:
|
|
309
|
+
return jump.split(":")[0]
|
|
310
|
+
return jump
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _looks_like_hostname(name: str) -> bool:
|
|
314
|
+
"""Heuristic: does this look like a real hostname rather than an alias?"""
|
|
315
|
+
if "." in name:
|
|
316
|
+
return True # FQDN or IP
|
|
317
|
+
# IPv6
|
|
318
|
+
if ":" in name:
|
|
319
|
+
return True
|
|
320
|
+
# Check if it's a numeric IP (4 octets)
|
|
321
|
+
parts = name.split(".")
|
|
322
|
+
if len(parts) == 4:
|
|
323
|
+
try:
|
|
324
|
+
for p in parts:
|
|
325
|
+
if 0 <= int(p) <= 255:
|
|
326
|
+
continue
|
|
327
|
+
return False
|
|
328
|
+
return True
|
|
329
|
+
except ValueError:
|
|
330
|
+
pass
|
|
331
|
+
return False
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _check_pattern_overlaps(config: SSHConfig) -> list[LintFinding]:
|
|
335
|
+
"""Check for overlapping Host patterns that cause first-match-wins conflicts.
|
|
336
|
+
|
|
337
|
+
Only warns when a *less specific* pattern appears *before* a *more specific*
|
|
338
|
+
one with conflicting options — this is the actual first-match-wins trap.
|
|
339
|
+
The standard idiom (specific hosts first, `Host *` catch-all last) is
|
|
340
|
+
correctly ordered and will NOT produce a warning.
|
|
341
|
+
"""
|
|
342
|
+
findings = []
|
|
343
|
+
hosts = config.hosts
|
|
344
|
+
|
|
345
|
+
for i, h1 in enumerate(hosts):
|
|
346
|
+
for j, h2 in enumerate(hosts):
|
|
347
|
+
if i >= j:
|
|
348
|
+
continue
|
|
349
|
+
# h1 is earlier in the file than h2.
|
|
350
|
+
# Only warn if h1 is *less specific* than h2 (h1's pattern
|
|
351
|
+
# is a superset of h2's). This catches the real trap:
|
|
352
|
+
# `Host *` before `Host server` means server's options are
|
|
353
|
+
# shadowed. The reverse (specific first, wildcard last) is
|
|
354
|
+
# the correct idiom and should not warn.
|
|
355
|
+
patterns1 = h1.pattern.split()
|
|
356
|
+
patterns2 = h2.pattern.split()
|
|
357
|
+
for p1 in patterns1:
|
|
358
|
+
for p2 in patterns2:
|
|
359
|
+
if p1.startswith("!"):
|
|
360
|
+
continue
|
|
361
|
+
if p2.startswith("!"):
|
|
362
|
+
continue
|
|
363
|
+
# Only warn if p1 is broader than p2
|
|
364
|
+
if not _is_broader_pattern(p1, p2):
|
|
365
|
+
continue
|
|
366
|
+
# Check if they have conflicting options
|
|
367
|
+
shared_keys = set(h1.options.keys()) & set(h2.options.keys())
|
|
368
|
+
conflicts = [k for k in shared_keys if h1.options[k] != h2.options[k]]
|
|
369
|
+
if conflicts:
|
|
370
|
+
findings.append(LintFinding(
|
|
371
|
+
severity="info",
|
|
372
|
+
host_alias=h1.alias,
|
|
373
|
+
option="host",
|
|
374
|
+
message=(
|
|
375
|
+
f"Host pattern '{p1}' (earlier) is broader than "
|
|
376
|
+
f"'{p2}' (in '{h2.alias}') with conflicting options: "
|
|
377
|
+
f"{', '.join(conflicts)}. ssh uses first-match-wins."
|
|
378
|
+
),
|
|
379
|
+
suggestion="Reorder: put specific hosts before wildcards.",
|
|
380
|
+
))
|
|
381
|
+
return findings
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _is_broader_pattern(broad: str, specific: str) -> bool:
|
|
385
|
+
"""Check if *broad* is a superset pattern of *specific*.
|
|
386
|
+
|
|
387
|
+
`*` is broader than everything. `*.example.com` is broader than
|
|
388
|
+
`server.example.com`. `server` is not broader than `server`.
|
|
389
|
+
"""
|
|
390
|
+
if broad == "*":
|
|
391
|
+
return broad != specific
|
|
392
|
+
if broad == specific:
|
|
393
|
+
return False
|
|
394
|
+
if "*" in broad:
|
|
395
|
+
# broad is a wildcard — does specific match it?
|
|
396
|
+
if fnmatch.fnmatch(specific, broad):
|
|
397
|
+
return True
|
|
398
|
+
# Try a test name from the wildcard
|
|
399
|
+
test_name = broad.replace("*", "test")
|
|
400
|
+
if fnmatch.fnmatch(test_name, specific):
|
|
401
|
+
return True
|
|
402
|
+
return False
|