putty-export 0.1.0__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.
- putty_export/__init__.py +7 -0
- putty_export/cli.py +55 -0
- putty_export/decoders.py +48 -0
- putty_export/reg_parser.py +55 -0
- putty_export/session_filter.py +72 -0
- putty_export/ssh_config.py +154 -0
- putty_export-0.1.0.dist-info/METADATA +94 -0
- putty_export-0.1.0.dist-info/RECORD +11 -0
- putty_export-0.1.0.dist-info/WHEEL +5 -0
- putty_export-0.1.0.dist-info/entry_points.txt +2 -0
- putty_export-0.1.0.dist-info/top_level.txt +1 -0
putty_export/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Export PuTTY session settings from Windows registry to OpenSSH config format."""
|
|
2
|
+
|
|
3
|
+
from putty_export.reg_parser import parse_reg_file
|
|
4
|
+
from putty_export.session_filter import filter_ssh_sessions
|
|
5
|
+
from putty_export.ssh_config import build_ssh_config
|
|
6
|
+
|
|
7
|
+
__all__ = ["parse_reg_file", "filter_ssh_sessions", "build_ssh_config"]
|
putty_export/cli.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""CLI entrypoint: read .reg file, emit SSH config to stdout or file."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from putty_export.reg_parser import parse_reg_file
|
|
8
|
+
from putty_export.session_filter import filter_ssh_sessions
|
|
9
|
+
from putty_export.ssh_config import build_ssh_config
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main() -> None:
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
description="Export PuTTY session settings from a Windows registry (.reg) file to OpenSSH config format.",
|
|
15
|
+
)
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"reg_file",
|
|
18
|
+
type=Path,
|
|
19
|
+
help="Path to the exported PuTTY Sessions .reg file",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"-o",
|
|
23
|
+
"--output",
|
|
24
|
+
type=Path,
|
|
25
|
+
default=None,
|
|
26
|
+
help="Write config to this file instead of stdout",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--include-default-settings",
|
|
30
|
+
action="store_true",
|
|
31
|
+
help="Include the 'Default Settings' template session if it has a hostname",
|
|
32
|
+
)
|
|
33
|
+
args = parser.parse_args()
|
|
34
|
+
|
|
35
|
+
if not args.reg_file.exists():
|
|
36
|
+
print(f"Error: file not found: {args.reg_file}", file=sys.stderr)
|
|
37
|
+
sys.exit(1)
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
keys = parse_reg_file(args.reg_file)
|
|
41
|
+
except OSError as e:
|
|
42
|
+
print(f"Error reading {args.reg_file}: {e}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
sessions = filter_ssh_sessions(keys, skip_default_settings=not args.include_default_settings)
|
|
46
|
+
config = build_ssh_config(sessions)
|
|
47
|
+
|
|
48
|
+
if args.output is not None:
|
|
49
|
+
args.output.write_text(config, encoding="utf-8")
|
|
50
|
+
else:
|
|
51
|
+
print(config)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if __name__ == "__main__":
|
|
55
|
+
main()
|
putty_export/decoders.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Decode Windows .reg value types: hex(1) UTF-16LE strings and dword integers."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def decode_hex_string(raw: str) -> str:
|
|
7
|
+
"""
|
|
8
|
+
Decode a hex(1) registry value (UTF-16LE) to a string.
|
|
9
|
+
Strips trailing null terminator. Returns empty string on invalid/empty input.
|
|
10
|
+
"""
|
|
11
|
+
if not raw or not raw.strip():
|
|
12
|
+
return ""
|
|
13
|
+
raw = raw.strip()
|
|
14
|
+
parts = [p.strip() for p in re.split(r"[\s,]+", raw) if p.strip()]
|
|
15
|
+
try:
|
|
16
|
+
bytes_list = []
|
|
17
|
+
for part in parts:
|
|
18
|
+
if len(part) > 2:
|
|
19
|
+
continue
|
|
20
|
+
b = int(part, 16)
|
|
21
|
+
if 0 <= b <= 255:
|
|
22
|
+
bytes_list.append(b)
|
|
23
|
+
else:
|
|
24
|
+
return ""
|
|
25
|
+
if not bytes_list:
|
|
26
|
+
return ""
|
|
27
|
+
data = bytes(bytes_list)
|
|
28
|
+
s = data.decode("utf-16-le", errors="replace").rstrip("\x00")
|
|
29
|
+
return s
|
|
30
|
+
except (ValueError, UnicodeDecodeError):
|
|
31
|
+
return ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decode_dword(raw: str) -> int:
|
|
35
|
+
"""
|
|
36
|
+
Decode a dword registry value (8 hex digits) to an integer.
|
|
37
|
+
Returns 0 on invalid/empty input.
|
|
38
|
+
"""
|
|
39
|
+
if not raw or not raw.strip():
|
|
40
|
+
return 0
|
|
41
|
+
raw = raw.strip()
|
|
42
|
+
m = re.match(r"^([0-9a-fA-F]{1,8})$", raw)
|
|
43
|
+
if not m:
|
|
44
|
+
return 0
|
|
45
|
+
try:
|
|
46
|
+
return int(m.group(1), 16)
|
|
47
|
+
except ValueError:
|
|
48
|
+
return 0
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Parse Windows .reg files into a structure keyed by full key path and value name."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from putty_export.decoders import decode_dword, decode_hex_string
|
|
8
|
+
|
|
9
|
+
# Match key line: [\Software\SimonTatham\PuTTY\Sessions\SessionName]
|
|
10
|
+
KEY_LINE = re.compile(r"^\[(.*)\]$")
|
|
11
|
+
# Match value line: "Name"=dword:00000001 or "Name"=hex(1):00,00,...
|
|
12
|
+
VALUE_LINE = re.compile(r'^"([^"]+)"=(dword|hex\(1\)):(.+)$')
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_reg_file(path: str | Path) -> dict[str, dict[str, Any]]:
|
|
16
|
+
"""
|
|
17
|
+
Parse a .reg file and return a nested dict: keys[full_key_path][value_name] = decoded_value.
|
|
18
|
+
Decodes dword as int and hex(1) as UTF-16LE string. Other value types are ignored.
|
|
19
|
+
"""
|
|
20
|
+
path = Path(path)
|
|
21
|
+
result: dict[str, dict[str, Any]] = {}
|
|
22
|
+
current_key: str | None = None
|
|
23
|
+
|
|
24
|
+
with open(path, "rb") as f:
|
|
25
|
+
first = f.read(2)
|
|
26
|
+
if first == b"\xff\xfe":
|
|
27
|
+
encoding = "utf-16-le"
|
|
28
|
+
elif first == b"\xfe\xff":
|
|
29
|
+
encoding = "utf-16"
|
|
30
|
+
else:
|
|
31
|
+
encoding = "utf-8"
|
|
32
|
+
f.seek(0)
|
|
33
|
+
|
|
34
|
+
with open(path, encoding=encoding, errors="replace") as f:
|
|
35
|
+
for line in f:
|
|
36
|
+
line = line.rstrip("\r\n")
|
|
37
|
+
key_m = KEY_LINE.match(line)
|
|
38
|
+
if key_m:
|
|
39
|
+
current_key = key_m.group(1).strip()
|
|
40
|
+
if current_key and current_key not in result:
|
|
41
|
+
result[current_key] = {}
|
|
42
|
+
continue
|
|
43
|
+
|
|
44
|
+
if current_key is None:
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
value_m = VALUE_LINE.match(line)
|
|
48
|
+
if value_m:
|
|
49
|
+
name, vtype, raw_value = value_m.group(1), value_m.group(2), value_m.group(3)
|
|
50
|
+
if vtype == "dword":
|
|
51
|
+
result[current_key][name] = decode_dword(raw_value)
|
|
52
|
+
elif vtype == "hex(1)":
|
|
53
|
+
result[current_key][name] = decode_hex_string(raw_value)
|
|
54
|
+
|
|
55
|
+
return result
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Filter parsed registry keys to SSH sessions only (Protocol=ssh, non-empty HostName)."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from urllib.parse import unquote
|
|
5
|
+
|
|
6
|
+
# PuTTY sessions live under \Software\SimonTatham\PuTTY\Sessions\<SessionName>
|
|
7
|
+
SESSIONS_PREFIX = r"\Software\SimonTatham\PuTTY\Sessions"
|
|
8
|
+
SESSIONS_PREFIX_NORMALIZED = SESSIONS_PREFIX.lower().replace("/", "\\")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _normalize_key(key: str) -> str:
|
|
12
|
+
return key.replace("/", "\\").strip("\\").lower()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _session_name_from_key(full_key: str) -> str | None:
|
|
16
|
+
"""Extract session name from key path. Returns None if not a session key."""
|
|
17
|
+
norm = _normalize_key(full_key)
|
|
18
|
+
prefix = SESSIONS_PREFIX_NORMALIZED.strip("\\")
|
|
19
|
+
if not norm.startswith(prefix):
|
|
20
|
+
return None
|
|
21
|
+
suffix = norm[len(prefix) :].strip("\\")
|
|
22
|
+
if not suffix:
|
|
23
|
+
return None
|
|
24
|
+
# Session name is the last segment in the original key
|
|
25
|
+
parts = full_key.replace("/", "\\").strip("\\").split("\\")
|
|
26
|
+
if len(parts) < 2:
|
|
27
|
+
return None
|
|
28
|
+
name = parts[-1]
|
|
29
|
+
return unquote(name)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def filter_ssh_sessions(
|
|
33
|
+
keys: dict[str, dict[str, object]],
|
|
34
|
+
*,
|
|
35
|
+
skip_default_settings: bool = True,
|
|
36
|
+
) -> dict[str, dict[str, object]]:
|
|
37
|
+
"""
|
|
38
|
+
Return a dict of session_name -> session_values for keys that are SSH sessions:
|
|
39
|
+
- Key path is ...\\Sessions\\<Name> (not the bare Sessions key).
|
|
40
|
+
- Protocol (decoded string) equals "ssh" (case-insensitive).
|
|
41
|
+
- HostName (decoded string) is non-empty after strip.
|
|
42
|
+
- Optionally skip session name "Default Settings".
|
|
43
|
+
Duplicate session names: later key wins.
|
|
44
|
+
"""
|
|
45
|
+
result: dict[str, dict[str, object]] = {}
|
|
46
|
+
norm_prefix = _normalize_key(SESSIONS_PREFIX).rstrip("\\")
|
|
47
|
+
|
|
48
|
+
for full_key, values in keys.items():
|
|
49
|
+
session_name = _session_name_from_key(full_key)
|
|
50
|
+
if session_name is None:
|
|
51
|
+
continue
|
|
52
|
+
if skip_default_settings and session_name == "Default Settings":
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
protocol_val = values.get("Protocol")
|
|
56
|
+
if isinstance(protocol_val, str):
|
|
57
|
+
protocol = protocol_val.strip().lower()
|
|
58
|
+
else:
|
|
59
|
+
protocol = ""
|
|
60
|
+
if protocol != "ssh":
|
|
61
|
+
continue
|
|
62
|
+
|
|
63
|
+
hostname_val = values.get("HostName")
|
|
64
|
+
if isinstance(hostname_val, str):
|
|
65
|
+
hostname = hostname_val.strip()
|
|
66
|
+
else:
|
|
67
|
+
hostname = ""
|
|
68
|
+
if not hostname:
|
|
69
|
+
continue
|
|
70
|
+
|
|
71
|
+
result[session_name] = dict(values)
|
|
72
|
+
return result
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Build OpenSSH config text from filtered PuTTY session data."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _get_str(data: dict[str, Any], key: str, default: str = "") -> str:
|
|
8
|
+
v = data.get(key)
|
|
9
|
+
if isinstance(v, str):
|
|
10
|
+
return v.strip()
|
|
11
|
+
return default
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_int(data: dict[str, Any], key: str, default: int = 0) -> int:
|
|
15
|
+
v = data.get(key)
|
|
16
|
+
if isinstance(v, int):
|
|
17
|
+
return v
|
|
18
|
+
if isinstance(v, str) and v.strip().isdigit():
|
|
19
|
+
return int(v.strip())
|
|
20
|
+
return default
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _quote_value(s: str) -> str:
|
|
24
|
+
"""Quote value if it contains spaces or special characters."""
|
|
25
|
+
if not s:
|
|
26
|
+
return '""'
|
|
27
|
+
if re.search(r'[\s#"\\]', s):
|
|
28
|
+
return '"' + s.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
29
|
+
return s
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_proxy_directive(data: dict[str, Any]) -> str | None:
|
|
33
|
+
"""Build ProxyCommand or ProxyJump line. Returns None if no proxy."""
|
|
34
|
+
method = _get_int(data, "ProxyMethod", 0)
|
|
35
|
+
host = _get_str(data, "ProxyHost")
|
|
36
|
+
if method == 0 or not host:
|
|
37
|
+
return None
|
|
38
|
+
port = _get_int(data, "ProxyPort", 80)
|
|
39
|
+
username = _get_str(data, "ProxyUsername")
|
|
40
|
+
|
|
41
|
+
if method == 5:
|
|
42
|
+
# SSH proxy (jump host)
|
|
43
|
+
if username:
|
|
44
|
+
return f" ProxyJump {username}@{host}"
|
|
45
|
+
return f" ProxyJump {host}"
|
|
46
|
+
|
|
47
|
+
if method == 1:
|
|
48
|
+
# SOCKS 4/5
|
|
49
|
+
return f" ProxyCommand nc -x {host}:{port} %h %p"
|
|
50
|
+
if method == 2:
|
|
51
|
+
# HTTP CONNECT
|
|
52
|
+
return f" ProxyCommand connect -H {host}:{port} %h %p"
|
|
53
|
+
if method in (3, 4):
|
|
54
|
+
# Telnet or Local: use ProxyTelnetCommand (custom command)
|
|
55
|
+
cmd = _get_str(data, "ProxyTelnetCommand")
|
|
56
|
+
if cmd:
|
|
57
|
+
cmd = cmd.replace("%host", "%h").replace("%port", "%p")
|
|
58
|
+
return f" ProxyCommand {cmd}"
|
|
59
|
+
return None
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parse_port_forwardings(raw: str) -> list[tuple[str, str]]:
|
|
64
|
+
"""
|
|
65
|
+
Parse PortForwardings string. Returns list of (directive_name, value).
|
|
66
|
+
Format: Lport=host:port, Rport=host:port, Dport. Optional bind: Lbind:port=host:port.
|
|
67
|
+
"""
|
|
68
|
+
if not raw or not raw.strip():
|
|
69
|
+
return []
|
|
70
|
+
result = []
|
|
71
|
+
for part in raw.split(","):
|
|
72
|
+
part = part.strip()
|
|
73
|
+
if not part:
|
|
74
|
+
continue
|
|
75
|
+
if part.startswith("L"):
|
|
76
|
+
rest = part[1:]
|
|
77
|
+
if "=" in rest:
|
|
78
|
+
left, right = rest.split("=", 1)
|
|
79
|
+
# left can be "port" or "bind:port"
|
|
80
|
+
result.append(("LocalForward", f"{left.strip()} {right.strip()}"))
|
|
81
|
+
else:
|
|
82
|
+
result.append(("LocalForward", rest))
|
|
83
|
+
elif part.startswith("R"):
|
|
84
|
+
rest = part[1:]
|
|
85
|
+
if "=" in rest:
|
|
86
|
+
left, right = rest.split("=", 1)
|
|
87
|
+
result.append(("RemoteForward", f"{left.strip()} {right.strip()}"))
|
|
88
|
+
else:
|
|
89
|
+
result.append(("RemoteForward", rest))
|
|
90
|
+
elif part.startswith("D"):
|
|
91
|
+
port = part[1:].strip()
|
|
92
|
+
if port:
|
|
93
|
+
result.append(("DynamicForward", port))
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _identity_file_path(path: str) -> str:
|
|
98
|
+
"""Normalize path: backslashes to forward slashes for cross-platform hint."""
|
|
99
|
+
if not path:
|
|
100
|
+
return path
|
|
101
|
+
return path.replace("\\", "/")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_host_block(session_name: str, data: dict[str, Any]) -> str:
|
|
105
|
+
"""Build one Host block for a single session. session_name becomes the Host alias."""
|
|
106
|
+
lines = [f"Host {_quote_value(session_name)}"]
|
|
107
|
+
|
|
108
|
+
hostname = _get_str(data, "HostName")
|
|
109
|
+
if hostname:
|
|
110
|
+
lines.append(f" HostName {hostname}")
|
|
111
|
+
|
|
112
|
+
port = _get_int(data, "PortNumber", 22)
|
|
113
|
+
if port != 22:
|
|
114
|
+
lines.append(f" Port {port}")
|
|
115
|
+
else:
|
|
116
|
+
lines.append(" Port 22")
|
|
117
|
+
|
|
118
|
+
user = _get_str(data, "UserName")
|
|
119
|
+
if user:
|
|
120
|
+
lines.append(f" User {user}")
|
|
121
|
+
|
|
122
|
+
identity = _get_str(data, "PublicKeyFile")
|
|
123
|
+
if identity:
|
|
124
|
+
lines.append(f" IdentityFile {_quote_value(_identity_file_path(identity))}")
|
|
125
|
+
|
|
126
|
+
proxy = _build_proxy_directive(data)
|
|
127
|
+
if proxy:
|
|
128
|
+
lines.append(proxy)
|
|
129
|
+
|
|
130
|
+
port_fwd = _get_str(data, "PortForwardings")
|
|
131
|
+
for directive, value in _parse_port_forwardings(port_fwd):
|
|
132
|
+
lines.append(f" {directive} {value}")
|
|
133
|
+
|
|
134
|
+
agent_fwd = _get_int(data, "AgentFwd", 0)
|
|
135
|
+
if agent_fwd:
|
|
136
|
+
lines.append(" ForwardAgent yes")
|
|
137
|
+
|
|
138
|
+
compression = _get_int(data, "Compression", 0)
|
|
139
|
+
if compression:
|
|
140
|
+
lines.append(" Compression yes")
|
|
141
|
+
|
|
142
|
+
x11 = _get_int(data, "X11Forward", 0)
|
|
143
|
+
if x11:
|
|
144
|
+
lines.append(" ForwardX11 yes")
|
|
145
|
+
|
|
146
|
+
return "\n".join(lines)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_ssh_config(sessions: dict[str, dict[str, Any]]) -> str:
|
|
150
|
+
"""Build full SSH config from sessions dict (session_name -> values)."""
|
|
151
|
+
blocks = []
|
|
152
|
+
for name in sorted(sessions.keys()):
|
|
153
|
+
blocks.append(build_host_block(name, sessions[name]))
|
|
154
|
+
return "\n\n".join(blocks)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: putty-export
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Export PuTTY session settings from Windows registry to OpenSSH config format
|
|
5
|
+
Author: putty-export
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Environment :: Console
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Requires-Python: >=3.8
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: build; extra == "dev"
|
|
22
|
+
Requires-Dist: twine; extra == "dev"
|
|
23
|
+
|
|
24
|
+
# putty-export
|
|
25
|
+
|
|
26
|
+
Export PuTTY session settings from a Windows registry (.reg) file to OpenSSH `~/.ssh/config` format.
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Print SSH config to stdout
|
|
32
|
+
putty-export putty_sessions.reg
|
|
33
|
+
|
|
34
|
+
# Write to a file
|
|
35
|
+
putty-export putty_sessions.reg -o ~/.ssh/config
|
|
36
|
+
|
|
37
|
+
# Include the "Default Settings" template session (normally skipped)
|
|
38
|
+
putty-export putty_sessions.reg --include-default-settings -o config
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Exporting PuTTY sessions from Windows
|
|
42
|
+
|
|
43
|
+
1. Open **Registry Editor** (`regedit.exe`).
|
|
44
|
+
2. Navigate to `HKEY_CURRENT_USER\Software\SimonTatham\PuTTY\Sessions`.
|
|
45
|
+
3. Right-click **Sessions** and choose **Export**.
|
|
46
|
+
4. Save as a `.reg` file (e.g. `putty_sessions.reg`) and transfer it to the machine where you run `putty-export`.
|
|
47
|
+
|
|
48
|
+
## PuTTY → OpenSSH mapping
|
|
49
|
+
|
|
50
|
+
| PuTTY (registry) | OpenSSH config | Notes |
|
|
51
|
+
|--------------------------------|-------------------------------|-------|
|
|
52
|
+
| Session name (from key path) | `Host` | URL-decoded (e.g. `%20` → space). |
|
|
53
|
+
| HostName | `HostName` | Decoded from UTF-16LE. |
|
|
54
|
+
| PortNumber | `Port` | Default 22 if missing. |
|
|
55
|
+
| UserName | `User` | Omitted if empty. |
|
|
56
|
+
| PublicKeyFile | `IdentityFile` | Path normalized to forward slashes. See [Keys](#keys) below. |
|
|
57
|
+
| ProxyMethod + ProxyHost/Port/Username | `ProxyCommand` or `ProxyJump` | See [Proxy](#proxy) below. |
|
|
58
|
+
| PortForwardings | `LocalForward` / `RemoteForward` / `DynamicForward` | Comma-separated: `Lport=host:port`, `Rport=host:port`, `Dport`. |
|
|
59
|
+
| AgentFwd | `ForwardAgent` | 1 → yes (omitted when 0). |
|
|
60
|
+
| Compression | `Compression` | 1 → yes (omitted when 0). |
|
|
61
|
+
| X11Forward | `ForwardX11` | 1 → yes (omitted when 0). |
|
|
62
|
+
|
|
63
|
+
### Keys
|
|
64
|
+
|
|
65
|
+
- **IdentityFile** is written with the path from PuTTY (backslashes converted to forward slashes). PuTTY uses `.ppk` keys; OpenSSH uses PEM/OpenSSH format. Convert keys with:
|
|
66
|
+
```bash
|
|
67
|
+
puttygen key.ppk -O private-openssh -o ~/.ssh/key_openssh
|
|
68
|
+
```
|
|
69
|
+
Then point `IdentityFile` at the converted file, or replace the path in the generated config.
|
|
70
|
+
|
|
71
|
+
### Proxy
|
|
72
|
+
|
|
73
|
+
- **ProxyMethod** 0 = none (no directive). 1 = SOCKS → `ProxyCommand nc -x host:port %h %p`. 2 = HTTP CONNECT → `ProxyCommand connect -H host:port %h %p`. 3/4 = Telnet/Local → `ProxyCommand` from ProxyTelnetCommand. 5 = SSH proxy → `ProxyJump user@host`.
|
|
74
|
+
- **ProxyPassword** is not stored in SSH config. Use key-based auth or other means for proxy/jump host authentication.
|
|
75
|
+
|
|
76
|
+
## Requirements
|
|
77
|
+
|
|
78
|
+
- Python 3.8+
|
|
79
|
+
- No external dependencies (stdlib only).
|
|
80
|
+
|
|
81
|
+
## Installation
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install -e .
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Development
|
|
88
|
+
|
|
89
|
+
Run tests:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
python -m unittest discover -s tests -v
|
|
93
|
+
```
|
|
94
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
putty_export/__init__.py,sha256=aAgr_9AKx5RqQwErZa9m3oIN6gNmmysQcZ9zgGyeGEc,322
|
|
2
|
+
putty_export/cli.py,sha256=YVf-zC0AL8idjshB8fqTAm-0oVBehxHS0P_cN4QxFRk,1585
|
|
3
|
+
putty_export/decoders.py,sha256=_StamXINcs7tdjrxGDPJL9BOcj_liTGBuEzDRe4eUmY,1318
|
|
4
|
+
putty_export/reg_parser.py,sha256=ne5U1Tg7JKsCpL7sjSxQDl-gjcTNJZu4Yb-nLU0lNxw,1926
|
|
5
|
+
putty_export/session_filter.py,sha256=bJXF9JKFGNt3AC8Vae93m5A-0gW1EO8DhtMvM46uAy8,2429
|
|
6
|
+
putty_export/ssh_config.py,sha256=RkOmCyM20vkxzgWMVEGxN3fhGFZrd2xy9ojiiUPV6Gc,4823
|
|
7
|
+
putty_export-0.1.0.dist-info/METADATA,sha256=D_3I4cgI2m_yxJvfL9q-fPiFosrc8WBhd_OzmZqqAHg,3763
|
|
8
|
+
putty_export-0.1.0.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
|
|
9
|
+
putty_export-0.1.0.dist-info/entry_points.txt,sha256=IOAoXLkXJhM5SEKe0PhwhpX9IjsrlgjqDxEKjfwSxdY,55
|
|
10
|
+
putty_export-0.1.0.dist-info/top_level.txt,sha256=cCSHNEpHUPVIDEjiB0aL-UT05f8BNW5oJxOl5s0L95Q,13
|
|
11
|
+
putty_export-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
putty_export
|