@@ -0,0 +1,423 @@
|
||||
// Required namespaces:
|
||||
// System, System.IO, System.Linq, System.Text, System.Text.RegularExpressions, System.Collections.Generic, Renci.SshNet
|
||||
|
||||
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 {
|
||||
public static List<Host> Hosts { get; set; }
|
||||
|
||||
static SshHosts() {
|
||||
Hosts = new List<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(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();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
for (int i = 0; i < Hosts.Count; i++) {
|
||||
if (string.Equals(Hosts[i].Name, name, StringComparison.OrdinalIgnoreCase)) {
|
||||
return Hosts[i];
|
||||
}
|
||||
}
|
||||
throw new KeyNotFoundException($"SSH host '{name}' not found.");
|
||||
}
|
||||
|
||||
public static bool TryGetHost(string name, out Host host) {
|
||||
for (int i = 0; i < Hosts.Count; i++) {
|
||||
if (string.Equals(Hosts[i].Name, name, StringComparison.OrdinalIgnoreCase)) {
|
||||
host = Hosts[i];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
host = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user