416 lines
15 KiB
C#
416 lines
15 KiB
C#
// Required namespaces:
|
|
// System, System.IO, System.Linq, System.Text, System.Text.RegularExpressions, System.Collections.Generic, Renci.SshNet
|
|
|
|
using System.Collections.Immutable;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace aeqw89.tools.Publish;
|
|
|
|
public record Host(
|
|
string Name,
|
|
string Hostname,
|
|
string User,
|
|
string Password,
|
|
int Port,
|
|
List<string> IdentityFiles,
|
|
Renci.SshNet.ProxyTypes? ProxyType,
|
|
string? ProxyHost,
|
|
int? ProxyPort,
|
|
string? ProxyUser,
|
|
string? ProxyPassword
|
|
);
|
|
|
|
public static class SshHosts {
|
|
private static ImmutableDictionary<string, Host> hosts;
|
|
public static IReadOnlyDictionary<string, Host> Hosts => hosts;
|
|
|
|
static SshHosts() {
|
|
var hosts = new Dictionary<string, Host>();
|
|
|
|
var path = Path.Combine(
|
|
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
|
".ssh",
|
|
"config"
|
|
);
|
|
|
|
if (!File.Exists(path)) return;
|
|
|
|
var lines = File.ReadAllLines(path);
|
|
|
|
string[]? currentNames = null;
|
|
string? currentHostName = null;
|
|
string? currentUser = null;
|
|
string? currentPassword = null;
|
|
int? currentPort = null;
|
|
List<string>? currentIdentityFiles = null;
|
|
|
|
// Proxy (explicit)
|
|
Renci.SshNet.ProxyTypes? currentProxyType = null;
|
|
string? currentProxyHost = null;
|
|
int? currentProxyPort = null;
|
|
string? currentProxyUser = null;
|
|
string? currentProxyPassword = null;
|
|
|
|
// Proxy (derived from ProxyCommand)
|
|
string? currentProxyCommand = null;
|
|
|
|
void ResetBlock()
|
|
{
|
|
currentNames = null;
|
|
currentHostName = null;
|
|
currentUser = null;
|
|
currentPassword = null;
|
|
currentPort = null;
|
|
currentIdentityFiles = null;
|
|
|
|
currentProxyType = null;
|
|
currentProxyHost = null;
|
|
currentProxyPort = null;
|
|
currentProxyUser = null;
|
|
currentProxyPassword = null;
|
|
|
|
currentProxyCommand = null;
|
|
}
|
|
|
|
void MaybeDeriveProxyFromProxyCommand()
|
|
{
|
|
if (string.IsNullOrWhiteSpace(currentProxyCommand)) return;
|
|
if (currentProxyType != null && currentProxyHost != null && currentProxyPort != null) return;
|
|
|
|
// Very common forms:
|
|
// nc -x host:port -X 5 %h %p (SOCKS5)
|
|
// nc -x host:port -X 4 %h %p (SOCKS4)
|
|
// nc -x host:port %h %p (default: treat as SOCKS5)
|
|
// nc -X connect -x host:port %h %p (HTTP CONNECT)
|
|
// connect -S host:port %h %p (treat as SOCKS5)
|
|
var s = currentProxyCommand;
|
|
|
|
// host:port extraction
|
|
var hp = Regex.Match(s, @"(?:(?:-x|-S)\s+|\s)([A-Za-z0-9.\-]+):(\d{1,5})");
|
|
if (hp.Success) {
|
|
currentProxyHost ??= hp.Groups[1].Value;
|
|
if (int.TryParse(hp.Groups[2].Value, out var pp) && pp > 0 && pp <= 65535)
|
|
currentProxyPort ??= pp;
|
|
}
|
|
|
|
// type extraction
|
|
if (Regex.IsMatch(s, @"-X\s+5\b", RegexOptions.IgnoreCase))
|
|
currentProxyType ??= Renci.SshNet.ProxyTypes.Socks5;
|
|
else if (Regex.IsMatch(s, @"-X\s+4\b", RegexOptions.IgnoreCase))
|
|
currentProxyType ??= Renci.SshNet.ProxyTypes.Socks4;
|
|
else if (Regex.IsMatch(s, @"-X\s+connect\b", RegexOptions.IgnoreCase))
|
|
currentProxyType ??= Renci.SshNet.ProxyTypes.Http;
|
|
else if (s.Contains("connect ", System.StringComparison.OrdinalIgnoreCase))
|
|
currentProxyType ??= Renci.SshNet.ProxyTypes.Http;
|
|
else if (s.Contains("nc ", System.StringComparison.OrdinalIgnoreCase))
|
|
currentProxyType ??= Renci.SshNet.ProxyTypes.Socks5;
|
|
|
|
// If we still don't have enough, leave proxy unset.
|
|
}
|
|
|
|
void Flush()
|
|
{
|
|
if (currentNames == null || currentNames.Length == 0) {
|
|
ResetBlock();
|
|
return;
|
|
}
|
|
|
|
// Try deriving proxy from ProxyCommand if explicit values weren't set
|
|
MaybeDeriveProxyFromProxyCommand();
|
|
|
|
foreach (var n in currentNames) {
|
|
var hn = string.IsNullOrWhiteSpace(currentHostName) ? n : currentHostName!;
|
|
var idFiles = new List<string>();
|
|
if (currentIdentityFiles != null) {
|
|
foreach (var f in currentIdentityFiles) idFiles.Add(ExpandPath(f));
|
|
}
|
|
|
|
hosts.Add(n, new Host(
|
|
Name: n,
|
|
Hostname: hn,
|
|
User: currentUser ?? string.Empty,
|
|
Password: currentPassword ?? string.Empty,
|
|
Port: currentPort ?? 22,
|
|
IdentityFiles: idFiles,
|
|
ProxyType: currentProxyType,
|
|
ProxyHost: currentProxyHost,
|
|
ProxyPort: currentProxyPort,
|
|
ProxyUser: currentProxyUser,
|
|
ProxyPassword: currentProxyPassword
|
|
));
|
|
}
|
|
|
|
ResetBlock();
|
|
}
|
|
|
|
foreach (var raw in lines) {
|
|
var line = raw.Trim();
|
|
if (line.Length == 0 || line.StartsWith("#")) continue;
|
|
|
|
int idx = line.IndexOfAny(new[] { ' ', '\t' });
|
|
string key, value;
|
|
if (idx < 0) {
|
|
key = line;
|
|
value = string.Empty;
|
|
} else {
|
|
key = line[..idx];
|
|
value = line[idx..].Trim();
|
|
}
|
|
|
|
switch (key.ToLowerInvariant()) {
|
|
case "host":
|
|
Flush();
|
|
currentNames = SplitArgs(value).ToArray();
|
|
break;
|
|
|
|
case "hostname":
|
|
if (currentNames != null) currentHostName = value;
|
|
break;
|
|
|
|
case "user":
|
|
if (currentNames != null) currentUser = value;
|
|
break;
|
|
|
|
case "password":
|
|
// Non-standard; supported here as a convenience (used for password auth or key passphrase)
|
|
if (currentNames != null) currentPassword = value;
|
|
break;
|
|
|
|
case "port":
|
|
if (currentNames != null && int.TryParse(value, out var p) && p > 0 && p <= 65535)
|
|
currentPort = p;
|
|
break;
|
|
|
|
case "identityfile":
|
|
if (currentNames != null) {
|
|
currentIdentityFiles ??= new List<string>();
|
|
foreach (var f in SplitArgs(value)) {
|
|
if (!string.IsNullOrWhiteSpace(f)) currentIdentityFiles.Add(f);
|
|
}
|
|
}
|
|
break;
|
|
|
|
// --- Proxy settings (explicit custom keys) ---
|
|
case "proxytype":
|
|
if (currentNames != null) {
|
|
var v = value.ToLowerInvariant();
|
|
currentProxyType =
|
|
v switch {
|
|
"socks5" or "socks" or "socks5h" => Renci.SshNet.ProxyTypes.Socks5,
|
|
"socks4" => Renci.SshNet.ProxyTypes.Socks4,
|
|
"http" or "https" or "connect" => Renci.SshNet.ProxyTypes.Http,
|
|
_ => currentProxyType
|
|
};
|
|
}
|
|
break;
|
|
|
|
case "proxyhost":
|
|
if (currentNames != null) currentProxyHost = value;
|
|
break;
|
|
|
|
case "proxyport":
|
|
if (currentNames != null && int.TryParse(value, out var prxP) && prxP > 0 && prxP <= 65535)
|
|
currentProxyPort = prxP;
|
|
break;
|
|
|
|
case "proxyuser":
|
|
if (currentNames != null) currentProxyUser = value;
|
|
break;
|
|
|
|
case "proxypassword":
|
|
if (currentNames != null) currentProxyPassword = value;
|
|
break;
|
|
|
|
// --- OpenSSH-style hints we try to interpret ---
|
|
case "proxycommand":
|
|
if (currentNames != null) currentProxyCommand = value;
|
|
break;
|
|
|
|
// (Note: ProxyJump is non-trivial to replicate in SSH.NET; not parsed here.)
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
Flush();
|
|
SshHosts.hosts = hosts.ToImmutableDictionary();
|
|
}
|
|
|
|
// Builds a ConnectionInfo from a parsed host, ensuring all ~/.ssh private keys are tried.
|
|
private static Renci.SshNet.ConnectionInfo BuildConnection(Host h) {
|
|
var authMethods = new List<Renci.SshNet.AuthenticationMethod>();
|
|
|
|
// 1) Collect candidate key file paths: from config + everything that looks like a private key in ~/.ssh
|
|
var keyPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var f in h.IdentityFiles)
|
|
if (!string.IsNullOrWhiteSpace(f))
|
|
keyPaths.Add(f);
|
|
|
|
foreach (var f in EnumerateAllSshPrivateKeys())
|
|
keyPaths.Add(f);
|
|
|
|
// 2) Load keys (try with passphrase, then without)
|
|
var pkFiles = new List<Renci.SshNet.PrivateKeyFile>();
|
|
foreach (var p in keyPaths) {
|
|
if (!File.Exists(p)) continue;
|
|
try {
|
|
if (!string.IsNullOrEmpty(h.Password)) {
|
|
try {
|
|
pkFiles.Add(new Renci.SshNet.PrivateKeyFile(p, h.Password));
|
|
continue;
|
|
}
|
|
catch { /* fall through to try without passphrase */
|
|
}
|
|
}
|
|
|
|
pkFiles.Add(new Renci.SshNet.PrivateKeyFile(p));
|
|
}
|
|
catch { /* skip unreadable/unparsable key */
|
|
}
|
|
}
|
|
|
|
if (pkFiles.Count > 0)
|
|
authMethods.Add(new Renci.SshNet.PrivateKeyAuthenticationMethod(h.User, pkFiles.ToArray()));
|
|
|
|
// 3) Optional password auth (remote account password)
|
|
if (!string.IsNullOrEmpty(h.Password))
|
|
authMethods.Add(new Renci.SshNet.PasswordAuthenticationMethod(h.User, h.Password));
|
|
|
|
if (authMethods.Count == 0)
|
|
throw new InvalidOperationException($"No authentication methods available for host '{h.Name}'.");
|
|
|
|
// 4) Proxy-aware ConnectionInfo
|
|
if (h.ProxyType.HasValue && !string.IsNullOrWhiteSpace(h.ProxyHost) && h.ProxyPort.HasValue) {
|
|
return new Renci.SshNet.ConnectionInfo(
|
|
h.Hostname,
|
|
h.Port,
|
|
h.User,
|
|
h.ProxyType.Value,
|
|
h.ProxyHost!,
|
|
h.ProxyPort.Value,
|
|
h.ProxyUser,
|
|
h.ProxyPassword,
|
|
authMethods.ToArray()
|
|
);
|
|
}
|
|
|
|
return new Renci.SshNet.ConnectionInfo(h.Hostname, h.Port, h.User, authMethods.ToArray());
|
|
}
|
|
|
|
// Enumerate everything in ~/.ssh that *looks* like a private key.
|
|
// Skips obvious non-keys like *.pub, known_hosts, config, and directories.
|
|
private static IEnumerable<string> EnumerateAllSshPrivateKeys() {
|
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
var sshDir = Path.Combine(home, ".ssh");
|
|
if (!Directory.Exists(sshDir)) yield break;
|
|
|
|
// Common private key basenames to prioritize
|
|
var preferred = new HashSet<string>(StringComparer.OrdinalIgnoreCase) {
|
|
"id_rsa", "id_ecdsa", "id_ed25519", "id_dsa"
|
|
};
|
|
|
|
// First yield preferred names if present
|
|
foreach (var name in preferred) {
|
|
var p = Path.Combine(sshDir, name);
|
|
if (File.Exists(p)) yield return p;
|
|
}
|
|
|
|
// Then yield everything else that looks like a key
|
|
foreach (var file in Directory.EnumerateFiles(sshDir)) {
|
|
var name = Path.GetFileName(file);
|
|
|
|
// Skip ones we already yielded
|
|
if (preferred.Contains(name)) continue;
|
|
|
|
// Exclude common non-key files and public keys
|
|
if (name.EndsWith(".pub", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.Equals("config", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.Equals("known_hosts", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.Equals("authorized_keys", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.EndsWith(".crt", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.EndsWith(".csr", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.EndsWith(".pem.pub", StringComparison.OrdinalIgnoreCase)) continue; // guard for odd combos
|
|
if (name.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)) continue;
|
|
if (name.EndsWith(".conf", StringComparison.OrdinalIgnoreCase)) continue;
|
|
|
|
// Heuristic: accept files with no extension, or with .key/.pem
|
|
var ext = Path.GetExtension(name);
|
|
if (string.IsNullOrEmpty(ext) ||
|
|
ext.Equals(".key", StringComparison.OrdinalIgnoreCase) ||
|
|
ext.Equals(".pem", StringComparison.OrdinalIgnoreCase)) {
|
|
yield return file;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
public static Host Get(string name) {
|
|
return TryGetHost(name, out var h) ? h : throw new KeyNotFoundException($"SSH host '{name}' not found.");
|
|
}
|
|
|
|
public static bool TryGetHost(string name, [NotNullWhen(true)] out Host? host) {
|
|
return Hosts.TryGetValue(name, out host);
|
|
}
|
|
|
|
public static Renci.SshNet.ConnectionInfo GetConnection(string name)
|
|
{
|
|
var h = Get(name);
|
|
return BuildConnection(h);
|
|
}
|
|
|
|
public static bool TryGetConnection(string name, out Renci.SshNet.ConnectionInfo connectionInfo)
|
|
{
|
|
if (TryGetHost(name, out var h)) {
|
|
try {
|
|
connectionInfo = BuildConnection(h);
|
|
return true;
|
|
} catch {
|
|
// If auth material is unusable, return false.
|
|
}
|
|
}
|
|
connectionInfo = default!;
|
|
return false;
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// Splits a value into whitespace-delimited tokens while respecting double/single quotes.
|
|
static IEnumerable<string> SplitArgs(string input)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(input)) yield break;
|
|
|
|
bool inSingle = false, inDouble = false;
|
|
var sb = new StringBuilder();
|
|
|
|
for (int i = 0; i < input.Length; i++) {
|
|
char c = input[i];
|
|
|
|
if (c == '"' && !inSingle) { inDouble = !inDouble; continue; }
|
|
if (c == '\'' && !inDouble) { inSingle = !inSingle; continue; }
|
|
|
|
if (!inSingle && !inDouble && char.IsWhiteSpace(c)) {
|
|
if (sb.Length > 0) { yield return sb.ToString(); sb.Clear(); }
|
|
} else {
|
|
sb.Append(c);
|
|
}
|
|
}
|
|
if (sb.Length > 0) yield return sb.ToString();
|
|
}
|
|
|
|
// Expands leading "~" to user home. Leaves %h, %r, etc. untouched.
|
|
static string ExpandPath(string p)
|
|
{
|
|
if (string.IsNullOrEmpty(p)) return p;
|
|
if (p == "~") return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
if (p.StartsWith("~/")) {
|
|
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
|
return Path.Combine(home, p[2..]);
|
|
}
|
|
return p;
|
|
}
|
|
}
|