First Commit

This commit is contained in:
qwsdcvghyu89
2025-09-21 14:57:28 +10:00
commit a9a9733e66
72 changed files with 2686 additions and 0 deletions
+423
View File
@@ -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;
}
}