From de47602923131fa4fbb99738f0726b1b90b0447b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 21 Jul 2026 20:42:10 +0800 Subject: [PATCH] Add Fortinet hostcheck support --- docs/configuration/endpoint/openconnect.md | 28 ++++++ docs/configuration/endpoint/openconnect.zh.md | 28 ++++++ go.mod | 2 +- go.sum | 6 +- option/openconnect.go | 88 ++++++++++--------- protocol/openconnect/client.go | 8 ++ 6 files changed, 114 insertions(+), 46 deletions(-) diff --git a/docs/configuration/endpoint/openconnect.md b/docs/configuration/endpoint/openconnect.md index 2f6e4bf1d..8edaa9f9e 100644 --- a/docs/configuration/endpoint/openconnect.md +++ b/docs/configuration/endpoint/openconnect.md @@ -58,6 +58,10 @@ } ] }, + "fortinet_host_check": { + "hostcheck": "", + "check_virtual_desktop": "" + }, "no_udp": false, "dtls_local_port": 0, "compression_disabled": false, @@ -342,6 +346,30 @@ TNCC machine certificate path in PEM format. Conflict with `tncc.certificates.certificate`. +### fortinet_host_check + +Fortinet hostcheck result override. + +Hostcheck is disabled by default. It is enabled only when `fortinet_host_check.hostcheck` is non-empty. No operating system, security product, or network interface information is collected automatically. + +When enabled and a successful Fortinet login response requests hostcheck, both configured values are submitted to the server before the VPN session is used. The values are sent unchanged as `application/x-www-form-urlencoded` fields. + +Some Fortinet servers only request hostcheck from recognized FortiClient user agents. Configure `user_agent` when required by the server policy. + +### fortinet_host_check.hostcheck + +Fortinet hostcheck result string. + +The conventional format is `,`, for example `0100,10.0.19042`. `security-status` contains four `0` or `1` characters representing, in order, third-party firewall, third-party antivirus, FortiClient firewall, and FortiClient antivirus. + +An empty value disables Fortinet hostcheck, even if `fortinet_host_check.check_virtual_desktop` is configured. + +### fortinet_host_check.check_virtual_desktop + +Fortinet virtual desktop check result string. + +FortiClient conventionally sends colon-separated MAC addresses joined by `|`, for example `74:78:27:4d:81:93|84:1b:77:3a:95:84`. An empty value is submitted as an empty field when hostcheck is enabled. + ### no_udp Disable the DTLS or ESP secondary data channel and use the TLS data channel only. diff --git a/docs/configuration/endpoint/openconnect.zh.md b/docs/configuration/endpoint/openconnect.zh.md index f4dfa353e..63bebdd26 100644 --- a/docs/configuration/endpoint/openconnect.zh.md +++ b/docs/configuration/endpoint/openconnect.zh.md @@ -58,6 +58,10 @@ } ] }, + "fortinet_host_check": { + "hostcheck": "", + "check_virtual_desktop": "" + }, "no_udp": false, "dtls_local_port": 0, "compression_disabled": false, @@ -342,6 +346,30 @@ PEM 格式的 TNCC 机器证书路径。 与 `tncc.certificates.certificate` 冲突。 +### fortinet_host_check + +Fortinet hostcheck 结果覆盖选项。 + +默认禁用 hostcheck。仅当 `fortinet_host_check.hostcheck` 非空时启用。不会自动收集操作系统、安全产品或网络接口信息。 + +启用后,如果成功的 Fortinet 登录响应要求 hostcheck,将在使用 VPN 会话前向服务器提交两个配置值。这些值不经修改,作为 `application/x-www-form-urlencoded` 字段发送。 + +部分 Fortinet 服务器只会要求可识别的 FortiClient User-Agent 执行 hostcheck。服务器策略有要求时请配置 `user_agent`。 + +### fortinet_host_check.hostcheck + +Fortinet hostcheck 结果字符串。 + +通常格式为 `,`,例如 `0100,10.0.19042`。`security-status` 包含四个 `0` 或 `1` 字符,依次表示第三方防火墙、第三方杀毒软件、FortiClient 防火墙和 FortiClient 杀毒软件。 + +空值会禁用 Fortinet hostcheck,即使配置了 `fortinet_host_check.check_virtual_desktop`。 + +### fortinet_host_check.check_virtual_desktop + +Fortinet virtual desktop 检查结果字符串。 + +FortiClient 通常发送以冒号分隔的 MAC 地址,多个地址使用 `|` 连接,例如 `74:78:27:4d:81:93|84:1b:77:3a:95:84`。启用 hostcheck 时,空值会作为空字段提交。 + ### no_udp 禁用 DTLS 或 ESP 辅助数据通道,仅使用 TLS 数据通道。 diff --git a/go.mod b/go.mod index 664cd3176..e8a2f4643 100644 --- a/go.mod +++ b/go.mod @@ -46,7 +46,7 @@ require ( github.com/sagernet/sing v0.8.12-0.20260721063414-596db5dd6ef4 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 github.com/sagernet/sing-mux v0.3.5 - github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a + github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 github.com/sagernet/sing-shadowsocks v0.2.8 diff --git a/go.sum b/go.sum index f12bdfcba..39f329e31 100644 --- a/go.sum +++ b/go.sum @@ -190,8 +190,6 @@ github.com/mdlayher/netlink v1.9.0 h1:G8+GLq2x3v4D4MVIqDdNUhTUC7TKiCy/6MDkmItfKc github.com/mdlayher/netlink v1.9.0/go.mod h1:YBnl5BXsCoRuwBjKKlZ+aYmEoq0r12FDA/3JC+94KDg= github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= -github.com/metacubex/utls v1.8.4 h1:HmL9nUApDdWSkgUyodfwF6hSjtiwCGGdyhaSpEejKpg= -github.com/metacubex/utls v1.8.4/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs= github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko= github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk= @@ -319,8 +317,8 @@ github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3 h1:3y6 github.com/sagernet/sing-cloudflared v0.1.3-0.20260706062323-d9787e794aa3/go.mod h1:XEqEDYRCAYLaoPjZ1ifVWJg5iWAJHL2gOAXe/PM28Cg= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= -github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a h1:DafepSfytV5uShjQctZ0Cnw+Q9cOBIMT88p8jU2DSck= -github.com/sagernet/sing-openconnect v0.0.0-20260721013312-6c25fa7e089a/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= +github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f h1:FKT5eCVyNiRNpa5jov6Fl9aQpAgKuCmkuhqVXvBK8b4= +github.com/sagernet/sing-openconnect v0.0.0-20260721123934-0d945d43288f/go.mod h1:4AKZLVcvY3r54UaK2Gbnm7aN8pOwdLz+y4EP0QFZ5Eg= github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277 h1:4H38L3OxOx1fGEuH4n9lh/5O7XtZTgQ/1V/gdQ+b+Es= github.com/sagernet/sing-openvpn v0.0.0-20260721005523-64b754d1c277/go.mod h1:PWX7WygD8jpwfqfaGNySXpJYTn0SOwjBI1BKHHC2+Bw= github.com/sagernet/sing-quic v0.6.4-0.20260720125339-63fa6a7ebc31 h1:FuiTs45PaA1/f2Kj6E2QlctFBrJUUFB8QAHnV5im9c8= diff --git a/option/openconnect.go b/option/openconnect.go index f2450871b..d3ff01ab4 100644 --- a/option/openconnect.go +++ b/option/openconnect.go @@ -4,47 +4,48 @@ import "github.com/sagernet/sing/common/json/badoption" type OpenConnectEndpointOptions struct { DialerOptions - System bool `json:"system,omitempty"` - Name string `json:"name,omitempty"` - UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` - UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` - UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` - UDPNATMax uint32 `json:"udp_nat_max,omitempty"` - Server string `json:"server"` - Flavor string `json:"flavor,omitempty"` - Username string `json:"username,omitempty"` - Password string `json:"password,omitempty"` - AuthGroup string `json:"auth_group,omitempty"` - Cookie string `json:"cookie,omitempty"` - Token *OpenConnectTokenOptions `json:"token,omitempty"` - ReportedOS string `json:"reported_os,omitempty"` - UserAgent string `json:"user_agent,omitempty"` - Version string `json:"version,omitempty"` - LocalHostname string `json:"local_hostname,omitempty"` - Mobile *OpenConnectMobileOptions `json:"mobile,omitempty"` - CSD *OpenConnectCSDOptions `json:"csd,omitempty"` - HIP *OpenConnectHIPOptions `json:"hip,omitempty"` - TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` - NoUDP bool `json:"no_udp,omitempty"` - DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` - CompressionDisabled bool `json:"compression_disabled,omitempty"` - CompressionMode string `json:"compression_mode,omitempty"` - IPv6Disabled bool `json:"ipv6_disabled,omitempty"` - HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` - XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` - ExternalAuthDisabled bool `json:"external_auth_disabled,omitempty"` - PasswordAuthenticationDisabled bool `json:"password_authentication_disabled,omitempty"` - TCPKeepAliveEnabled bool `json:"tcp_keep_alive_enabled,omitempty"` - PFS bool `json:"pfs,omitempty"` - MTU uint32 `json:"mtu,omitempty"` - BaseMTU uint32 `json:"base_mtu,omitempty"` - DPDInterval badoption.Duration `json:"dpd_interval,omitempty"` - ReconnectTimeout badoption.Duration `json:"reconnect_timeout,omitempty"` - TrojanInterval badoption.Duration `json:"trojan_interval,omitempty"` - QueueLength uint32 `json:"queue_length,omitempty"` - AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` - TLS OpenConnectTLSOptions `json:"tls,omitempty"` - FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` + System bool `json:"system,omitempty"` + Name string `json:"name,omitempty"` + UDPTimeout badoption.Duration `json:"udp_timeout,omitempty"` + UDPMapping UDPNATBehavior `json:"udp_mapping,omitempty"` + UDPFiltering UDPNATBehavior `json:"udp_filtering,omitempty"` + UDPNATMax uint32 `json:"udp_nat_max,omitempty"` + Server string `json:"server"` + Flavor string `json:"flavor,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + AuthGroup string `json:"auth_group,omitempty"` + Cookie string `json:"cookie,omitempty"` + Token *OpenConnectTokenOptions `json:"token,omitempty"` + ReportedOS string `json:"reported_os,omitempty"` + UserAgent string `json:"user_agent,omitempty"` + Version string `json:"version,omitempty"` + LocalHostname string `json:"local_hostname,omitempty"` + Mobile *OpenConnectMobileOptions `json:"mobile,omitempty"` + CSD *OpenConnectCSDOptions `json:"csd,omitempty"` + HIP *OpenConnectHIPOptions `json:"hip,omitempty"` + TNCC *OpenConnectTNCCOptions `json:"tncc,omitempty"` + FortinetHostCheck *OpenConnectFortinetHostCheckOptions `json:"fortinet_host_check,omitempty"` + NoUDP bool `json:"no_udp,omitempty"` + DTLSLocalPort uint16 `json:"dtls_local_port,omitempty"` + CompressionDisabled bool `json:"compression_disabled,omitempty"` + CompressionMode string `json:"compression_mode,omitempty"` + IPv6Disabled bool `json:"ipv6_disabled,omitempty"` + HTTPKeepAliveDisabled bool `json:"http_keepalive_disabled,omitempty"` + XMLPostDisabled bool `json:"xml_post_disabled,omitempty"` + ExternalAuthDisabled bool `json:"external_auth_disabled,omitempty"` + PasswordAuthenticationDisabled bool `json:"password_authentication_disabled,omitempty"` + TCPKeepAliveEnabled bool `json:"tcp_keep_alive_enabled,omitempty"` + PFS bool `json:"pfs,omitempty"` + MTU uint32 `json:"mtu,omitempty"` + BaseMTU uint32 `json:"base_mtu,omitempty"` + DPDInterval badoption.Duration `json:"dpd_interval,omitempty"` + ReconnectTimeout badoption.Duration `json:"reconnect_timeout,omitempty"` + TrojanInterval badoption.Duration `json:"trojan_interval,omitempty"` + QueueLength uint32 `json:"queue_length,omitempty"` + AllowInsecureCrypto bool `json:"allow_insecure_crypto,omitempty"` + TLS OpenConnectTLSOptions `json:"tls,omitempty"` + FormEntries []OpenConnectFormEntryOptions `json:"form_entries,omitempty"` } type OpenConnectTokenOptions struct { @@ -79,6 +80,11 @@ type OpenConnectTNCCOptions struct { Certificates []OpenConnectTNCCCertificateOptions `json:"certificates,omitempty"` } +type OpenConnectFortinetHostCheckOptions struct { + HostCheck string `json:"hostcheck,omitempty"` + CheckVirtualDesktop string `json:"check_virtual_desktop,omitempty"` +} + type OpenConnectTNCCCertificateOptions struct { Certificate badoption.Listable[string] `json:"certificate,omitempty"` CertificatePath string `json:"certificate_path,omitempty"` diff --git a/protocol/openconnect/client.go b/protocol/openconnect/client.go index 715066a8b..4c139f63e 100644 --- a/protocol/openconnect/client.go +++ b/protocol/openconnect/client.go @@ -254,6 +254,13 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, Certificates: tnccCertificates, } } + var fortinetHostCheckOptions *openconnect.FortinetHostCheckOptions + if options.FortinetHostCheck != nil { + fortinetHostCheckOptions = &openconnect.FortinetHostCheckOptions{ + HostCheck: options.FortinetHostCheck.HostCheck, + CheckVirtualDesktop: options.FortinetHostCheck.CheckVirtualDesktop, + } + } formEntries := common.Map(options.FormEntries, func(entry option.OpenConnectFormEntryOptions) openconnect.FormEntry { return openconnect.FormEntry{ FormID: entry.FormID, @@ -280,6 +287,7 @@ func (e *Endpoint) buildClientOptions(options option.OpenConnectEndpointOptions, CSD: csdOptions, HIP: hipOptions, TNCC: tnccOptions, + FortinetHostCheck: fortinetHostCheckOptions, NoUDP: options.NoUDP, DTLSLocalPort: options.DTLSLocalPort, CompressionDisabled: options.CompressionDisabled,