39b58e1a77
Third mode alongside proxy and VLESS+Reality. Bundles wireproxy-awg built from a pinned, audited source commit (fork of wireproxy over the official amneziawg-go), exposing a local SOCKS5 tunnel with no TUN/driver/admin. User picks their AmneziaWG .conf; the app appends a [Socks5] section and routes Claude through it with the same fail-closed firewall. build-wireproxy.ps1 reproducibly builds the binary (portable Go + pinned commit); it is embedded as a resource and not stored in git. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
467 lines
21 KiB
C#
467 lines
21 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.IO.Compression;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Reflection;
|
|
using System.Runtime.InteropServices;
|
|
using System.Security.Principal;
|
|
using System.Text;
|
|
using System.Threading;
|
|
|
|
namespace ClaudeProxy
|
|
{
|
|
// ---- MSIX activation (launch a packaged app with arguments) -------------------
|
|
[ComImport, Guid("2e941141-7f97-4756-ba1d-9decde894a3d"),
|
|
InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
|
interface IApplicationActivationManager
|
|
{
|
|
int ActivateApplication([In, MarshalAs(UnmanagedType.LPWStr)] string appUserModelId,
|
|
[In, MarshalAs(UnmanagedType.LPWStr)] string arguments,
|
|
[In] int options, [Out] out uint processId);
|
|
int ActivateForFile();
|
|
int ActivateForProtocol();
|
|
}
|
|
[ComImport, Guid("45BA127D-10A8-46EA-8AB7-56EA9078943C")]
|
|
class ApplicationActivationManager { }
|
|
|
|
class ClaudeApp { public string InstallLocation; public string Exe; public string Aumid; }
|
|
|
|
static class Core
|
|
{
|
|
public const string XrayVersion = "v26.3.27";
|
|
public const string PackageFamily = "Claude_pzs8sxrjxfjjc";
|
|
public const string Aumid = "Claude_pzs8sxrjxfjjc!Claude";
|
|
public const string FwGroup = "ClaudeProxyLock";
|
|
public const string DefaultProxy = "socks5://127.0.0.1:1080";
|
|
|
|
public static string ConfigDir { get { return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "ClaudeProxy"); } }
|
|
public static string ConfigPath { get { return Path.Combine(ConfigDir, "config.ini"); } }
|
|
public static string BinDir { get { return Path.Combine(ConfigDir, "bin"); } }
|
|
public static string XrayExe { get { return Path.Combine(BinDir, "xray.exe"); } }
|
|
public static string XrayCfgPath { get { return Path.Combine(ConfigDir, "xray-config.json"); } }
|
|
|
|
// ---- admin ----------------------------------------------------------------
|
|
public static bool IsAdmin()
|
|
{
|
|
using (var id = WindowsIdentity.GetCurrent())
|
|
return new WindowsPrincipal(id).IsInRole(WindowsBuiltInRole.Administrator);
|
|
}
|
|
|
|
// Relaunch self elevated with a single token argument; wait for it. Returns false if cancelled.
|
|
public static bool ElevateSelf(string arg)
|
|
{
|
|
try
|
|
{
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = Process.GetCurrentProcess().MainModule.FileName,
|
|
Arguments = arg,
|
|
Verb = "runas",
|
|
UseShellExecute = true,
|
|
WindowStyle = ProcessWindowStyle.Hidden
|
|
};
|
|
var p = Process.Start(psi);
|
|
p.WaitForExit();
|
|
return true;
|
|
}
|
|
catch { return false; } // UAC cancelled / error
|
|
}
|
|
|
|
// ---- config ---------------------------------------------------------------
|
|
public static Config LoadConfig()
|
|
{
|
|
var c = new Config();
|
|
try
|
|
{
|
|
if (File.Exists(ConfigPath))
|
|
foreach (var line in File.ReadAllLines(ConfigPath))
|
|
{
|
|
int i = line.IndexOf('=');
|
|
if (i <= 0) continue;
|
|
string k = line.Substring(0, i).Trim(), v = line.Substring(i + 1);
|
|
switch (k)
|
|
{
|
|
case "mode": c.Mode = v.Trim(); break;
|
|
case "proxy": c.Proxy = v.Trim(); break;
|
|
case "vless": c.Vless = v; break;
|
|
case "awg": c.Awg = v.Trim(); break;
|
|
case "localPort": int.TryParse(v.Trim(), out c.LocalPort); break;
|
|
case "failClosed": c.FailClosed = v.Trim() == "1" || v.Trim().ToLower() == "true"; break;
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
if (c.LocalPort <= 0) c.LocalPort = 10808;
|
|
if (string.IsNullOrEmpty(c.Mode)) c.Mode = "proxy";
|
|
if (string.IsNullOrEmpty(c.Proxy)) c.Proxy = DefaultProxy;
|
|
return c;
|
|
}
|
|
|
|
public static void SaveConfig(Config c)
|
|
{
|
|
Directory.CreateDirectory(ConfigDir);
|
|
var sb = new StringBuilder();
|
|
sb.AppendLine("mode=" + c.Mode);
|
|
sb.AppendLine("proxy=" + c.Proxy);
|
|
sb.AppendLine("vless=" + (c.Vless ?? ""));
|
|
sb.AppendLine("awg=" + (c.Awg ?? ""));
|
|
sb.AppendLine("localPort=" + c.LocalPort);
|
|
sb.AppendLine("failClosed=" + (c.FailClosed ? "1" : "0"));
|
|
File.WriteAllText(ConfigPath, sb.ToString());
|
|
}
|
|
|
|
// ---- proxy parsing --------------------------------------------------------
|
|
public static bool TryParseProxy(string uri, out string host, out int port)
|
|
{
|
|
host = null; port = 0;
|
|
if (string.IsNullOrWhiteSpace(uri)) return false;
|
|
var m = System.Text.RegularExpressions.Regex.Match(uri.Trim(),
|
|
@"^(socks5|socks5h|socks4|http|https)://([^:/]+):(\d+)/?$");
|
|
if (!m.Success) return false;
|
|
host = m.Groups[2].Value; port = int.Parse(m.Groups[3].Value);
|
|
return true;
|
|
}
|
|
public static string ProxyArg(string uri) { return uri.Trim().TrimEnd('/'); }
|
|
|
|
public static bool TcpReachable(string host, int port, int timeoutMs)
|
|
{
|
|
try
|
|
{
|
|
using (var c = new TcpClient())
|
|
{
|
|
var ar = c.BeginConnect(host, port, null, null);
|
|
if (ar.AsyncWaitHandle.WaitOne(timeoutMs) && c.Connected) { c.EndConnect(ar); return true; }
|
|
return false;
|
|
}
|
|
}
|
|
catch { return false; }
|
|
}
|
|
|
|
// ---- Claude app discovery -------------------------------------------------
|
|
public static ClaudeApp GetClaudeApp()
|
|
{
|
|
string exe = null;
|
|
// 1. from a running instance (works de-elevated)
|
|
foreach (var p in Process.GetProcessesByName("claude"))
|
|
{
|
|
try
|
|
{
|
|
string fn = p.MainModule.FileName;
|
|
if (fn.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0 &&
|
|
fn.IndexOf(PackageFamily.Split('_')[1], StringComparison.OrdinalIgnoreCase) >= 0)
|
|
{ exe = fn; break; }
|
|
}
|
|
catch { }
|
|
}
|
|
// 2. enumerate WindowsApps (works when elevated)
|
|
if (exe == null)
|
|
{
|
|
try
|
|
{
|
|
string wa = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "WindowsApps");
|
|
foreach (var dir in Directory.GetDirectories(wa, "Claude_*__" + PackageFamily.Split('_')[1]))
|
|
{
|
|
string cand = Path.Combine(dir, "app", "Claude.exe");
|
|
if (File.Exists(cand)) { exe = cand; break; }
|
|
}
|
|
}
|
|
catch { }
|
|
}
|
|
if (exe == null) throw new Exception("Не найден Claude (десктопный MSIX-пакет).");
|
|
string install = Directory.GetParent(Directory.GetParent(exe).FullName).FullName;
|
|
return new ClaudeApp { Exe = exe, InstallLocation = install, Aumid = Aumid };
|
|
}
|
|
|
|
public static bool ClaudeRunning()
|
|
{
|
|
foreach (var p in Process.GetProcessesByName("claude"))
|
|
try { var fn = p.MainModule.FileName; if (fn.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0) return true; }
|
|
catch { }
|
|
return false;
|
|
}
|
|
|
|
public static void StopClaude()
|
|
{
|
|
foreach (var p in Process.GetProcessesByName("claude"))
|
|
{
|
|
try
|
|
{
|
|
string fn = p.MainModule.FileName;
|
|
if (fn.IndexOf("WindowsApps", StringComparison.OrdinalIgnoreCase) >= 0) { p.Kill(); }
|
|
}
|
|
catch { }
|
|
}
|
|
Thread.Sleep(900);
|
|
}
|
|
|
|
public static uint LaunchClaude(string argLine)
|
|
{
|
|
var mgr = (IApplicationActivationManager)(new ApplicationActivationManager());
|
|
uint pid;
|
|
int hr = mgr.ActivateApplication(Aumid, argLine ?? "", 0, out pid);
|
|
if (hr < 0) throw new Exception("Не удалось запустить Claude (HRESULT 0x" + hr.ToString("X8") + ").");
|
|
return pid;
|
|
}
|
|
|
|
// ---- firewall (Windows Firewall COM API via dynamic late binding) ---------
|
|
// Returns all IPv4 addresses EXCEPT the given ones, as "a-b" range strings.
|
|
public static List<string> Ipv4Complement(IEnumerable<string> ips)
|
|
{
|
|
var vals = ips.Select(IpToUInt).Distinct().OrderBy(x => x).ToList();
|
|
var res = new List<string>();
|
|
ulong cursor = 0, max = 4294967295UL;
|
|
foreach (var v in vals)
|
|
{
|
|
if (v > cursor) res.Add(UIntToIp(cursor) + "-" + UIntToIp(v - 1));
|
|
cursor = v + 1;
|
|
}
|
|
if (cursor <= max) res.Add(UIntToIp(cursor) + "-" + UIntToIp(max));
|
|
return res;
|
|
}
|
|
static ulong IpToUInt(string ip)
|
|
{
|
|
var b = IPAddress.Parse(ip).GetAddressBytes();
|
|
return ((ulong)b[0] << 24) | ((ulong)b[1] << 16) | ((ulong)b[2] << 8) | b[3];
|
|
}
|
|
static string UIntToIp(ulong v)
|
|
{
|
|
return string.Format("{0}.{1}.{2}.{3}", (v >> 24) & 255, (v >> 16) & 255, (v >> 8) & 255, v & 255);
|
|
}
|
|
const string V6All = "::-ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff";
|
|
|
|
static dynamic FwPolicy()
|
|
{
|
|
return Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FwPolicy2"));
|
|
}
|
|
static void AddBlockRule(dynamic policy, string name, string exe, string remote)
|
|
{
|
|
dynamic r = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FWRule"));
|
|
r.Name = name;
|
|
r.Grouping = FwGroup;
|
|
r.Direction = 2; // NET_FW_RULE_DIR_OUT
|
|
r.Action = 0; // NET_FW_ACTION_BLOCK
|
|
r.ApplicationName = exe;
|
|
if (!string.IsNullOrEmpty(remote)) r.RemoteAddresses = remote;
|
|
r.Profiles = 0x7FFFFFFF; // all profiles
|
|
r.Enabled = true;
|
|
policy.Rules.Add(r);
|
|
}
|
|
|
|
public static void RemoveFirewall()
|
|
{
|
|
try
|
|
{
|
|
dynamic policy = FwPolicy();
|
|
var names = new List<string>();
|
|
foreach (dynamic r in policy.Rules)
|
|
{
|
|
try { if ((string)r.Grouping == FwGroup) names.Add((string)r.Name); } catch { }
|
|
}
|
|
foreach (var n in names) { try { policy.Rules.Remove(n); } catch { } }
|
|
}
|
|
catch { }
|
|
}
|
|
|
|
public static void ApplyFirewallForConfig()
|
|
{
|
|
var cfg = LoadConfig();
|
|
var app = GetClaudeApp();
|
|
RemoveFirewall();
|
|
dynamic policy = FwPolicy();
|
|
if (cfg.Mode == "vless" || cfg.Mode == "awg")
|
|
{
|
|
// tunnel: block ALL external (loopback is exempt from firewall filtering)
|
|
AddBlockRule(policy, "ClaudeProxyLock: tunnel", app.Exe, null);
|
|
}
|
|
else
|
|
{
|
|
string host; int port;
|
|
if (!TryParseProxy(cfg.Proxy, out host, out port)) throw new Exception("Некорректный прокси.");
|
|
var addrs = Dns.GetHostAddresses(host);
|
|
var v4 = addrs.Where(a => a.AddressFamily == AddressFamily.InterNetwork).Select(a => a.ToString()).Distinct().ToList();
|
|
var v6 = addrs.Where(a => a.AddressFamily == AddressFamily.InterNetworkV6).Select(a => a.ToString()).Distinct().ToList();
|
|
if (v4.Count > 0)
|
|
AddBlockRule(policy, "ClaudeProxyLock: IPv4", app.Exe, string.Join(",", Ipv4Complement(v4)));
|
|
if (v6.Count == 0)
|
|
AddBlockRule(policy, "ClaudeProxyLock: IPv6", app.Exe, V6All);
|
|
else
|
|
{
|
|
// allow proxy v6, block the rest
|
|
dynamic allow = Activator.CreateInstance(Type.GetTypeFromProgID("HNetCfg.FWRule"));
|
|
allow.Name = "ClaudeProxyLock: IPv6 allow"; allow.Grouping = FwGroup;
|
|
allow.Direction = 2; allow.Action = 1; allow.ApplicationName = app.Exe;
|
|
allow.RemoteAddresses = string.Join(",", v6); allow.Profiles = 0x7FFFFFFF; allow.Enabled = true;
|
|
policy.Rules.Add(allow);
|
|
AddBlockRule(policy, "ClaudeProxyLock: IPv6 block", app.Exe, V6All);
|
|
}
|
|
}
|
|
}
|
|
|
|
// absent | stale | ok (path mismatch -> stale)
|
|
public static string FirewallState(string exe)
|
|
{
|
|
try
|
|
{
|
|
dynamic policy = FwPolicy();
|
|
int count = 0; bool stale = false;
|
|
foreach (dynamic r in policy.Rules)
|
|
{
|
|
try
|
|
{
|
|
if ((string)r.Grouping != FwGroup) continue;
|
|
count++;
|
|
string prog = null; try { prog = (string)r.ApplicationName; } catch { }
|
|
if (!string.IsNullOrEmpty(prog) && exe != null &&
|
|
!prog.Equals(exe, StringComparison.OrdinalIgnoreCase)) stale = true;
|
|
}
|
|
catch { }
|
|
}
|
|
if (count == 0) return "absent";
|
|
return stale ? "stale" : "ok";
|
|
}
|
|
catch { return "absent"; }
|
|
}
|
|
|
|
// ---- Xray -----------------------------------------------------------------
|
|
public static bool XrayPresent() { return File.Exists(XrayExe); }
|
|
|
|
public static void EnsureXray()
|
|
{
|
|
if (XrayPresent()) return;
|
|
Directory.CreateDirectory(BinDir);
|
|
// embedded resource "ClaudeProxy.xray.zip"
|
|
var asm = Assembly.GetExecutingAssembly();
|
|
string resName = asm.GetManifestResourceNames().FirstOrDefault(n => n.EndsWith("xray.zip", StringComparison.OrdinalIgnoreCase));
|
|
if (resName != null)
|
|
{
|
|
string tmp = Path.Combine(Path.GetTempPath(), "claudeproxy-xray.zip");
|
|
using (var rs = asm.GetManifestResourceStream(resName))
|
|
using (var fs = File.Create(tmp))
|
|
rs.CopyTo(fs);
|
|
using (var z = ZipFile.OpenRead(tmp))
|
|
foreach (var e in z.Entries)
|
|
{
|
|
if (string.IsNullOrEmpty(e.Name)) continue;
|
|
string dest = Path.Combine(BinDir, e.Name);
|
|
e.ExtractToFile(dest, true);
|
|
}
|
|
try { File.Delete(tmp); } catch { }
|
|
}
|
|
if (!XrayPresent()) throw new Exception("Не удалось распаковать встроенный Xray.");
|
|
}
|
|
|
|
public static void StopXray()
|
|
{
|
|
foreach (var p in Process.GetProcessesByName("xray"))
|
|
{
|
|
try
|
|
{
|
|
if (p.MainModule.FileName.StartsWith(BinDir, StringComparison.OrdinalIgnoreCase)) p.Kill();
|
|
}
|
|
catch { }
|
|
}
|
|
Thread.Sleep(300);
|
|
}
|
|
|
|
public static bool LocalSocksUp(int port) { return TcpReachable("127.0.0.1", port, 500); }
|
|
|
|
public static void StartXray(string vlessLink, int localPort)
|
|
{
|
|
EnsureXray();
|
|
StopXray();
|
|
File.WriteAllText(XrayCfgPath, Vless.BuildXrayConfig(vlessLink, localPort));
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = XrayExe,
|
|
Arguments = "run -config \"" + XrayCfgPath + "\"",
|
|
UseShellExecute = false,
|
|
CreateNoWindow = true,
|
|
WindowStyle = ProcessWindowStyle.Hidden
|
|
};
|
|
Process.Start(psi);
|
|
for (int i = 0; i < 30; i++) { if (LocalSocksUp(localPort)) return; Thread.Sleep(200); }
|
|
StopXray();
|
|
throw new Exception("Xray не поднял локальный SOCKS на 127.0.0.1:" + localPort + ". Проверь ссылку и сервер.");
|
|
}
|
|
|
|
public static bool XrayRunning() { return Process.GetProcessesByName("xray").Any(p =>
|
|
{ try { return p.MainModule.FileName.StartsWith(BinDir, StringComparison.OrdinalIgnoreCase); } catch { return false; } }); }
|
|
|
|
// ---- AmneziaWG (wireproxy-awg userspace SOCKS) ----------------------------
|
|
public static string AwgExe { get { return Path.Combine(BinDir, "wireproxy-awg.exe"); } }
|
|
public static string AwgRuntimeCfg { get { return Path.Combine(ConfigDir, "awg-runtime.conf"); } }
|
|
public static bool AwgPresent() { return File.Exists(AwgExe); }
|
|
|
|
public static void EnsureAwg()
|
|
{
|
|
if (AwgPresent()) return;
|
|
Directory.CreateDirectory(BinDir);
|
|
var asm = Assembly.GetExecutingAssembly();
|
|
string res = asm.GetManifestResourceNames().FirstOrDefault(n => n.EndsWith("awg.exe", StringComparison.OrdinalIgnoreCase));
|
|
if (res == null) throw new Exception("Встроенный wireproxy-awg не найден.");
|
|
using (var rs = asm.GetManifestResourceStream(res))
|
|
using (var fs = File.Create(AwgExe))
|
|
rs.CopyTo(fs);
|
|
if (!AwgPresent()) throw new Exception("Не удалось распаковать wireproxy-awg.");
|
|
}
|
|
|
|
public static void StopAwg()
|
|
{
|
|
foreach (var p in Process.GetProcessesByName("wireproxy-awg"))
|
|
try { if (p.MainModule.FileName.StartsWith(BinDir, StringComparison.OrdinalIgnoreCase)) p.Kill(); } catch { }
|
|
Thread.Sleep(300);
|
|
}
|
|
public static bool AwgRunning() { return Process.GetProcessesByName("wireproxy-awg").Any(p =>
|
|
{ try { return p.MainModule.FileName.StartsWith(BinDir, StringComparison.OrdinalIgnoreCase); } catch { return false; } }); }
|
|
|
|
// Take the user's AmneziaWG .conf, append a [Socks5] section, run wireproxy-awg on it.
|
|
public static void StartAwg(string confPath, int localPort)
|
|
{
|
|
EnsureAwg();
|
|
if (string.IsNullOrWhiteSpace(confPath) || !File.Exists(confPath))
|
|
throw new Exception("Файл AmneziaWG-конфига не найден: " + confPath);
|
|
string cfg = File.ReadAllText(confPath);
|
|
if (cfg.IndexOf("[Socks5]", StringComparison.OrdinalIgnoreCase) < 0)
|
|
cfg = cfg.TrimEnd() + "\r\n\r\n[Socks5]\r\nBindAddress = 127.0.0.1:" + localPort + "\r\n";
|
|
File.WriteAllText(AwgRuntimeCfg, cfg);
|
|
StopAwg();
|
|
var psi = new ProcessStartInfo
|
|
{
|
|
FileName = AwgExe,
|
|
Arguments = "-s -c \"" + AwgRuntimeCfg + "\"",
|
|
UseShellExecute = false, CreateNoWindow = true, WindowStyle = ProcessWindowStyle.Hidden
|
|
};
|
|
Process.Start(psi);
|
|
for (int i = 0; i < 40; i++) { if (LocalSocksUp(localPort)) return; Thread.Sleep(200); }
|
|
StopAwg();
|
|
throw new Exception("wireproxy-awg не поднял SOCKS на 127.0.0.1:" + localPort + ". Проверь конфиг AmneziaWG.");
|
|
}
|
|
|
|
// Info line for the AmneziaWG config (its Peer Endpoint). WG is UDP, so we don't TCP-ping it.
|
|
public static bool AwgConfigInfo(string confPath, out string info)
|
|
{
|
|
info = "не выбран";
|
|
if (string.IsNullOrWhiteSpace(confPath) || !File.Exists(confPath)) return false;
|
|
try
|
|
{
|
|
foreach (var line in File.ReadAllLines(confPath))
|
|
{
|
|
var l = line.Trim();
|
|
if (l.StartsWith("Endpoint", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
int eq = l.IndexOf('=');
|
|
if (eq >= 0) { info = l.Substring(eq + 1).Trim(); return true; }
|
|
}
|
|
}
|
|
info = "выбран"; return true;
|
|
}
|
|
catch { info = "ошибка чтения"; return false; }
|
|
}
|
|
}
|
|
}
|