This is an automated email from the ASF dual-hosted git repository.
hsluoyz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/casbin-gateway.git
The following commit(s) were added to refs/heads/master by this push:
new 52a85d2 feat(proxy): test the outbound proxy from Settings and hand
it to installs
52a85d2 is described below
commit 52a85d2f971dfbf6dbc05f2c21257787763427aa
Author: Yang Luo <[email protected]>
AuthorDate: Sat Sep 5 21:17:10 2026 +0800
feat(proxy): test the outbound proxy from Settings and hand it to installs
---
agentinstall/host.go | 8 +-
agentinstall/job.go | 4 +
controllers/setting.go | 28 ++++
proxy/check.go | 204 ++++++++++++++++++++++++++++
proxy/proxy.go | 61 +++++++--
routers/router.go | 1 +
web/src/backend/SettingBackend.ts | 7 +-
web/src/components/settings/proxy-field.tsx | 125 +++++++++++++++++
web/src/locales/en/data.json | 8 +-
web/src/locales/zh/data.json | 8 +-
web/src/pages/SettingPage.tsx | 3 +-
web/src/types.ts | 24 ++++
12 files changed, 466 insertions(+), 15 deletions(-)
diff --git a/agentinstall/host.go b/agentinstall/host.go
index 7a8806c..f11aec6 100644
--- a/agentinstall/host.go
+++ b/agentinstall/host.go
@@ -43,7 +43,13 @@ func launcherOf(installation agent.Installation) string {
// it, so it needs a shell rather than an argument list.
func shellCommand(script string) (string, []string) {
if runtime.GOOS == "windows" {
- program := lookup("powershell")
+ // PowerShell 7 first: Windows PowerShell reads no proxy from
its
+ // environment, so a vendor script run under it would download
direct
+ // however the Settings page is filled in.
+ program := lookup("pwsh")
+ if program == "" {
+ program = lookup("powershell")
+ }
if program == "" {
return "", nil
}
diff --git a/agentinstall/job.go b/agentinstall/job.go
index 6f84c73..7534596 100644
--- a/agentinstall/job.go
+++ b/agentinstall/job.go
@@ -26,6 +26,7 @@ import (
"time"
"github.com/apache/casbin-gateway/agent"
+ "github.com/apache/casbin-gateway/proxy"
)
const (
@@ -144,6 +145,9 @@ func run(started *job, plan Plan) {
// Nothing is attached to the input, so a manager that asks a question
would
// hang until the timeout. These say to answer none.
cmd.Env = append(os.Environ(), "CI=1", "NO_COLOR=1",
"npm_config_yes=true", "HOMEBREW_NO_AUTO_UPDATE=1")
+ // A manager downloads through its own network stack, not Gateway's, so
the
+ // configured proxy has to be handed to it.
+ cmd.Env = append(cmd.Env, proxy.Env()...)
// An uninstaller with no silent switch, and the consent prompt Windows
// raises for a machine-wide change, both need to be on screen: hiding
them
// would leave the job waiting on a dialog nobody can answer.
diff --git a/controllers/setting.go b/controllers/setting.go
index 0a66b1b..7d11574 100644
--- a/controllers/setting.go
+++ b/controllers/setting.go
@@ -90,6 +90,34 @@ func (c *ApiController) UpdateSetting() {
c.ResponseOk(setting)
}
+// TestOutboundProxy tries the outbound proxy and reports what answered through
+// it: whether its port is up, and whether the upstreams behind it are
reachable.
+// The address in the request is tried as typed, so the Settings page can test
a
+// value before saving it; an empty one tests the stored setting.
+func (c *ApiController) TestOutboundProxy() {
+ if c.RequireAdmin() {
+ return
+ }
+
+ var request struct {
+ Address string `json:"address"`
+ }
+ if len(c.Ctx.Input.RequestBody) > 0 {
+ if err := json.Unmarshal(c.Ctx.Input.RequestBody, &request);
err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+ }
+
+ result, err := proxy.Check(request.Address)
+ if err != nil {
+ c.ResponseError(err.Error())
+ return
+ }
+
+ c.ResponseOk(result)
+}
+
func validateSetting(setting *object.Setting) error {
switch setting.LlmRecordMode {
case conf.LlmRecordOff, conf.LlmRecordMetadata, conf.LlmRecordFull:
diff --git a/proxy/check.go b/proxy/check.go
new file mode 100644
index 0000000..fb99691
--- /dev/null
+++ b/proxy/check.go
@@ -0,0 +1,204 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package proxy
+
+import (
+ "errors"
+ "io"
+ "net"
+ "net/http"
+ "net/url"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/apache/casbin-gateway/conf"
+)
+
+const (
+ dialTimeout = 5 * time.Second
+ requestTimeout = 12 * time.Second
+)
+
+// CheckResult is what one test of the outbound proxy found.
+type CheckResult struct {
+ // Address is the proxy as it was parsed, with any password hidden.
+ Address string `json:"address"`
+ // Dialed reports that the proxy port itself accepted a connection,
which is
+ // the difference between a proxy that is down and one that cannot get
out.
+ Dialed bool `json:"dialed"`
+ DialMillis int64 `json:"dialMillis"`
+ DialError string `json:"dialError,omitempty"`
+ // ExitIp and ExitCountry are where the traffic left from, which is how
an
+ // admin tells a local relay from one abroad.
+ ExitIp string `json:"exitIp,omitempty"`
+ ExitCountry string `json:"exitCountry,omitempty"`
+ Targets []CheckTarget `json:"targets"`
+}
+
+// CheckTarget is one upstream the test fetched through the proxy.
+type CheckTarget struct {
+ Name string `json:"name"`
+ Url string `json:"url"`
+ // Restricted marks an upstream that a blocked network cannot reach on
its
+ // own, so reaching it is what says the proxy leaves that network.
+ Restricted bool `json:"restricted,omitempty"`
+ Ok bool `json:"ok"`
+ Status int `json:"status,omitempty"`
+ Millis int64 `json:"millis"`
+ Error string `json:"error,omitempty"`
+}
+
+// The upstreams worth knowing about: the one a blocked network cannot reach,
+// and the two the installs and the update check download from.
+var checkTargets = []CheckTarget{
+ {Name: "Google", Url: "https://www.google.com/generate_204",
Restricted: true},
+ {Name: "npm registry", Url: "https://registry.npmjs.org/-/ping"},
+ {Name: "GitHub API", Url: "https://api.github.com/"},
+}
+
+// Check tries the proxy address given, or the stored setting when it is empty,
+// and reports what answered. It stores nothing, so the Settings page can try a
+// value before saving it.
+func Check(address string) (*CheckResult, error) {
+ address = strings.TrimSpace(address)
+ if address == "" {
+ address =
strings.TrimSpace(conf.GetConfigStringUnquoted("httpProxy"))
+ }
+ if address == "" {
+ return nil, errors.New("no outbound proxy is configured")
+ }
+
+ parsedUrl, err := parseProxyAddress(address)
+ if err != nil {
+ return nil, err
+ }
+
+ result := &CheckResult{Address: parsedUrl.Redacted()}
+
+ started := time.Now()
+ connection, err := net.DialTimeout("tcp", dialAddress(parsedUrl),
dialTimeout)
+ result.DialMillis = time.Since(started).Milliseconds()
+ if err != nil {
+ result.DialError = err.Error()
+ return result, nil
+ }
+ result.Dialed = true
+ _ = connection.Close()
+
+ result.Targets = make([]CheckTarget, len(checkTargets))
+ client := checkClient(parsedUrl)
+
+ var group sync.WaitGroup
+ for i := range checkTargets {
+ group.Add(1)
+ go func(i int) {
+ defer group.Done()
+ result.Targets[i] = fetch(client, checkTargets[i])
+ }(i)
+ }
+ group.Add(1)
+ go func() {
+ defer group.Done()
+ result.ExitIp, result.ExitCountry = exitAddress(client)
+ }()
+ group.Wait()
+
+ return result, nil
+}
+
+// checkClient sends its requests through the proxy under test rather than
+// through the shared transport, which holds the stored proxy.
+func checkClient(parsedUrl *url.URL) *http.Client {
+ transport := http.DefaultTransport.(*http.Transport).Clone()
+ transport.Proxy = func(*http.Request) (*url.URL, error) { return
parsedUrl, nil }
+ return &http.Client{Timeout: requestTimeout, Transport:
WithUserAgent(transport)}
+}
+
+func fetch(client *http.Client, target CheckTarget) CheckTarget {
+ started := time.Now()
+ response, err := client.Get(target.Url)
+ target.Millis = time.Since(started).Milliseconds()
+ if err != nil {
+ target.Error = requestError(err)
+ return target
+ }
+ defer response.Body.Close()
+ _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4096))
+
+ target.Status = response.StatusCode
+ // A 4xx from the upstream still proves the request got there, which is
what
+ // is being tested; only a 5xx is worth reporting as a failure.
+ target.Ok = response.StatusCode < 500
+ return target
+}
+
+// exitAddress reads where the traffic leaves from. Cloudflare answers this on
+// every edge as a few "key=value" lines.
+func exitAddress(client *http.Client) (string, string) {
+ response, err := client.Get("https://www.cloudflare.com/cdn-cgi/trace")
+ if err != nil {
+ return "", ""
+ }
+ defer response.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(response.Body, 4096))
+ if err != nil {
+ return "", ""
+ }
+
+ var ip, country string
+ for _, line := range strings.Split(string(body), "\n") {
+ name, value, found := strings.Cut(strings.TrimSpace(line), "=")
+ if !found {
+ continue
+ }
+ switch name {
+ case "ip":
+ ip = value
+ case "loc":
+ country = value
+ }
+ }
+ return ip, country
+}
+
+// dialAddress is the proxy's host and port, filling in the port each scheme is
+// usually served on when the setting left it out.
+func dialAddress(parsedUrl *url.URL) string {
+ if parsedUrl.Port() != "" {
+ return parsedUrl.Host
+ }
+
+ port := "1080"
+ switch parsedUrl.Scheme {
+ case "http":
+ port = "80"
+ case "https":
+ port = "443"
+ }
+ return net.JoinHostPort(parsedUrl.Hostname(), port)
+}
+
+// requestError drops the URL the transport repeats back, which the page
already
+// shows beside the error.
+func requestError(err error) string {
+ message := err.Error()
+ var urlErr *url.Error
+ if errors.As(err, &urlErr) && urlErr.Err != nil {
+ message = urlErr.Err.Error()
+ }
+ return message
+}
diff --git a/proxy/proxy.go b/proxy/proxy.go
index ea165ce..e1ecfc4 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -113,21 +113,15 @@ func currentProxyUrl() *url.URL {
return proxyUrl
}
-// parseProxyUrl reads a proxy address. A bare "host:port" means SOCKS5, which
-// is what the setting has always been taken to mean; "socks5://",
"socks5h://",
-// "http://" and "https://" addresses are honoured as written, and may carry
-// credentials.
+// parseProxyUrl reads the httpProxy setting, reporting an address it cannot
use
+// rather than failing the requests that would have gone through it.
func parseProxyUrl(httpProxy string) *url.URL {
if httpProxy == "" {
return nil
}
- if !strings.Contains(httpProxy, "://") {
- httpProxy = "socks5://" + httpProxy
- }
-
- parsedUrl, err := url.Parse(httpProxy)
- if err != nil || parsedUrl.Host == "" {
+ parsedUrl, err := parseProxyAddress(httpProxy)
+ if err != nil {
fmt.Printf("httpProxy is not a valid proxy address, outbound
traffic is left unproxied: %s\n", httpProxy)
return nil
}
@@ -135,3 +129,50 @@ func parseProxyUrl(httpProxy string) *url.URL {
fmt.Printf("Proxy enabled for outbound traffic: %s\n",
parsedUrl.Redacted())
return parsedUrl
}
+
+// parseProxyAddress reads a proxy address. A bare "host:port" means SOCKS5,
+// which is what the setting has always been taken to mean; "socks5://",
+// "socks5h://", "http://" and "https://" addresses are honoured as written,
and
+// may carry credentials.
+func parseProxyAddress(address string) (*url.URL, error) {
+ if !strings.Contains(address, "://") {
+ address = "socks5://" + address
+ }
+
+ parsedUrl, err := url.Parse(address)
+ if err != nil {
+ return nil, fmt.Errorf("%s is not a valid proxy address: %w",
address, err)
+ }
+ if parsedUrl.Host == "" {
+ return nil, fmt.Errorf("%s is not a valid proxy address",
address)
+ }
+
+ switch parsedUrl.Scheme {
+ case "socks5", "socks5h", "http", "https":
+ default:
+ return nil, fmt.Errorf("%s is not a proxy scheme Gateway can
use", parsedUrl.Scheme)
+ }
+ return parsedUrl, nil
+}
+
+// Env is the proxy configuration a child process reads, for the package
+// managers and vendor install scripts Gateway runs: none of them share this
+// process's transport, so a proxy set here has to be handed to them. Empty
when
+// no proxy is configured, which leaves a child whatever the environment
already
+// gives it.
+//
+// Both spellings are set because tools disagree on which they read. winget is
+// the exception on Windows: it goes through the system proxy and ignores
these.
+func Env() []string {
+ parsedUrl := currentProxyUrl()
+ if parsedUrl == nil {
+ return nil
+ }
+
+ address := parsedUrl.String()
+ return []string{
+ "HTTP_PROXY=" + address, "http_proxy=" + address,
+ "HTTPS_PROXY=" + address, "https_proxy=" + address,
+ "ALL_PROXY=" + address, "all_proxy=" + address,
+ }
+}
diff --git a/routers/router.go b/routers/router.go
index b696ac3..4669d6c 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -43,6 +43,7 @@ func initAPI() {
beego.Router("/api/get-update-status", &controllers.ApiController{},
"GET:GetUpdateStatus")
beego.Router("/api/get-setting", &controllers.ApiController{},
"GET:GetSetting")
beego.Router("/api/update-setting", &controllers.ApiController{},
"POST:UpdateSetting")
+ beego.Router("/api/test-outbound-proxy", &controllers.ApiController{},
"POST:TestOutboundProxy")
beego.Router("/api/get-autostart", &controllers.ApiController{},
"GET:GetAutostart")
beego.Router("/api/update-autostart", &controllers.ApiController{},
"POST:UpdateAutostart")
diff --git a/web/src/backend/SettingBackend.ts
b/web/src/backend/SettingBackend.ts
index e8a153a..80f72e3 100644
--- a/web/src/backend/SettingBackend.ts
+++ b/web/src/backend/SettingBackend.ts
@@ -13,7 +13,7 @@
// limitations under the License.
import {request} from "@/backend/request";
-import type {Setting} from "@/types";
+import type {ProxyCheck, Setting} from "@/types";
export function getSetting() {
return request<Setting>("/api/get-setting");
@@ -22,3 +22,8 @@ export function getSetting() {
export function updateSetting(setting: Setting) {
return request<Setting>("/api/update-setting", "POST", setting);
}
+
+/** Tries the outbound proxy, the address as typed rather than as stored. */
+export function testOutboundProxy(address: string) {
+ return request<ProxyCheck>("/api/test-outbound-proxy", "POST", {address:
address});
+}
diff --git a/web/src/components/settings/proxy-field.tsx
b/web/src/components/settings/proxy-field.tsx
new file mode 100644
index 0000000..9434d10
--- /dev/null
+++ b/web/src/components/settings/proxy-field.tsx
@@ -0,0 +1,125 @@
+// Copyright 2026 The casbin Authors. All Rights Reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import * as React from "react";
+import {Check, Globe, PlugZap, X} from "lucide-react";
+import i18next from "i18next";
+
+import * as SettingBackend from "@/backend/SettingBackend";
+import * as Setting from "@/Setting";
+import {Field} from "@/components/shared/form-dialog";
+import {MessageAlert} from "@/components/ui/alert";
+import {Badge} from "@/components/ui/badge";
+import {Button} from "@/components/ui/button";
+import {Input} from "@/components/ui/input";
+import type {ProxyCheck} from "@/types";
+
+/**
+ * The proxy every outbound request goes through, with a button that tries it.
+ * The test uses the address as typed rather than as stored, so a proxy can be
+ * checked before the page is saved.
+ */
+export function ProxyField({value, onChange}: {value: string; onChange:
(value: string) => void}) {
+ const [testing, setTesting] = React.useState(false);
+ const [result, setResult] = React.useState<ProxyCheck | null>(null);
+
+ const test = () => {
+ setTesting(true);
+ setResult(null);
+ SettingBackend.testOutboundProxy(value)
+ .then(res => {
+ if (res.status === "ok" && res.data) {
+ setResult(res.data);
+ } else {
+ Setting.showMessage("error", res.msg);
+ }
+ })
+ .catch(failure => Setting.showMessage("error", failure.message ||
String(failure)))
+ .then(() => setTesting(false));
+ };
+
+ return (
+ <Field className="md:col-span-2" label={i18next.t("setting:Outbound SOCKS5
proxy")} htmlFor="setting-httpProxy">
+ <div className="flex flex-wrap items-center gap-2">
+ <Input
+ id="setting-httpProxy"
+ value={value}
+ placeholder="127.0.0.1:1080"
+ className="max-w-xs"
+ onChange={event => onChange(event.target.value)}
+ />
+ <Button variant="outline" loading={testing} onClick={test}>
+ <PlugZap />
+ {i18next.t("setting:Test the proxy")}
+ </Button>
+ </div>
+ <p className="text-muted-foreground
text-xs">{i18next.t("setting:Outbound SOCKS5 proxy hint")}</p>
+ {result === null ? null : <ProxyResult result={result} />}
+ </Field>
+ );
+}
+
+function ProxyResult({result}: {result: ProxyCheck}) {
+ if (!result.dialed) {
+ return (
+ <MessageAlert
+ variant="destructive"
+ title={i18next.t("setting:{proxy} did not accept a
connection").replace("{proxy}", result.address)}
+ description={result.dialError}
+ />
+ );
+ }
+
+ const targets = result.targets ?? [];
+ const restricted = targets.find(target => target.restricted);
+ const exit = [result.exitIp, result.exitCountry].filter(part => part).join("
· ");
+
+ return (
+ <div className="flex flex-col gap-2 rounded-lg border bg-card px-4 py-3
text-sm">
+ <div className="flex flex-wrap items-center gap-2">
+ <Badge variant="success">
+ <Check />
+ {i18next.t("setting:Answered in {millis} ms").replace("{millis}",
String(result.dialMillis))}
+ </Badge>
+ {restricted === undefined ? null : (
+ <Badge variant={restricted.ok ? "success" : "danger"}>
+ {restricted.ok ? <Check /> : <X />}
+ {restricted.ok
+ ? i18next.t("setting:Blocked sites are reachable")
+ : i18next.t("setting:Blocked sites are not reachable")}
+ </Badge>
+ )}
+ {exit === "" ? null : (
+ <Badge variant="info">
+ <Globe />
+ {i18next.t("setting:Traffic leaves from {exit}").replace("{exit}",
exit)}
+ </Badge>
+ )}
+ </div>
+
+ <div className="grid gap-1">
+ {targets.map(target => (
+ <div key={target.url} className="flex flex-wrap items-baseline
gap-x-2 text-xs">
+ {target.ok ? <Check className="size-3.5 text-success" /> : <X
className="size-3.5 text-destructive" />}
+ <span className="font-medium">{target.name}</span>
+ <span className="text-muted-foreground
break-all">{target.url}</span>
+ <span className="text-muted-foreground ml-auto shrink-0">
+ {target.ok ? `${target.status} · ${target.millis} ms` :
target.error}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/web/src/locales/en/data.json b/web/src/locales/en/data.json
index ff19b4c..2084825 100644
--- a/web/src/locales/en/data.json
+++ b/web/src/locales/en/data.json
@@ -1273,6 +1273,7 @@
"Allowed hosts hint": "Extra Host names Gateway answers to,
comma-separated. Its own addresses, \"localhost\" and this machine's hostname
always work; add a name only when a reverse proxy or another domain points
here.",
"Allowed origins": "Allowed origins",
"Allowed origins hint": "Browser origins allowed to call the management
API from another site, comma-separated and written in full, e.g.
\"https://app.example.com\". The web UI needs none of these, and neither does a
browser extension calling /v1.",
+ "Answered in {millis} ms": "Answered in {millis} ms",
"Autostart is off": "Autostart is off",
"Autostart is on": "Autostart is on",
"Back up now": "Back up now",
@@ -1281,6 +1282,8 @@
"Backups are on": "Backups are on",
"Backups description": "A snapshot of the configuration, written beside
the database rather than inside it, so it survives the database it was taken
from. One is also taken in front of every import.",
"Before an import": "Before an import",
+ "Blocked sites are not reachable": "Blocked sites are not reachable",
+ "Blocked sites are reachable": "Blocked sites are reachable",
"Bucket": "Bucket",
"By hand": "By hand",
"Casdoor application": "Casdoor application",
@@ -1349,7 +1352,7 @@
"Once a day": "Once a day",
"Once a week": "Once a week",
"Outbound SOCKS5 proxy": "Outbound SOCKS5 proxy",
- "Outbound SOCKS5 proxy hint": "\"host:port\", used for outbound traffic
such as update downloads.",
+ "Outbound SOCKS5 proxy hint": "\"host:port\", used for Gateway's own
outbound traffic and for the package managers it runs to install agents.",
"Overwrite what the file names": "Overwrite what the file names",
"Page description": "Everything Casbin Gateway reads out of its
configuration. Changes are stored in the database and applied at once.",
"Password": "Password",
@@ -1394,6 +1397,7 @@
"Syncing is on": "Syncing is on",
"Taken on {host} at {time}": "Taken on {host} at {time}",
"Test connection": "Test connection",
+ "Test the proxy": "Test the proxy",
"The backup was restored": "The backup was restored",
"The file carries every key": "The file carries every key",
"The file carries every key hint": "Anyone who opens it can read every
provider API key on this machine. Keep it where the keys themselves belong.",
@@ -1404,6 +1408,7 @@
"The sync finished": "The sync finished",
"This file carries no keys": "This file carries no API keys, so every
provider in it needs its key typed again.",
"This file is not a Gateway snapshot": "This file is not a Gateway
snapshot",
+ "Traffic leaves from {exit}": "Traffic leaves from {exit}",
"Updated": "Updated",
"Username": "Username",
"WebDAV": "WebDAV",
@@ -1416,6 +1421,7 @@
"Where backups live": "Where backups live",
"Where backups live hint": "The directory the snapshots are written to.
Point it at a folder Dropbox, OneDrive, iCloud Drive or your NAS already syncs,
and every backup is uploaded as it is written.",
"{count} in this file": "{count} in this file",
+ "{proxy} did not accept a connection": "{proxy} did not accept a
connection",
"{up} sent, {down} received, {gone} dropped": "{up} sent, {down} received,
{gone} dropped"
},
"usage": {
diff --git a/web/src/locales/zh/data.json b/web/src/locales/zh/data.json
index d42d7eb..ed0e3f1 100644
--- a/web/src/locales/zh/data.json
+++ b/web/src/locales/zh/data.json
@@ -1343,6 +1343,7 @@
"Allowed hosts hint":
"网关额外响应的Host名称,用逗号分隔。本机地址、\"localhost\"和本机主机名始终可用;仅当有反向代理或其他域名指向这里时才需要添加。",
"Allowed origins": "允许的来源",
"Allowed origins hint":
"允许从其他站点调用管理API的浏览器来源,用逗号分隔并写全,例如\"https://app.example.com\"。网页控制台无需配置,浏览器扩展调用/v1也无需配置。",
+ "Answered in {millis} ms": "{millis}毫秒内应答",
"Autostart is off": "自启动已关闭",
"Autostart is on": "自启动已开启",
"Back up now": "立即备份",
@@ -1351,6 +1352,8 @@
"Backups are on": "自动备份已开启",
"Backups description": "配置的快照,写在数据库旁边而不是里面,所以它比数据库本身活得更久。每次导入之前也会自动备份一份。",
"Before an import": "导入前",
+ "Blocked sites are not reachable": "无法访问被屏蔽的站点",
+ "Blocked sites are reachable": "可以访问被屏蔽的站点",
"Bucket": "存储桶",
"By hand": "手动",
"Casdoor application": "Casdoor应用",
@@ -1419,7 +1422,7 @@
"Once a day": "每天一次",
"Once a week": "每周一次",
"Outbound SOCKS5 proxy": "出站SOCKS5代理",
- "Outbound SOCKS5 proxy hint": "形如\"host:port\",用于升级下载等出站流量。",
+ "Outbound SOCKS5 proxy hint":
"形如\"host:port\",用于Gateway自身的出站流量,以及安装Agent时运行的包管理器。",
"Overwrite what the file names": "覆盖文件里提到的",
"Page description": "Casbin Gateway的全部配置项。修改会保存到数据库并立即生效。",
"Password": "密码",
@@ -1464,6 +1467,7 @@
"Syncing is on": "同步已开启",
"Taken on {host} at {time}": "{time}于{host}导出",
"Test connection": "测试连接",
+ "Test the proxy": "测试代理",
"The backup was restored": "备份已恢复",
"The file carries every key": "这个文件会带上全部密钥",
"The file carries every key hint":
"任何打开它的人都能看到这台机器上所有供应商的API密钥,请按密钥本身的标准保管。",
@@ -1474,6 +1478,7 @@
"The sync finished": "同步完成",
"This file carries no keys": "这个文件里没有API密钥,里面每个供应商的密钥都要重新填。",
"This file is not a Gateway snapshot": "这个文件不是Gateway的配置快照",
+ "Traffic leaves from {exit}": "出口位置{exit}",
"Updated": "更新",
"Username": "用户名",
"WebDAV": "WebDAV",
@@ -1486,6 +1491,7 @@
"Where backups live": "备份存放位置",
"Where backups live hint":
"快照写入的目录。指向Dropbox、OneDrive、iCloud云盘或NAS已经在同步的文件夹,每次备份写入时就会被一并上传。",
"{count} in this file": "文件里有{count}项",
+ "{proxy} did not accept a connection": "{proxy}拒绝了连接",
"{up} sent, {down} received, {gone} dropped": "上传{up}个,下载{down}个,清理{gone}个"
},
"usage": {
diff --git a/web/src/pages/SettingPage.tsx b/web/src/pages/SettingPage.tsx
index db00c54..8061822 100644
--- a/web/src/pages/SettingPage.tsx
+++ b/web/src/pages/SettingPage.tsx
@@ -21,6 +21,7 @@ import * as Setting from "@/Setting";
import {BackupPanel} from "@/components/settings/backup-panel";
import {CloudSyncPanel} from "@/components/settings/cloud-sync-panel";
import {ImportExportPanel} from "@/components/settings/import-export-panel";
+import {ProxyField} from "@/components/settings/proxy-field";
import {StartupPanel} from "@/components/settings/startup-panel";
import {Field} from "@/components/shared/form-dialog";
import {Loading} from "@/components/shared/loading";
@@ -220,7 +221,7 @@ export default function SettingPage({account}: {account:
Account}) {
</Section>
<Section id="network" columns={2} title={i18next.t("setting:Network")}>
- {textField("httpProxy", i18next.t("setting:Outbound SOCKS5 proxy"),
i18next.t("setting:Outbound SOCKS5 proxy hint"))}
+ <ProxyField value={setting.httpProxy} onChange={value =>
updateField("httpProxy", value)} />
{textField("allowedHosts", i18next.t("setting:Allowed hosts"),
i18next.t("setting:Allowed hosts hint"))}
{textField("allowedOrigins", i18next.t("setting:Allowed origins"),
i18next.t("setting:Allowed origins hint"))}
</Section>
diff --git a/web/src/types.ts b/web/src/types.ts
index bf4dfee..0b77e17 100644
--- a/web/src/types.ts
+++ b/web/src/types.ts
@@ -938,6 +938,30 @@ export interface Setting {
cloudSyncOptions: string;
}
+/** One upstream the proxy test fetched. `restricted` marks the one a blocked
+ * network cannot reach on its own, so reaching it says the proxy gets out. */
+export interface ProxyCheckTarget {
+ name: string;
+ url: string;
+ restricted?: boolean;
+ ok: boolean;
+ status?: number;
+ millis: number;
+ error?: string;
+}
+
+/** What one test of the outbound proxy found. */
+export interface ProxyCheck {
+ address: string;
+ dialed: boolean;
+ dialMillis: number;
+ dialError?: string;
+ exitIp?: string;
+ exitCountry?: string;
+ /** Absent when the proxy port itself did not answer. */
+ targets?: ProxyCheckTarget[];
+}
+
export type BackupMode = "auto" | "off";
/** Which sections a snapshot carries, and whether the credentials inside them
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]