mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(proxy): global outbound HTTP/SOCKS proxy with settings page (#109)
Add a single global-proxy switch that routes the backend's outbound traffic through a configured HTTP/HTTPS/SOCKS proxy, for deployments that cannot reach overseas APIs directly or must use a unified egress. - ProxyManager installs the proxy via a default ProxySelector (honored by java.net.http and HttpURLConnection), the proxy system properties, and a --proxy-server arg for the browser tool; restores direct egress when disabled. One switch covers LLM, web search, media generation, channels, MCP and the browser. - SOCKS applies to the HttpURLConnection-based egress only; the java.net.http LLM/streaming path uses an HTTP proxy, and the UI states this. - New Settings -> Network Proxy page: enable toggle, address, bypass list, test-connection, and a coverage summary. Config stored as key/value in mate_system_setting (no migration). refs #109
This commit is contained in:
parent
7894a50067
commit
808047d723
@ -0,0 +1,84 @@
|
||||
package vip.mate.system.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import vip.mate.common.result.R;
|
||||
import vip.mate.system.proxy.ProxyManager;
|
||||
import vip.mate.system.proxy.ProxySettings;
|
||||
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
|
||||
|
||||
/**
|
||||
* Global outbound-proxy configuration. System-wide, so reads require an admin
|
||||
* and writes require the global admin.
|
||||
*/
|
||||
@Tag(name = "网络代理")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/settings/proxy")
|
||||
@RequiredArgsConstructor
|
||||
public class ProxyController {
|
||||
|
||||
private final ProxyManager proxyManager;
|
||||
|
||||
@Operation(summary = "获取全局代理配置")
|
||||
@GetMapping
|
||||
@RequireGlobalAdmin
|
||||
public R<ProxyConfigResponse> get() {
|
||||
return R.ok(toResponse(proxyManager.currentSettings()));
|
||||
}
|
||||
|
||||
@Operation(summary = "保存全局代理配置")
|
||||
@PutMapping
|
||||
@RequireGlobalAdmin
|
||||
public R<ProxyConfigResponse> save(@RequestBody ProxyConfigRequest req) {
|
||||
ProxySettings saved = proxyManager.save(
|
||||
Boolean.TRUE.equals(req.getEnabled()),
|
||||
req.getUrl(),
|
||||
req.getNonProxyHosts());
|
||||
if (saved.enabled() && !saved.valid()) {
|
||||
return R.fail(saved.error());
|
||||
}
|
||||
return R.ok(toResponse(saved));
|
||||
}
|
||||
|
||||
@Operation(summary = "测试代理连通性")
|
||||
@PostMapping("/test")
|
||||
@RequireGlobalAdmin
|
||||
public R<ProxyManager.ProbeResult> test(@RequestBody ProxyConfigRequest req) {
|
||||
ProxyManager.ProbeResult result = proxyManager.test(req.getUrl());
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
private ProxyConfigResponse toResponse(ProxySettings s) {
|
||||
ProxyConfigResponse resp = new ProxyConfigResponse();
|
||||
resp.setEnabled(s.enabled());
|
||||
resp.setUrl(s.url());
|
||||
resp.setNonProxyHosts(s.nonProxyHosts());
|
||||
resp.setValid(s.valid());
|
||||
resp.setError(s.error());
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ProxyConfigRequest {
|
||||
private Boolean enabled;
|
||||
private String url;
|
||||
private String nonProxyHosts;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class ProxyConfigResponse {
|
||||
private boolean enabled;
|
||||
private String url;
|
||||
private String nonProxyHosts;
|
||||
private boolean valid;
|
||||
private String error;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,291 @@
|
||||
package vip.mate.system.proxy;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
import vip.mate.system.service.SystemSettingService;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.Authenticator;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.PasswordAuthentication;
|
||||
import java.net.Proxy;
|
||||
import java.net.ProxySelector;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketAddress;
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/**
|
||||
* Installs and refreshes the process-wide outbound proxy from the global
|
||||
* {@code proxy.*} system settings.
|
||||
*
|
||||
* <p>When enabled, the configured proxy is applied through three mechanisms so
|
||||
* it reaches every egress style in the backend with a single switch, instead of
|
||||
* patching dozens of scattered HTTP-client construction sites:
|
||||
* <ol>
|
||||
* <li>A default {@link ProxySelector} — honored by every
|
||||
* {@link java.net.http.HttpClient} built without an explicit proxy (LLM
|
||||
* calls, model probes, most channel adapters, MCP, STT/TTS) and by
|
||||
* {@code HttpURLConnection} (the Hutool-based search / media tools).</li>
|
||||
* <li>{@code http(s).proxyHost}/{@code socksProxyHost} system properties — for
|
||||
* libraries that read them directly.</li>
|
||||
* <li>A static accessor ({@link #chromeProxyServer()}) the browser launcher
|
||||
* reads to add {@code --proxy-server}, since Chromium does not honor the
|
||||
* JVM proxy.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>SOCKS proxies are honored by {@code HttpURLConnection} but silently ignored
|
||||
* by {@code java.net.http.HttpClient}; see {@link ProxySettings} for the
|
||||
* resulting coverage boundary.
|
||||
*
|
||||
* <p>Adapters that carry their own per-channel proxy (Telegram / Discord set an
|
||||
* explicit {@code .proxy()}) override the default selector, so this global proxy
|
||||
* never fights a more specific one.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ProxyManager {
|
||||
|
||||
static final String KEY_ENABLED = "proxy.enabled";
|
||||
static final String KEY_URL = "proxy.url";
|
||||
static final String KEY_NON_PROXY_HOSTS = "proxy.nonProxyHosts";
|
||||
|
||||
private static final String[] MANAGED_SYSTEM_PROPS = {
|
||||
"http.proxyHost", "http.proxyPort",
|
||||
"https.proxyHost", "https.proxyPort",
|
||||
"http.nonProxyHosts",
|
||||
"socksProxyHost", "socksProxyPort",
|
||||
};
|
||||
|
||||
/**
|
||||
* Current {@code scheme://host:port} for Chromium's {@code --proxy-server},
|
||||
* or {@code null} when no proxy is active. Static so the static browser
|
||||
* launch-arg builder can read it without a bean reference.
|
||||
*/
|
||||
private static volatile String chromeProxyServer;
|
||||
|
||||
private final SystemSettingService settings;
|
||||
|
||||
/** The default selector present before we ever overrode it, for restore. */
|
||||
private final ProxySelector originalDefaultSelector;
|
||||
private final Authenticator originalDefaultAuthenticator;
|
||||
private final AtomicReference<ProxySettings> current = new AtomicReference<>();
|
||||
private volatile boolean authenticatorInstalled;
|
||||
|
||||
public ProxyManager(SystemSettingService settings) {
|
||||
this.settings = settings;
|
||||
this.originalDefaultSelector = ProxySelector.getDefault();
|
||||
this.originalDefaultAuthenticator = Authenticator.getDefault();
|
||||
}
|
||||
|
||||
/** Chromium {@code --proxy-server} value, or {@code null} when inactive. */
|
||||
public static String chromeProxyServer() {
|
||||
return chromeProxyServer;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onReady() {
|
||||
try {
|
||||
apply(readSettings());
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to apply proxy settings at startup: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-read persisted settings and re-apply. Called after a config save. */
|
||||
public synchronized void refresh() {
|
||||
apply(readSettings());
|
||||
}
|
||||
|
||||
public ProxySettings currentSettings() {
|
||||
ProxySettings s = current.get();
|
||||
return s != null ? s : readSettings();
|
||||
}
|
||||
|
||||
public ProxySettings readSettings() {
|
||||
boolean enabled = settings.getBool(KEY_ENABLED, false);
|
||||
String url = settings.getString(KEY_URL, "");
|
||||
String nph = settings.getString(KEY_NON_PROXY_HOSTS, "");
|
||||
return ProxySettings.parse(enabled, url, nph);
|
||||
}
|
||||
|
||||
/** Persist new config and apply it immediately. Returns the parsed result. */
|
||||
public synchronized ProxySettings save(boolean enabled, String url, String nonProxyHosts) {
|
||||
settings.saveBool(KEY_ENABLED, enabled, "Global outbound proxy enabled");
|
||||
settings.saveString(KEY_URL, url == null ? "" : url.trim(), "Global outbound proxy url");
|
||||
settings.saveString(KEY_NON_PROXY_HOSTS,
|
||||
nonProxyHosts == null ? "" : nonProxyHosts.trim(),
|
||||
"Global proxy bypass list (| separated)");
|
||||
ProxySettings parsed = readSettings();
|
||||
apply(parsed);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private synchronized void apply(ProxySettings s) {
|
||||
current.set(s);
|
||||
clearManagedSystemProps();
|
||||
if (!s.active()) {
|
||||
ProxySelector.setDefault(originalDefaultSelector);
|
||||
uninstallAuthenticator();
|
||||
chromeProxyServer = null;
|
||||
if (s.enabled() && !s.valid()) {
|
||||
log.warn("Global proxy is enabled but the url is invalid ({}); running without a proxy",
|
||||
s.error());
|
||||
} else {
|
||||
log.info("Global proxy disabled; outbound traffic goes direct");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
ProxySelector.setDefault(new GlobalProxySelector(s, originalDefaultSelector));
|
||||
setSystemProps(s);
|
||||
installAuthenticatorIfNeeded(s);
|
||||
chromeProxyServer = s.chromeProxyServer();
|
||||
log.info("Global proxy active: {} {}:{}{} (bypass: {})",
|
||||
s.isSocks() ? "SOCKS" : "HTTP", s.host(), s.port(),
|
||||
s.hasCredentials() ? " (auth)" : "", s.nonProxyHosts());
|
||||
if (s.isSocks()) {
|
||||
log.warn("SOCKS proxy applies to HttpURLConnection-based egress (search/media) only; "
|
||||
+ "the java.net.http LLM/streaming path does not support SOCKS and will go direct");
|
||||
}
|
||||
}
|
||||
|
||||
private void setSystemProps(ProxySettings s) {
|
||||
if (s.isSocks()) {
|
||||
System.setProperty("socksProxyHost", s.host());
|
||||
System.setProperty("socksProxyPort", String.valueOf(s.port()));
|
||||
} else {
|
||||
System.setProperty("http.proxyHost", s.host());
|
||||
System.setProperty("http.proxyPort", String.valueOf(s.port()));
|
||||
System.setProperty("https.proxyHost", s.host());
|
||||
System.setProperty("https.proxyPort", String.valueOf(s.port()));
|
||||
// JVM uses '|'-separated patterns here — same format we persist.
|
||||
if (StringUtils.hasText(s.nonProxyHosts())) {
|
||||
System.setProperty("http.nonProxyHosts", s.nonProxyHosts());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void clearManagedSystemProps() {
|
||||
for (String prop : MANAGED_SYSTEM_PROPS) {
|
||||
System.clearProperty(prop);
|
||||
}
|
||||
}
|
||||
|
||||
private void installAuthenticatorIfNeeded(ProxySettings s) {
|
||||
if (!s.hasCredentials()) {
|
||||
uninstallAuthenticator();
|
||||
return;
|
||||
}
|
||||
final String user = s.username();
|
||||
final char[] pass = s.password() == null ? new char[0] : s.password().toCharArray();
|
||||
// Allow Basic proxy auth over an HTTPS CONNECT tunnel (disabled by default since JDK 8u111).
|
||||
System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "");
|
||||
System.setProperty("jdk.http.auth.proxying.disabledSchemes", "");
|
||||
Authenticator.setDefault(new Authenticator() {
|
||||
@Override
|
||||
protected PasswordAuthentication getPasswordAuthentication() {
|
||||
if (getRequestorType() == RequestorType.PROXY) {
|
||||
return new PasswordAuthentication(user, pass);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
authenticatorInstalled = true;
|
||||
}
|
||||
|
||||
private void uninstallAuthenticator() {
|
||||
if (authenticatorInstalled) {
|
||||
Authenticator.setDefault(originalDefaultAuthenticator);
|
||||
authenticatorInstalled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe reachability of the configured proxy by opening a TCP connection to
|
||||
* its host:port. Confirms the proxy endpoint is listening (the common
|
||||
* failure mode — proxy app not running / wrong port) without depending on
|
||||
* upstream internet connectivity. Returns latency in milliseconds.
|
||||
*/
|
||||
public ProbeResult test(String url) {
|
||||
ProxySettings s = ProxySettings.parse(true, url, null);
|
||||
if (!s.valid()) {
|
||||
return new ProbeResult(false, 0, s.error());
|
||||
}
|
||||
long start = System.nanoTime();
|
||||
try (Socket socket = new Socket()) {
|
||||
socket.connect(new InetSocketAddress(s.host(), s.port()), 4000);
|
||||
long ms = (System.nanoTime() - start) / 1_000_000;
|
||||
return new ProbeResult(true, ms, null);
|
||||
} catch (IOException e) {
|
||||
return new ProbeResult(false, 0, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Result of {@link #test(String)}. */
|
||||
public record ProbeResult(boolean ok, long latencyMs, String error) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes through the configured proxy unless the target host matches the
|
||||
* bypass list, in which case it defers to the selector that was the default
|
||||
* before we took over (preserving any pre-existing direct/proxy behavior).
|
||||
*/
|
||||
private static final class GlobalProxySelector extends ProxySelector {
|
||||
private final List<Proxy> proxyList;
|
||||
private final List<String> bypass;
|
||||
private final ProxySelector fallback;
|
||||
|
||||
GlobalProxySelector(ProxySettings s, ProxySelector fallback) {
|
||||
this.proxyList = List.of(s.toProxy());
|
||||
this.bypass = s.bypassPatterns();
|
||||
this.fallback = fallback;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Proxy> select(URI uri) {
|
||||
String host = uri == null ? null : uri.getHost();
|
||||
if (host == null || matchesBypass(host)) {
|
||||
return fallback != null ? fallback.select(uri) : List.of(Proxy.NO_PROXY);
|
||||
}
|
||||
return proxyList;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
|
||||
if (fallback != null) {
|
||||
fallback.connectFailed(uri, sa, ioe);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean matchesBypass(String host) {
|
||||
for (String pattern : bypass) {
|
||||
if (matches(pattern, host)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Glob match with leading/trailing {@code *}, matching JVM nonProxyHosts semantics. */
|
||||
private static boolean matches(String pattern, String host) {
|
||||
if (pattern.equalsIgnoreCase(host)) {
|
||||
return true;
|
||||
}
|
||||
if (pattern.startsWith("*") && pattern.endsWith("*") && pattern.length() > 2) {
|
||||
return host.toLowerCase().contains(pattern.substring(1, pattern.length() - 1).toLowerCase());
|
||||
}
|
||||
if (pattern.startsWith("*")) {
|
||||
return host.toLowerCase().endsWith(pattern.substring(1).toLowerCase());
|
||||
}
|
||||
if (pattern.endsWith("*")) {
|
||||
return host.toLowerCase().startsWith(pattern.substring(0, pattern.length() - 1).toLowerCase());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,194 @@
|
||||
package vip.mate.system.proxy;
|
||||
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Proxy;
|
||||
import java.net.URI;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Parsed, validated form of the global outbound-proxy configuration.
|
||||
*
|
||||
* <p>The raw config is a single proxy URL — {@code http://127.0.0.1:7890},
|
||||
* {@code https://host:443}, or {@code socks5://127.0.0.1:1080} — with optional
|
||||
* {@code user:pass@} credentials. The scheme selects the proxy type:
|
||||
* {@code socks}/{@code socks5}/{@code socks4} map to a SOCKS proxy, everything
|
||||
* else to an HTTP proxy.
|
||||
*
|
||||
* <p>HTTP/HTTPS proxies are honored across the whole backend (the JDK
|
||||
* {@link java.net.http.HttpClient} and {@code HttpURLConnection} both respect a
|
||||
* default {@link java.net.ProxySelector}). SOCKS is only honored by the
|
||||
* {@code HttpURLConnection}-based egress (search / media tools); the
|
||||
* {@code java.net.http} client silently ignores a SOCKS proxy, so SOCKS does not
|
||||
* cover the LLM / streaming path — callers must use an HTTP proxy for that.
|
||||
*/
|
||||
public final class ProxySettings {
|
||||
|
||||
/** Default bypass list applied when the user leaves the field blank. */
|
||||
public static final String DEFAULT_NON_PROXY_HOSTS =
|
||||
"localhost|127.*|[::1]|10.*|172.16.*|172.17.*|172.18.*|172.19.*|"
|
||||
+ "172.2*|172.30.*|172.31.*|192.168.*|*.local";
|
||||
|
||||
private final boolean enabled;
|
||||
private final String url;
|
||||
private final String nonProxyHosts;
|
||||
|
||||
// Derived (only meaningful when valid()).
|
||||
private final Proxy.Type type;
|
||||
private final String host;
|
||||
private final int port;
|
||||
private final String username;
|
||||
private final String password;
|
||||
private final boolean valid;
|
||||
private final String error;
|
||||
|
||||
private ProxySettings(boolean enabled, String url, String nonProxyHosts,
|
||||
Proxy.Type type, String host, int port,
|
||||
String username, String password, boolean valid, String error) {
|
||||
this.enabled = enabled;
|
||||
this.url = url;
|
||||
this.nonProxyHosts = nonProxyHosts;
|
||||
this.type = type;
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
this.valid = valid;
|
||||
this.error = error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse persisted values into a validated settings object. Never throws —
|
||||
* an unparseable URL yields {@code valid() == false} with {@link #error()}
|
||||
* populated, so a bad row can't crash startup.
|
||||
*/
|
||||
public static ProxySettings parse(boolean enabled, String url, String nonProxyHosts) {
|
||||
String nph = StringUtils.hasText(nonProxyHosts) ? nonProxyHosts.trim() : DEFAULT_NON_PROXY_HOSTS;
|
||||
if (!StringUtils.hasText(url)) {
|
||||
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||
"proxy url is empty");
|
||||
}
|
||||
String trimmed = url.trim();
|
||||
URI uri;
|
||||
try {
|
||||
uri = new URI(trimmed);
|
||||
} catch (Exception e) {
|
||||
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||
"malformed proxy url: " + e.getMessage());
|
||||
}
|
||||
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase();
|
||||
Proxy.Type proxyType = switch (scheme) {
|
||||
case "socks", "socks5", "socks4" -> Proxy.Type.SOCKS;
|
||||
case "http", "https" -> Proxy.Type.HTTP;
|
||||
default -> null;
|
||||
};
|
||||
if (proxyType == null) {
|
||||
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||
"unsupported proxy scheme: " + scheme + " (use http/https/socks5)");
|
||||
}
|
||||
String h = uri.getHost();
|
||||
int p = uri.getPort();
|
||||
if (!StringUtils.hasText(h) || p <= 0) {
|
||||
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
|
||||
"proxy url must include host and port, e.g. http://127.0.0.1:7890");
|
||||
}
|
||||
String user = null;
|
||||
String pass = null;
|
||||
String userInfo = uri.getUserInfo();
|
||||
if (StringUtils.hasText(userInfo)) {
|
||||
int idx = userInfo.indexOf(':');
|
||||
if (idx >= 0) {
|
||||
user = userInfo.substring(0, idx);
|
||||
pass = userInfo.substring(idx + 1);
|
||||
} else {
|
||||
user = userInfo;
|
||||
}
|
||||
}
|
||||
return new ProxySettings(enabled, trimmed, nph, proxyType, h, p, user, pass, true, null);
|
||||
}
|
||||
|
||||
public boolean enabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** True when the proxy should actually be installed: enabled AND parseable. */
|
||||
public boolean active() {
|
||||
return enabled && valid;
|
||||
}
|
||||
|
||||
public boolean valid() {
|
||||
return valid;
|
||||
}
|
||||
|
||||
public String error() {
|
||||
return error;
|
||||
}
|
||||
|
||||
public String url() {
|
||||
return url;
|
||||
}
|
||||
|
||||
public String nonProxyHosts() {
|
||||
return nonProxyHosts;
|
||||
}
|
||||
|
||||
public Proxy.Type type() {
|
||||
return type;
|
||||
}
|
||||
|
||||
public boolean isSocks() {
|
||||
return type == Proxy.Type.SOCKS;
|
||||
}
|
||||
|
||||
public String host() {
|
||||
return host;
|
||||
}
|
||||
|
||||
public int port() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public String username() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public String password() {
|
||||
return password;
|
||||
}
|
||||
|
||||
public boolean hasCredentials() {
|
||||
return StringUtils.hasText(username);
|
||||
}
|
||||
|
||||
/** A {@link Proxy} instance for explicit injection where needed. */
|
||||
public Proxy toProxy() {
|
||||
return new Proxy(type, new InetSocketAddress(host, port));
|
||||
}
|
||||
|
||||
/**
|
||||
* The {@code --proxy-server} value for Chromium. Chromium accepts
|
||||
* {@code scheme://host:port} but not embedded credentials, so userinfo is
|
||||
* dropped here.
|
||||
*/
|
||||
public String chromeProxyServer() {
|
||||
String scheme = isSocks() ? "socks5" : "http";
|
||||
return scheme + "://" + host + ":" + port;
|
||||
}
|
||||
|
||||
/** Split the {@code |}-separated bypass list into individual patterns. */
|
||||
public List<String> bypassPatterns() {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (!StringUtils.hasText(nonProxyHosts)) {
|
||||
return out;
|
||||
}
|
||||
for (String part : nonProxyHosts.split("\\|")) {
|
||||
String t = part.trim();
|
||||
if (!t.isEmpty()) {
|
||||
out.add(t);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -365,6 +365,14 @@ public class BrowserLauncher {
|
||||
if (IS_WINDOWS) {
|
||||
args.add("--disable-gpu");
|
||||
}
|
||||
// Chromium does not honor the JVM proxy selector / system properties, so
|
||||
// route it explicitly when a global proxy is active. Bypass loopback so
|
||||
// the local CDP endpoint and local services stay reachable.
|
||||
String proxyServer = vip.mate.system.proxy.ProxyManager.chromeProxyServer();
|
||||
if (proxyServer != null && !proxyServer.isBlank()) {
|
||||
args.add("--proxy-server=" + proxyServer);
|
||||
args.add("--proxy-bypass-list=<-loopback>");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
|
||||
@ -644,6 +644,14 @@ export const settingsApi = {
|
||||
http.put('/settings/sidecar', data),
|
||||
}
|
||||
|
||||
// ==================== Global outbound proxy ====================
|
||||
export const proxyApi = {
|
||||
get: () => http.get('/settings/proxy'),
|
||||
update: (data: { enabled: boolean; url: string; nonProxyHosts?: string }) =>
|
||||
http.put('/settings/proxy', data),
|
||||
test: (url: string) => http.post('/settings/proxy/test', { url }),
|
||||
}
|
||||
|
||||
// ==================== Workspace ====================
|
||||
const encodeFilePath = (filename: string) =>
|
||||
filename.split('/').map(encodeURIComponent).join('/')
|
||||
|
||||
@ -613,6 +613,7 @@ export default {
|
||||
about: 'About',
|
||||
advanced: 'Advanced',
|
||||
skillCurator: 'Skill Curator',
|
||||
proxy: 'Network Proxy',
|
||||
},
|
||||
models: {
|
||||
sidecar: {
|
||||
@ -1012,6 +1013,31 @@ export default {
|
||||
},
|
||||
searchTitle: 'Search Service',
|
||||
searchDesc: 'Configure the built-in search tool provider and API credentials',
|
||||
proxyTitle: 'Network Proxy',
|
||||
proxyDesc: 'Configure a global HTTP / SOCKS proxy for all outbound requests (LLM APIs, web search, channel bridging, etc.). For environments that cannot reach overseas APIs directly or require a unified egress.',
|
||||
proxy: {
|
||||
enableLabel: 'Enable proxy',
|
||||
enableHint: 'Global switch. When on, the outbound connections below all route through the proxy.',
|
||||
urlLabel: 'Proxy address',
|
||||
urlHint: 'Supports HTTP, HTTPS and SOCKS5 (e.g. <code>http://127.0.0.1:7890</code>, <code>socks5://127.0.0.1:1080</code>). Optional credentials: <code>http://user:pass@host:port</code>.',
|
||||
bypassLabel: 'Bypass list',
|
||||
bypassHint: 'Hosts that skip the proxy, separated by |, with * wildcards (e.g. localhost|127.*|*.local). Leave blank to use the default, which bypasses loopback and private ranges.',
|
||||
socksWarn: 'Note: a SOCKS proxy only applies to HttpURLConnection-based egress (search / media). LLM and streaming chat use java.net.http, which does not support SOCKS and will connect directly. Use your proxy tool\'s HTTP / mixed port for LLM.',
|
||||
coverageTitle: 'Outbound routed through the proxy once enabled:',
|
||||
coverage: {
|
||||
llm: 'LLM APIs',
|
||||
search: 'Web search',
|
||||
media: 'Image / Video / Music',
|
||||
channels: 'IM channel bridging',
|
||||
mcp: 'Remote MCP',
|
||||
browser: 'Browser tool',
|
||||
},
|
||||
test: 'Test connection',
|
||||
testing: 'Testing…',
|
||||
testOk: 'Proxy port reachable · {ms} ms',
|
||||
testFail: 'Connection failed',
|
||||
saveFail: 'Save failed',
|
||||
},
|
||||
sttTitle: 'Speech Recognition',
|
||||
sttDesc: 'Configure STT speech-to-text with OpenAI Whisper and DashScope Paraformer',
|
||||
sttProviderOptions: { auto: 'Auto Select' },
|
||||
|
||||
@ -493,6 +493,7 @@ export default {
|
||||
about: '关于',
|
||||
advanced: '高级',
|
||||
skillCurator: '技能管家',
|
||||
proxy: '网络代理',
|
||||
},
|
||||
models: {
|
||||
sidecar: {
|
||||
@ -904,6 +905,31 @@ export default {
|
||||
},
|
||||
searchTitle: '搜索服务',
|
||||
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
|
||||
proxyTitle: '网络代理',
|
||||
proxyDesc: '为所有出站请求(LLM API、网页搜索、频道桥接等)配置全局 HTTP / SOCKS 代理。适用于无法直连海外 API、或要求统一出口的网络环境。',
|
||||
proxy: {
|
||||
enableLabel: '启用代理',
|
||||
enableHint: '全局开关。开启后,下方各类出站连接统一走代理。',
|
||||
urlLabel: '代理地址',
|
||||
urlHint: '支持 HTTP、HTTPS 和 SOCKS5 协议(如 <code>http://127.0.0.1:7890</code>、<code>socks5://127.0.0.1:1080</code>)。可选用户名 / 密码:<code>http://user:pass@host:port</code>。',
|
||||
bypassLabel: '绕过列表',
|
||||
bypassHint: '这些主机不走代理,用 | 分隔,支持 * 通配(如 localhost|127.*|*.local)。留空使用默认值,自动放行本机与内网地址。',
|
||||
socksWarn: '注意:SOCKS 代理仅对搜索 / 媒体等基于 HttpURLConnection 的出站生效;LLM 与流式聊天走 java.net.http,不支持 SOCKS,会直连。请为 LLM 使用代理工具的 HTTP / 混合端口。',
|
||||
coverageTitle: '启用后将统一纳入代理的出站:',
|
||||
coverage: {
|
||||
llm: 'LLM API',
|
||||
search: '网页搜索',
|
||||
media: '图片 / 视频 / 音乐',
|
||||
channels: 'IM 频道桥接',
|
||||
mcp: 'MCP 远程连接',
|
||||
browser: '浏览器工具',
|
||||
},
|
||||
test: '测试连接',
|
||||
testing: '测试中…',
|
||||
testOk: '代理端口可达 · {ms} ms',
|
||||
testFail: '连接失败',
|
||||
saveFail: '保存失败',
|
||||
},
|
||||
sttTitle: '语音识别',
|
||||
sttDesc: '配置 STT 语音转文字,支持 OpenAI Whisper 和 DashScope Paraformer',
|
||||
sttProviderOptions: { auto: '自动选择' },
|
||||
|
||||
@ -214,6 +214,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Tools.vue'),
|
||||
meta: { title: 'Settings - Tools Catalog', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
{
|
||||
path: 'proxy',
|
||||
name: 'SettingsProxy',
|
||||
component: () => import('@/views/Settings/Proxy/index.vue'),
|
||||
meta: { title: 'Settings - Proxy', requiredCapability: 'manage:settings' },
|
||||
},
|
||||
// RFC-090 Phase 7: ACP endpoints (External coding agents)
|
||||
{
|
||||
path: 'acp',
|
||||
|
||||
@ -189,6 +189,12 @@ const sections = computed(() => [
|
||||
label: t('nav.toolsCatalog'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'proxy',
|
||||
path: '/settings/proxy',
|
||||
label: t('settings.sections.proxy', '网络代理'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
|
||||
},
|
||||
// RFC-090 Phase 7: ACP endpoints
|
||||
{
|
||||
id: 'acp',
|
||||
|
||||
204
mateclaw-ui/src/views/Settings/Proxy/index.vue
Normal file
204
mateclaw-ui/src/views/Settings/Proxy/index.vue
Normal file
@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="settings-section">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">{{ t('settings.proxyTitle') }}</h2>
|
||||
<p class="section-desc">{{ t('settings.proxyDesc') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="setting-item">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.proxy.enableLabel') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.proxy.enableHint') }}</div>
|
||||
</div>
|
||||
<div class="setting-control">
|
||||
<label class="toggle-switch">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item column">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.proxy.urlLabel') }}</div>
|
||||
<div class="setting-hint" v-html="t('settings.proxy.urlHint')"></div>
|
||||
</div>
|
||||
<input
|
||||
v-model.trim="form.url"
|
||||
class="form-input mono full"
|
||||
:disabled="!form.enabled"
|
||||
placeholder="http://127.0.0.1:7890"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="setting-item column">
|
||||
<div class="setting-info">
|
||||
<div class="setting-label">{{ t('settings.proxy.bypassLabel') }}</div>
|
||||
<div class="setting-hint">{{ t('settings.proxy.bypassHint') }}</div>
|
||||
</div>
|
||||
<input
|
||||
v-model.trim="form.nonProxyHosts"
|
||||
class="form-input mono full"
|
||||
:disabled="!form.enabled"
|
||||
:placeholder="defaultBypass"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- SOCKS caveat: java.net.http (LLM/streaming) ignores SOCKS proxies. -->
|
||||
<div v-if="isSocks" class="proxy-warn">{{ t('settings.proxy.socksWarn') }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Coverage transparency, replaces fake per-tool toggles: the single
|
||||
switch routes every JVM-level egress through the proxy. -->
|
||||
<div class="coverage-card">
|
||||
<div class="coverage-title">{{ t('settings.proxy.coverageTitle') }}</div>
|
||||
<div class="coverage-list">
|
||||
<span v-for="item in coverage" :key="item" class="coverage-chip">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar">
|
||||
<button class="btn-secondary" :disabled="testing || !form.url" @click="onTest">
|
||||
{{ testing ? t('settings.proxy.testing') : t('settings.proxy.test') }}
|
||||
</button>
|
||||
<button class="btn-secondary" @click="load">{{ t('common.reset') }}</button>
|
||||
<button class="btn-primary" :disabled="saving" @click="onSave">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="test-result" :class="{ ok: testResult.ok }">
|
||||
<span class="dot"></span>
|
||||
<template v-if="testResult.ok">{{ t('settings.proxy.testOk', { ms: testResult.latencyMs }) }}</template>
|
||||
<template v-else>{{ t('settings.proxy.testFail') }}: {{ testResult.error }}</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { proxyApi } from '@/api'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const defaultBypass = 'localhost|127.*|10.*|192.168.*|*.local'
|
||||
|
||||
const form = reactive({
|
||||
enabled: false,
|
||||
url: '',
|
||||
nonProxyHosts: '',
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref<{ ok: boolean; latencyMs: number; error?: string } | null>(null)
|
||||
|
||||
const isSocks = computed(() => /^socks/i.test(form.url.trim()))
|
||||
|
||||
const coverage = computed(() => [
|
||||
t('settings.proxy.coverage.llm'),
|
||||
t('settings.proxy.coverage.search'),
|
||||
t('settings.proxy.coverage.media'),
|
||||
t('settings.proxy.coverage.channels'),
|
||||
t('settings.proxy.coverage.mcp'),
|
||||
t('settings.proxy.coverage.browser'),
|
||||
])
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function load() {
|
||||
testResult.value = null
|
||||
try {
|
||||
const res: any = await proxyApi.get()
|
||||
const d = res?.data || {}
|
||||
form.enabled = d.enabled ?? false
|
||||
form.url = d.url ?? ''
|
||||
form.nonProxyHosts = d.nonProxyHosts ?? ''
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.response?.data?.msg || t('settings.proxy.saveFail'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onSave() {
|
||||
saving.value = true
|
||||
try {
|
||||
await proxyApi.update({
|
||||
enabled: form.enabled,
|
||||
url: form.url,
|
||||
nonProxyHosts: form.nonProxyHosts,
|
||||
})
|
||||
await load()
|
||||
mcToast.success(t('settings.messages.saveSuccess'))
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.response?.data?.msg || t('settings.proxy.saveFail'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onTest() {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
try {
|
||||
const res: any = await proxyApi.test(form.url)
|
||||
testResult.value = res?.data || { ok: false, latencyMs: 0, error: 'no response' }
|
||||
} catch (e: any) {
|
||||
testResult.value = { ok: false, latencyMs: 0, error: e?.message || 'request failed' }
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-section { width: 100%; }
|
||||
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
|
||||
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
|
||||
|
||||
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124,63,30,0.04); width: 100%; }
|
||||
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.setting-item:last-child { border-bottom: none; }
|
||||
.setting-item.column { flex-direction: column; gap: 12px; }
|
||||
.setting-info { flex: 1; }
|
||||
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
|
||||
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; }
|
||||
.setting-hint :deep(code) { font-family: var(--mc-font-mono); background: var(--mc-inline-code-bg); color: var(--mc-inline-code-color); padding: 1px 6px; border-radius: 5px; font-size: 0.92em; }
|
||||
.setting-control { width: 220px; display: flex; align-items: center; justify-content: flex-end; }
|
||||
|
||||
.form-input { border: 1px solid var(--mc-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; transition: border-color .15s, box-shadow .15s; }
|
||||
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 3px var(--mc-primary-bg); }
|
||||
.form-input:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.form-input.full { width: 100%; }
|
||||
.form-input.mono { font-family: var(--mc-font-mono); }
|
||||
|
||||
.proxy-warn {
|
||||
margin-top: 4px; padding: 10px 14px; border-radius: 10px;
|
||||
background: var(--mc-tool-call-bg); border: 1px solid var(--mc-tool-call-border);
|
||||
color: var(--mc-tool-call-color); font-size: 13px; line-height: 1.6;
|
||||
}
|
||||
|
||||
.coverage-card { margin-top: 16px; background: var(--mc-bg-muted); border: 1px solid var(--mc-border-light); border-radius: 14px; padding: 16px 18px; }
|
||||
.coverage-title { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); margin-bottom: 10px; }
|
||||
.coverage-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.coverage-chip { font-size: 12px; padding: 4px 10px; border-radius: 999px; background: var(--mc-accent-soft); color: var(--mc-accent); font-weight: 500; }
|
||||
|
||||
.toggle-switch { position: relative; display: inline-flex; width: 44px; height: 24px; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; inset: 0; cursor: pointer; background: var(--mc-border); border-radius: 999px; transition: 0.2s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
|
||||
|
||||
.save-bar { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
|
||||
.btn-primary, .btn-secondary { border: none; border-radius: 10px; padding: 9px 16px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
|
||||
.btn-primary { background: var(--mc-primary); color: white; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled, .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); }
|
||||
|
||||
.test-result { display: flex; align-items: center; gap: 8px; justify-content: flex-end; margin-top: 12px; font-size: 13px; color: var(--mc-danger); }
|
||||
.test-result.ok { color: var(--mc-success); }
|
||||
.test-result .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user