mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-22 08:39:54 +00:00
Compare commits
91 Commits
wasm-webso
...
embedded-v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412193c602 | ||
|
|
5e67febf57 | ||
|
|
ee348ba007 | ||
|
|
3d3055dc7f | ||
|
|
2f4ddf0796 | ||
|
|
98d533c8e8 | ||
|
|
ef4ea2e311 | ||
|
|
b41d11bbbe | ||
|
|
f37e228cc2 | ||
|
|
640a267556 | ||
|
|
17359cdc1e | ||
|
|
7e5846a1ee | ||
|
|
517bea0daf | ||
|
|
9192b4f029 | ||
|
|
c784b02550 | ||
|
|
896530fd82 | ||
|
|
354fd004c7 | ||
|
|
c28e41e82b | ||
|
|
02b9fe704b | ||
|
|
5e200fa571 | ||
|
|
7d61975f6c | ||
|
|
62b36112ea | ||
|
|
df9a6fb020 | ||
|
|
b1b04f9ec6 | ||
|
|
fe15688f20 | ||
|
|
2285db2b62 | ||
|
|
b3f0f53a23 | ||
|
|
5eec9962ba | ||
|
|
393c102f45 | ||
|
|
b41fbad5e1 | ||
|
|
24a5f2252c | ||
|
|
9d189bb3e8 | ||
|
|
8e2505b59c | ||
|
|
97bc1eebde | ||
|
|
32a5a061b8 | ||
|
|
d927ef468a | ||
|
|
d3f3e08035 | ||
|
|
6bb66e0fad | ||
|
|
d250f92c43 | ||
|
|
80966ab1b0 | ||
|
|
af24fd7796 | ||
|
|
13d32d274f | ||
|
|
bc407527f4 | ||
|
|
5543404188 | ||
|
|
c2fdf62f1f | ||
|
|
b9f5264e36 | ||
|
|
705f87fc20 | ||
|
|
97d0a6776f | ||
|
|
7e7e056f3a | ||
|
|
785f94d13f | ||
|
|
bfb6750b13 | ||
|
|
f5e1057127 | ||
|
|
ee393d0e62 | ||
|
|
0b8fc5da59 | ||
|
|
2d0a54f31a | ||
|
|
61ec8d67de | ||
|
|
76add0b9b2 | ||
|
|
a11341f57a | ||
|
|
b135d462d6 | ||
|
|
da37a28951 | ||
|
|
4f884d9f30 | ||
|
|
2bed8b641b | ||
|
|
b4f696272a | ||
|
|
6d937af7a0 | ||
|
|
db5b6cfbb7 | ||
|
|
e75948753a | ||
|
|
047cc958b5 | ||
|
|
cd005ef9a9 | ||
|
|
44ed0c1992 | ||
|
|
d6d3fa95c7 | ||
|
|
fa90283781 | ||
|
|
8bf13b0d0c | ||
|
|
a8541a1529 | ||
|
|
94068d3ebc | ||
|
|
738c585ee7 | ||
|
|
9b5541d17d | ||
|
|
7123e6d1f4 | ||
|
|
62cf9e873b | ||
|
|
3f91f49277 | ||
|
|
9f0aa1ce26 | ||
|
|
347c5bf317 | ||
|
|
22e2519d71 | ||
|
|
e916f12cca | ||
|
|
9ed2e2a5b4 | ||
|
|
2ccae7ec47 | ||
|
|
07e5450117 | ||
|
|
3f914090cb | ||
|
|
ea9fab4396 | ||
|
|
77b479286e | ||
|
|
ab2a8794e7 | ||
|
|
9126a192ca |
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -12,6 +12,7 @@
|
||||
- [ ] Is a feature enhancement
|
||||
- [ ] It is a refactor
|
||||
- [ ] Created tests that fail without the change (if possible)
|
||||
- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
|
||||
|
||||
> By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
|
||||
|
||||
|
||||
4
.github/workflows/wasm-build-validation.yml
vendored
4
.github/workflows/wasm-build-validation.yml
vendored
@@ -61,8 +61,8 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 58720256 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||
if [ ${SIZE} -gt 62914560 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/
|
||||
- [Contributing to NetBird](#contributing-to-netbird)
|
||||
- [Contents](#contents)
|
||||
- [Code of conduct](#code-of-conduct)
|
||||
- [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first)
|
||||
- [Directory structure](#directory-structure)
|
||||
- [Development setup](#development-setup)
|
||||
- [Requirements](#requirements)
|
||||
@@ -33,6 +34,14 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||
By participating, you are expected to uphold this code. Please report
|
||||
unacceptable behavior to community@netbird.io.
|
||||
|
||||
## Discuss changes with the NetBird team first
|
||||
|
||||
Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation.
|
||||
|
||||
Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design.
|
||||
|
||||
Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly.
|
||||
|
||||
## Directory structure
|
||||
|
||||
The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
|
||||
|
||||
@@ -43,16 +43,16 @@ func init() {
|
||||
ipsFilterMap = make(map[string]struct{})
|
||||
prefixNamesFilterMap = make(map[string]struct{})
|
||||
statusCmd.PersistentFlags().BoolVarP(&detailFlag, "detail", "d", false, "display detailed status information in human-readable format")
|
||||
statusCmd.PersistentFlags().BoolVar(&jsonFlag, "json", false, "display detailed status information in json format")
|
||||
statusCmd.PersistentFlags().BoolVar(&yamlFlag, "yaml", false, "display detailed status information in yaml format")
|
||||
statusCmd.PersistentFlags().BoolVar(&ipv4Flag, "ipv4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
||||
statusCmd.PersistentFlags().BoolVar(&ipv6Flag, "ipv6", false, "display only NetBird IPv6 of this peer")
|
||||
statusCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display detailed status information in json format")
|
||||
statusCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display detailed status information in yaml format")
|
||||
statusCmd.PersistentFlags().BoolVarP(&ipv4Flag, "ipv4", "4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
||||
statusCmd.PersistentFlags().BoolVarP(&ipv6Flag, "ipv6", "6", false, "display only NetBird IPv6 of this peer")
|
||||
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6")
|
||||
statusCmd.PersistentFlags().StringSliceVar(&ipsFilter, "filter-by-ips", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1")
|
||||
statusCmd.PersistentFlags().StringSliceVar(&prefixNamesFilter, "filter-by-names", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
||||
statusCmd.PersistentFlags().StringVar(&statusFilter, "filter-by-status", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
||||
statusCmd.PersistentFlags().StringVar(&connectionTypeFilter, "filter-by-connection-type", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
||||
statusCmd.PersistentFlags().StringVar(&checkFlag, "check", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)")
|
||||
statusCmd.PersistentFlags().StringSliceVarP(&ipsFilter, "filter-by-ips", "I", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1")
|
||||
statusCmd.PersistentFlags().StringSliceVarP(&prefixNamesFilter, "filter-by-names", "N", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
||||
statusCmd.PersistentFlags().StringVarP(&statusFilter, "filter-by-status", "S", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
||||
statusCmd.PersistentFlags().StringVarP(&connectionTypeFilter, "filter-by-connection-type", "T", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
||||
statusCmd.PersistentFlags().StringVarP(&checkFlag, "check", "C", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)")
|
||||
}
|
||||
|
||||
func statusFunc(cmd *cobra.Command, args []string) error {
|
||||
|
||||
@@ -361,6 +361,9 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
req.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
@@ -467,6 +470,9 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
ic.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
@@ -595,6 +601,9 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
loginRequest.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||
|
||||
73
client/cmd/vnc_agent.go
Normal file
73
client/cmd/vnc_agent.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build windows || (darwin && !ios)
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
var vncAgentPort uint16
|
||||
|
||||
func init() {
|
||||
vncAgentCmd.Flags().Uint16Var(&vncAgentPort, "port", 15900, "Port for the VNC agent to listen on")
|
||||
rootCmd.AddCommand(vncAgentCmd)
|
||||
}
|
||||
|
||||
// vncAgentCmd runs a VNC server inside the user's interactive session,
|
||||
// listening on localhost. The NetBird service spawns it: on Windows via
|
||||
// CreateProcessAsUser into the console session, on macOS via
|
||||
// launchctl asuser into the Aqua session.
|
||||
var vncAgentCmd = &cobra.Command{
|
||||
Use: "vnc-agent",
|
||||
Short: "Run VNC capture agent (internal, spawned by service)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
log.SetReportCaller(true)
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
log.Infof("VNC agent starting on 127.0.0.1:%d", vncAgentPort)
|
||||
|
||||
token := os.Getenv("NB_VNC_AGENT_TOKEN")
|
||||
if token == "" {
|
||||
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
|
||||
}
|
||||
// Drop the token from our process environment so any child the
|
||||
// agent spawns does not inherit it, and casual debugging tools
|
||||
// that dump /proc/<pid>/environ (or the Windows equivalent) on a
|
||||
// running agent don't surface the loopback shared secret.
|
||||
if err := os.Unsetenv("NB_VNC_AGENT_TOKEN"); err != nil {
|
||||
log.Debugf("unset NB_VNC_AGENT_TOKEN: %v", err)
|
||||
}
|
||||
|
||||
capturer, injector, err := newAgentResources()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The per-user agent listens only on loopback and is gated by an
|
||||
// agent token shared with the daemon, so no X25519 identity key
|
||||
// is needed; auth is disabled at the RFB layer.
|
||||
srv := vncserver.New(capturer, injector, nil)
|
||||
srv.SetDisableAuth(true)
|
||||
srv.SetAgentToken(token)
|
||||
|
||||
addr := netip.AddrPortFrom(netip.AddrFrom4([4]byte{127, 0, 0, 1}), vncAgentPort)
|
||||
loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8)
|
||||
if err := srv.Start(cmd.Context(), addr, loopback); err != nil {
|
||||
return fmt.Errorf("start vnc server: %w", err)
|
||||
}
|
||||
log.Infof("vnc-agent listening on 127.0.0.1:%d, ready", vncAgentPort)
|
||||
|
||||
<-cmd.Context().Done()
|
||||
log.Info("vnc-agent context cancelled, shutting down")
|
||||
return srv.Stop()
|
||||
},
|
||||
SilenceUsage: true,
|
||||
}
|
||||
18
client/cmd/vnc_agent_darwin.go
Normal file
18
client/cmd/vnc_agent_darwin.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("macOS input injector: %w", err)
|
||||
}
|
||||
return capturer, injector, nil
|
||||
}
|
||||
15
client/cmd/vnc_agent_windows.go
Normal file
15
client/cmd/vnc_agent_windows.go
Normal file
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newAgentResources() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
sessionID := vncserver.GetCurrentSessionID()
|
||||
log.Infof("VNC agent running in Windows session %d", sessionID)
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), nil
|
||||
}
|
||||
9
client/cmd/vnc_flags.go
Normal file
9
client/cmd/vnc_flags.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package cmd
|
||||
|
||||
const serverVNCAllowedFlag = "allow-server-vnc"
|
||||
|
||||
var serverVNCAllowed bool
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
|
||||
}
|
||||
@@ -336,7 +336,7 @@ func (c *Client) ListenTCP(address string) (net.Listener, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split host port: %w", err)
|
||||
}
|
||||
listenAddr := fmt.Sprintf("%s:%s", addr, port)
|
||||
listenAddr := net.JoinHostPort(addr.String(), port)
|
||||
|
||||
tcpAddr, err := net.ResolveTCPAddr("tcp", listenAddr)
|
||||
if err != nil {
|
||||
@@ -357,7 +357,7 @@ func (c *Client) ListenUDP(address string) (net.PacketConn, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("split host port: %w", err)
|
||||
}
|
||||
listenAddr := fmt.Sprintf("%s:%s", addr, port)
|
||||
listenAddr := net.JoinHostPort(addr.String(), port)
|
||||
|
||||
udpAddr, err := net.ResolveUDPAddr("udp", listenAddr)
|
||||
if err != nil {
|
||||
|
||||
@@ -260,15 +260,23 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
||||
|
||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||
|
||||
; Create autostart registry entry based on checkbox
|
||||
; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
|
||||
; or HKCU by legacy installers.
|
||||
DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
SetRegView 32
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
||||
SetRegView 64
|
||||
|
||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||
${If} $AutostartEnabled == "1"
|
||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||
${Else}
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DetailPrint "Autostart not enabled by user"
|
||||
${EndIf}
|
||||
|
||||
@@ -299,11 +307,16 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
||||
DetailPrint "Terminating Netbird UI process..."
|
||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||
|
||||
; Remove autostart registry entry
|
||||
; Remove autostart entries from every view a previous installer may have used.
|
||||
DetailPrint "Removing autostart registry entry if exists..."
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
SetRegView 32
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
||||
SetRegView 64
|
||||
|
||||
; Handle data deletion based on checkbox
|
||||
DetailPrint "Checking if user requested data deletion..."
|
||||
|
||||
@@ -315,6 +315,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.RosenpassEnabled,
|
||||
a.config.RosenpassPermissive,
|
||||
a.config.ServerSSHAllowed,
|
||||
a.config.ServerVNCAllowed,
|
||||
a.config.DisableClientRoutes,
|
||||
a.config.DisableServerRoutes,
|
||||
a.config.DisableDNS,
|
||||
|
||||
@@ -116,7 +116,6 @@ func (c *ConnectClient) RunOniOS(
|
||||
fileDescriptor int32,
|
||||
networkChangeListener listener.NetworkChangeListener,
|
||||
dnsManager dns.IosDnsManager,
|
||||
dnsAddresses []netip.AddrPort,
|
||||
stateFilePath string,
|
||||
) error {
|
||||
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
|
||||
@@ -126,7 +125,6 @@ func (c *ConnectClient) RunOniOS(
|
||||
FileDescriptor: fileDescriptor,
|
||||
NetworkChangeListener: networkChangeListener,
|
||||
DnsManager: dnsManager,
|
||||
HostDNSAddresses: dnsAddresses,
|
||||
StateFilePath: stateFilePath,
|
||||
}
|
||||
return c.run(mobileDependency, nil, "")
|
||||
@@ -564,6 +562,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
RosenpassEnabled: config.RosenpassEnabled,
|
||||
RosenpassPermissive: config.RosenpassPermissive,
|
||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
|
||||
EnableSSHRoot: config.EnableSSHRoot,
|
||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||
@@ -646,6 +645,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.RosenpassEnabled,
|
||||
config.RosenpassPermissive,
|
||||
config.ServerSSHAllowed,
|
||||
config.ServerVNCAllowed,
|
||||
config.DisableClientRoutes,
|
||||
config.DisableServerRoutes,
|
||||
config.DisableDNS,
|
||||
|
||||
@@ -45,8 +45,11 @@ netbird.out: Most recent, anonymized stdout log file of the NetBird client.
|
||||
routes.txt: Detailed system routing table in tabular format including destination, gateway, interface, metrics, and protocol information, if --system-info flag was provided.
|
||||
interfaces.txt: Anonymized network interface information, if --system-info flag was provided.
|
||||
ip_rules.txt: Detailed IP routing rules in tabular format including priority, source, destination, interfaces, table, and action information (Linux only), if --system-info flag was provided.
|
||||
iptables.txt: Anonymized iptables rules with packet counters, if --system-info flag was provided.
|
||||
nftables.txt: Anonymized nftables rules with packet counters, if --system-info flag was provided.
|
||||
iptables.txt: Anonymized iptables (IPv4) rules with packet counters, if --system-info flag was provided.
|
||||
ip6tables.txt: Anonymized ip6tables (IPv6) rules with packet counters, if --system-info flag was provided.
|
||||
ipset.txt: Anonymized ipset list output, if --system-info flag was provided.
|
||||
nftables.txt: Anonymized nftables rules with packet counters across all families (ip, ip6, inet, etc.), if --system-info flag was provided.
|
||||
sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only).
|
||||
resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided.
|
||||
scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided.
|
||||
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
|
||||
@@ -165,22 +168,33 @@ The config.txt file contains anonymized configuration information of the NetBird
|
||||
Other non-sensitive configuration options are included without anonymization.
|
||||
|
||||
Firewall Rules (Linux only)
|
||||
The bundle includes two separate firewall rule files:
|
||||
The bundle includes the following firewall-related files:
|
||||
|
||||
iptables.txt:
|
||||
- Complete iptables ruleset with packet counters using 'iptables -v -n -L'
|
||||
- IPv4 iptables ruleset with packet counters using 'iptables-save' and 'iptables -v -n -L'
|
||||
- Includes all tables (filter, nat, mangle, raw, security)
|
||||
- Shows packet and byte counters for each rule
|
||||
- All IP addresses are anonymized
|
||||
- Chain names, table names, and other non-sensitive information remain unchanged
|
||||
|
||||
ip6tables.txt:
|
||||
- IPv6 ip6tables ruleset with packet counters using 'ip6tables-save' and 'ip6tables -v -n -L'
|
||||
- Same table coverage and anonymization as iptables.txt
|
||||
- Omitted when ip6tables is not installed or no IPv6 rules are present
|
||||
|
||||
ipset.txt:
|
||||
- Output of 'ipset list' (family-agnostic)
|
||||
- IP addresses are anonymized; set names and types remain unchanged
|
||||
|
||||
nftables.txt:
|
||||
- Complete nftables ruleset obtained via 'nft -a list ruleset'
|
||||
- Complete nftables ruleset across all families (ip, ip6, inet, arp, bridge, netdev) via 'nft -a list ruleset'
|
||||
- Includes rule handle numbers and packet counters
|
||||
- All tables, chains, and rules are included
|
||||
- Shows packet and byte counters for each rule
|
||||
- All IP addresses are anonymized
|
||||
- Chain names, table names, and other non-sensitive information remain unchanged
|
||||
- All IP addresses are anonymized; chain/table names remain unchanged
|
||||
|
||||
sysctls.txt:
|
||||
- Forwarding (IPv4 + IPv6, global and per-interface), reverse-path filter, source-validation, conntrack accounting, and TCP-related sysctls that netbird may read or modify
|
||||
- Per-interface keys are enumerated from /proc/sys/net/ipv{4,6}/conf
|
||||
- Interface names anonymized when --anonymize is set
|
||||
|
||||
IP Rules (Linux only)
|
||||
The ip_rules.txt file contains detailed IP routing rule information:
|
||||
@@ -412,6 +426,10 @@ func (g *BundleGenerator) addSystemInfo() {
|
||||
log.Errorf("failed to add firewall rules to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addSysctls(); err != nil {
|
||||
log.Errorf("failed to add sysctls to debug bundle: %v", err)
|
||||
}
|
||||
|
||||
if err := g.addDNSInfo(); err != nil {
|
||||
log.Errorf("failed to add DNS info to debug bundle: %v", err)
|
||||
}
|
||||
@@ -618,6 +636,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
if g.internalConfig.SSHJWTCacheTTL != nil {
|
||||
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
||||
}
|
||||
if g.internalConfig.ServerVNCAllowed != nil {
|
||||
configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed))
|
||||
}
|
||||
|
||||
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
||||
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
||||
|
||||
@@ -124,15 +124,18 @@ func getSystemdLogs(serviceName string) (string, error) {
|
||||
// addFirewallRules collects and adds firewall rules to the archive
|
||||
func (g *BundleGenerator) addFirewallRules() error {
|
||||
log.Info("Collecting firewall rules")
|
||||
iptablesRules, err := collectIPTablesRules()
|
||||
g.addIPTablesRulesToBundle("iptables-save", "iptables", "iptables.txt")
|
||||
g.addIPTablesRulesToBundle("ip6tables-save", "ip6tables", "ip6tables.txt")
|
||||
|
||||
ipsetOutput, err := collectIPSets()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to collect iptables rules: %v", err)
|
||||
log.Warnf("Failed to collect ipset information: %v", err)
|
||||
} else {
|
||||
if g.anonymize {
|
||||
iptablesRules = g.anonymizer.AnonymizeString(iptablesRules)
|
||||
ipsetOutput = g.anonymizer.AnonymizeString(ipsetOutput)
|
||||
}
|
||||
if err := g.addFileToZip(strings.NewReader(iptablesRules), "iptables.txt"); err != nil {
|
||||
log.Warnf("Failed to add iptables rules to bundle: %v", err)
|
||||
if err := g.addFileToZip(strings.NewReader(ipsetOutput), "ipset.txt"); err != nil {
|
||||
log.Warnf("Failed to add ipset output to bundle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,44 +154,65 @@ func (g *BundleGenerator) addFirewallRules() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectIPTablesRules collects rules using both iptables-save and verbose listing
|
||||
func collectIPTablesRules() (string, error) {
|
||||
var builder strings.Builder
|
||||
|
||||
saveOutput, err := collectIPTablesSave()
|
||||
// addIPTablesRulesToBundle collects iptables/ip6tables rules and writes them to the bundle.
|
||||
func (g *BundleGenerator) addIPTablesRulesToBundle(saveBin, listBin, filename string) {
|
||||
rules, err := collectIPTablesRules(saveBin, listBin)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to collect iptables rules using iptables-save: %v", err)
|
||||
} else {
|
||||
builder.WriteString("=== iptables-save output ===\n")
|
||||
log.Warnf("Failed to collect %s rules: %v", listBin, err)
|
||||
return
|
||||
}
|
||||
if g.anonymize {
|
||||
rules = g.anonymizer.AnonymizeString(rules)
|
||||
}
|
||||
if err := g.addFileToZip(strings.NewReader(rules), filename); err != nil {
|
||||
log.Warnf("Failed to add %s rules to bundle: %v", listBin, err)
|
||||
}
|
||||
}
|
||||
|
||||
// collectIPTablesRules collects rules using both <saveBin> and verbose listing via <listBin>.
|
||||
// Returns an error when neither command produced any output (e.g. the binary is missing),
|
||||
// so the caller can skip writing an empty file.
|
||||
func collectIPTablesRules(saveBin, listBin string) (string, error) {
|
||||
var builder strings.Builder
|
||||
var collected bool
|
||||
var firstErr error
|
||||
|
||||
saveOutput, err := runCommand(saveBin)
|
||||
switch {
|
||||
case err != nil:
|
||||
firstErr = err
|
||||
log.Warnf("Failed to collect %s output: %v", saveBin, err)
|
||||
case strings.TrimSpace(saveOutput) == "":
|
||||
log.Debugf("%s produced no output, skipping", saveBin)
|
||||
default:
|
||||
builder.WriteString(fmt.Sprintf("=== %s output ===\n", saveBin))
|
||||
builder.WriteString(saveOutput)
|
||||
builder.WriteString("\n")
|
||||
collected = true
|
||||
}
|
||||
|
||||
ipsetOutput, err := collectIPSets()
|
||||
if err != nil {
|
||||
log.Warnf("Failed to collect ipset information: %v", err)
|
||||
} else {
|
||||
builder.WriteString("=== ipset list output ===\n")
|
||||
builder.WriteString(ipsetOutput)
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
builder.WriteString("=== iptables -v -n -L output ===\n")
|
||||
listHeader := fmt.Sprintf("=== %s -v -n -L output ===\n", listBin)
|
||||
builder.WriteString(listHeader)
|
||||
|
||||
tables := []string{"filter", "nat", "mangle", "raw", "security"}
|
||||
|
||||
for _, table := range tables {
|
||||
builder.WriteString(fmt.Sprintf("*%s\n", table))
|
||||
|
||||
stats, err := getTableStatistics(table)
|
||||
stats, err := runCommand(listBin, "-v", "-n", "-L", "-t", table)
|
||||
if err != nil {
|
||||
log.Warnf("Failed to get statistics for table %s: %v", table, err)
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
log.Warnf("Failed to get %s statistics for table %s: %v", listBin, table, err)
|
||||
continue
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("*%s\n", table))
|
||||
builder.WriteString(stats)
|
||||
builder.WriteString("\n")
|
||||
collected = true
|
||||
}
|
||||
|
||||
if !collected {
|
||||
return "", fmt.Errorf("collect %s rules: %w", listBin, firstErr)
|
||||
}
|
||||
return builder.String(), nil
|
||||
}
|
||||
|
||||
@@ -214,34 +238,15 @@ func collectIPSets() (string, error) {
|
||||
return ipsets, nil
|
||||
}
|
||||
|
||||
// collectIPTablesSave uses iptables-save to get rule definitions
|
||||
func collectIPTablesSave() (string, error) {
|
||||
cmd := exec.Command("iptables-save")
|
||||
// runCommand executes a command and returns its stdout, wrapping stderr in the error on failure.
|
||||
func runCommand(name string, args ...string) (string, error) {
|
||||
cmd := exec.Command(name, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("execute iptables-save: %w (stderr: %s)", err, stderr.String())
|
||||
}
|
||||
|
||||
rules := stdout.String()
|
||||
if strings.TrimSpace(rules) == "" {
|
||||
return "", fmt.Errorf("no iptables rules found")
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// getTableStatistics gets verbose statistics for an entire table using iptables command
|
||||
func getTableStatistics(table string) (string, error) {
|
||||
cmd := exec.Command("iptables", "-v", "-n", "-L", "-t", table)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("execute iptables -v -n -L: %w (stderr: %s)", err, stderr.String())
|
||||
return "", fmt.Errorf("execute %s: %w (stderr: %s)", name, err, stderr.String())
|
||||
}
|
||||
|
||||
return stdout.String(), nil
|
||||
@@ -804,3 +809,91 @@ func formatSetKeyType(keyType nftables.SetDatatype) string {
|
||||
return fmt.Sprintf("type-%v", keyType)
|
||||
}
|
||||
}
|
||||
|
||||
// addSysctls collects forwarding and netbird-managed sysctl values and writes them to the bundle.
|
||||
func (g *BundleGenerator) addSysctls() error {
|
||||
log.Info("Collecting sysctls")
|
||||
content := collectSysctls()
|
||||
if g.anonymize {
|
||||
content = g.anonymizer.AnonymizeString(content)
|
||||
}
|
||||
if err := g.addFileToZip(strings.NewReader(content), "sysctls.txt"); err != nil {
|
||||
return fmt.Errorf("add sysctls to bundle: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectSysctls reads every sysctl that the netbird client may modify, plus
|
||||
// global IPv4/IPv6 forwarding, and returns a formatted dump grouped by topic.
|
||||
// Per-interface values are enumerated by listing /proc/sys/net/ipv{4,6}/conf.
|
||||
func collectSysctls() string {
|
||||
var builder strings.Builder
|
||||
|
||||
writeSysctlGroup(&builder, "forwarding", []string{
|
||||
"net.ipv4.ip_forward",
|
||||
"net.ipv6.conf.all.forwarding",
|
||||
"net.ipv6.conf.default.forwarding",
|
||||
})
|
||||
writeSysctlGroup(&builder, "ipv4 per-interface forwarding", listInterfaceSysctls("ipv4", "forwarding"))
|
||||
writeSysctlGroup(&builder, "ipv6 per-interface forwarding", listInterfaceSysctls("ipv6", "forwarding"))
|
||||
writeSysctlGroup(&builder, "rp_filter", append(
|
||||
[]string{"net.ipv4.conf.all.rp_filter", "net.ipv4.conf.default.rp_filter"},
|
||||
listInterfaceSysctls("ipv4", "rp_filter")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "src_valid_mark", append(
|
||||
[]string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"},
|
||||
listInterfaceSysctls("ipv4", "src_valid_mark")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "conntrack", []string{
|
||||
"net.netfilter.nf_conntrack_acct",
|
||||
"net.netfilter.nf_conntrack_tcp_loose",
|
||||
})
|
||||
writeSysctlGroup(&builder, "tcp", []string{
|
||||
"net.ipv4.tcp_tw_reuse",
|
||||
})
|
||||
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func writeSysctlGroup(builder *strings.Builder, title string, keys []string) {
|
||||
builder.WriteString(fmt.Sprintf("=== %s ===\n", title))
|
||||
for _, key := range keys {
|
||||
value, err := readSysctl(key)
|
||||
if err != nil {
|
||||
builder.WriteString(fmt.Sprintf("%s = <error: %v>\n", key, err))
|
||||
continue
|
||||
}
|
||||
builder.WriteString(fmt.Sprintf("%s = %s\n", key, value))
|
||||
}
|
||||
builder.WriteString("\n")
|
||||
}
|
||||
|
||||
// listInterfaceSysctls returns net.ipvX.conf.<iface>.<leaf> keys for every
|
||||
// interface present in /proc/sys/net/ipvX/conf, skipping "all" and "default"
|
||||
// (callers add those explicitly so they appear first).
|
||||
func listInterfaceSysctls(family, leaf string) []string {
|
||||
dir := fmt.Sprintf("/proc/sys/net/%s/conf", family)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if name == "all" || name == "default" {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, fmt.Sprintf("net.%s.conf.%s.%s", family, name, leaf))
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func readSysctl(key string) (string, error) {
|
||||
path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/"))
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(value)), nil
|
||||
}
|
||||
|
||||
@@ -17,3 +17,8 @@ func (g *BundleGenerator) addIPRules() error {
|
||||
// IP rules are only supported on Linux
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *BundleGenerator) addSysctls() error {
|
||||
// Sysctl collection is only supported on Linux
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -862,6 +862,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
RosenpassEnabled: true,
|
||||
RosenpassPermissive: true,
|
||||
ServerSSHAllowed: &bTrue,
|
||||
ServerVNCAllowed: &bTrue,
|
||||
EnableSSHRoot: &bTrue,
|
||||
EnableSSHSFTP: &bTrue,
|
||||
EnableSSHLocalPortForwarding: &bTrue,
|
||||
|
||||
@@ -16,6 +16,10 @@ type hostManager interface {
|
||||
restoreHostDNS() error
|
||||
supportCustomPort() bool
|
||||
string() string
|
||||
// getOriginalNameservers returns the OS-side resolvers used as PriorityFallback
|
||||
// upstreams: pre-takeover snapshots on desktop, the OS-pushed list on Android,
|
||||
// hardcoded Quad9 on iOS, nil for noop / mock.
|
||||
getOriginalNameservers() []netip.Addr
|
||||
}
|
||||
|
||||
type SystemDNSSettings struct {
|
||||
@@ -131,3 +135,11 @@ func (n noopHostConfigurator) supportCustomPort() bool {
|
||||
func (n noopHostConfigurator) string() string {
|
||||
return "noop"
|
||||
}
|
||||
|
||||
func (n noopHostConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHostConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
)
|
||||
|
||||
// androidHostManager is a noop on the OS side (Android's VPN service handles
|
||||
// DNS for us) but tracks the OS-reported resolver list pushed via
|
||||
// OnUpdatedHostDNSServer so it can serve as the fallback nameserver source.
|
||||
type androidHostManager struct {
|
||||
holder *hostsDNSHolder
|
||||
}
|
||||
|
||||
func newHostManager() (*androidHostManager, error) {
|
||||
return &androidHostManager{}, nil
|
||||
func newHostManager(holder *hostsDNSHolder) (*androidHostManager, error) {
|
||||
return &androidHostManager{holder: holder}, nil
|
||||
}
|
||||
|
||||
func (a androidHostManager) applyDNSConfig(HostDNSConfig, *statemanager.Manager) error {
|
||||
@@ -26,3 +32,12 @@ func (a androidHostManager) supportCustomPort() bool {
|
||||
func (a androidHostManager) string() string {
|
||||
return "none"
|
||||
}
|
||||
|
||||
func (a androidHostManager) getOriginalNameservers() []netip.Addr {
|
||||
hosts := a.holder.get()
|
||||
out := make([]netip.Addr, 0, len(hosts))
|
||||
for ap := range hosts {
|
||||
out = append(out, ap.Addr())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package dns
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -20,6 +21,14 @@ func newHostManager(dnsManager IosDnsManager) (*iosHostManager, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a iosHostManager) getOriginalNameservers() []netip.Addr {
|
||||
// Quad9 v4+v6: 9.9.9.9, 2620:fe::fe.
|
||||
return []netip.Addr{
|
||||
netip.AddrFrom4([4]byte{9, 9, 9, 9}),
|
||||
netip.AddrFrom16([16]byte{0x26, 0x20, 0x00, 0xfe, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xfe}),
|
||||
}
|
||||
}
|
||||
|
||||
func (a iosHostManager) applyDNSConfig(config HostDNSConfig, _ *statemanager.Manager) error {
|
||||
jsonData, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"io"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
@@ -44,9 +45,11 @@ const (
|
||||
|
||||
nrptMaxDomainsPerRule = 50
|
||||
|
||||
interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
interfaceConfigNameServerKey = "NameServer"
|
||||
interfaceConfigSearchListKey = "SearchList"
|
||||
interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
|
||||
interfaceConfigNameServerKey = "NameServer"
|
||||
interfaceConfigDhcpNameSrvKey = "DhcpNameServer"
|
||||
interfaceConfigSearchListKey = "SearchList"
|
||||
|
||||
// Network interface DNS registration settings
|
||||
disableDynamicUpdateKey = "DisableDynamicUpdate"
|
||||
@@ -67,10 +70,11 @@ const (
|
||||
)
|
||||
|
||||
type registryConfigurator struct {
|
||||
guid string
|
||||
routingAll bool
|
||||
gpo bool
|
||||
nrptEntryCount int
|
||||
guid string
|
||||
routingAll bool
|
||||
gpo bool
|
||||
nrptEntryCount int
|
||||
origNameservers []netip.Addr
|
||||
}
|
||||
|
||||
func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
@@ -94,6 +98,17 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
gpo: useGPO,
|
||||
}
|
||||
|
||||
origNameservers, err := configurator.captureOriginalNameservers()
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Warnf("capture original nameservers from non-WG adapters: %v", err)
|
||||
case len(origNameservers) == 0:
|
||||
log.Warnf("no original nameservers captured from non-WG adapters; DNS fallback will be empty")
|
||||
default:
|
||||
log.Debugf("captured %d original nameservers from non-WG adapters: %v", len(origNameservers), origNameservers)
|
||||
}
|
||||
configurator.origNameservers = origNameservers
|
||||
|
||||
if err := configurator.configureInterface(); err != nil {
|
||||
log.Errorf("failed to configure interface settings: %v", err)
|
||||
}
|
||||
@@ -101,6 +116,98 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
return configurator, nil
|
||||
}
|
||||
|
||||
// captureOriginalNameservers reads DNS addresses from every Tcpip(6) interface
|
||||
// registry key except the WG adapter. v4 and v6 servers live in separate
|
||||
// hives (Tcpip vs Tcpip6) keyed by the same interface GUID.
|
||||
func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error) {
|
||||
seen := make(map[netip.Addr]struct{})
|
||||
var out []netip.Addr
|
||||
var merr *multierror.Error
|
||||
for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} {
|
||||
addrs, err := r.captureFromTcpipRoot(root)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err))
|
||||
continue
|
||||
}
|
||||
for _, addr := range addrs {
|
||||
if _, dup := seen[addr]; dup {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
out = append(out, addr)
|
||||
}
|
||||
}
|
||||
return out, nberrors.FormatErrorOrNil(merr)
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) captureFromTcpipRoot(rootPath string) ([]netip.Addr, error) {
|
||||
root, err := registry.OpenKey(registry.LOCAL_MACHINE, rootPath, registry.READ)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open key: %w", err)
|
||||
}
|
||||
defer closer(root)
|
||||
|
||||
guids, err := root.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read subkeys: %w", err)
|
||||
}
|
||||
|
||||
var out []netip.Addr
|
||||
for _, guid := range guids {
|
||||
if strings.EqualFold(guid, r.guid) {
|
||||
continue
|
||||
}
|
||||
out = append(out, readInterfaceNameservers(rootPath, guid)...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readInterfaceNameservers(rootPath, guid string) []netip.Addr {
|
||||
keyPath := rootPath + "\\" + guid
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, keyPath, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer closer(k)
|
||||
|
||||
// Static NameServer wins over DhcpNameServer for actual resolution.
|
||||
for _, name := range []string{interfaceConfigNameServerKey, interfaceConfigDhcpNameSrvKey} {
|
||||
raw, _, err := k.GetStringValue(name)
|
||||
if err != nil || raw == "" {
|
||||
continue
|
||||
}
|
||||
if out := parseRegistryNameservers(raw); len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRegistryNameservers(raw string) []netip.Addr {
|
||||
var out []netip.Addr
|
||||
for _, field := range strings.FieldsFunc(raw, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' }) {
|
||||
addr, err := netip.ParseAddr(strings.TrimSpace(field))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() || addr.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
// Drop unzoned link-local: not routable without a scope id. If
|
||||
// the user wrote "fe80::1%eth0" ParseAddr preserves the zone.
|
||||
if addr.IsLinkLocalUnicast() && addr.Zone() == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
return slices.Clone(r.origNameservers)
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) supportCustomPort() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func (h *hostsDNSHolder) set(list []netip.AddrPort) {
|
||||
h.mutex.Unlock()
|
||||
}
|
||||
|
||||
//nolint:unused
|
||||
func (h *hostsDNSHolder) get() map[netip.AddrPort]struct{} {
|
||||
h.mutex.RLock()
|
||||
l := h.unprotectedDNSList
|
||||
|
||||
@@ -76,8 +76,6 @@ func (d *Resolver) ID() types.HandlerID {
|
||||
return "local-resolver"
|
||||
}
|
||||
|
||||
func (d *Resolver) ProbeAvailability(context.Context) {}
|
||||
|
||||
// ServeDNS handles a DNS request
|
||||
func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
logger := log.WithFields(log.Fields{
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
@@ -70,10 +71,6 @@ func (m *MockServer) SearchDomains() []string {
|
||||
return make([]string, 0)
|
||||
}
|
||||
|
||||
// ProbeAvailability mocks implementation of ProbeAvailability from the Server interface
|
||||
func (m *MockServer) ProbeAvailability() {
|
||||
}
|
||||
|
||||
func (m *MockServer) UpdateServerConfig(domains dnsconfig.ServerDomains) error {
|
||||
if m.UpdateServerConfigFunc != nil {
|
||||
return m.UpdateServerConfigFunc(domains)
|
||||
@@ -85,8 +82,8 @@ func (m *MockServer) PopulateManagementDomain(mgmtURL *url.URL) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRouteChecker mock implementation of SetRouteChecker from Server interface
|
||||
func (m *MockServer) SetRouteChecker(func(netip.Addr) bool) {
|
||||
// SetRouteSources mock implementation of SetRouteSources from Server interface
|
||||
func (m *MockServer) SetRouteSources(selected, active func() route.HAMap) {
|
||||
// Mock implementation - no-op
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -32,6 +33,15 @@ const (
|
||||
networkManagerDbusDeviceGetAppliedConnectionMethod = networkManagerDbusDeviceInterface + ".GetAppliedConnection"
|
||||
networkManagerDbusDeviceReapplyMethod = networkManagerDbusDeviceInterface + ".Reapply"
|
||||
networkManagerDbusDeviceDeleteMethod = networkManagerDbusDeviceInterface + ".Delete"
|
||||
networkManagerDbusDeviceIp4ConfigProperty = networkManagerDbusDeviceInterface + ".Ip4Config"
|
||||
networkManagerDbusDeviceIp6ConfigProperty = networkManagerDbusDeviceInterface + ".Ip6Config"
|
||||
networkManagerDbusDeviceIfaceProperty = networkManagerDbusDeviceInterface + ".Interface"
|
||||
networkManagerDbusGetDevicesMethod = networkManagerDest + ".GetDevices"
|
||||
networkManagerDbusIp4ConfigInterface = "org.freedesktop.NetworkManager.IP4Config"
|
||||
networkManagerDbusIp6ConfigInterface = "org.freedesktop.NetworkManager.IP6Config"
|
||||
networkManagerDbusIp4ConfigNameserverDataProperty = networkManagerDbusIp4ConfigInterface + ".NameserverData"
|
||||
networkManagerDbusIp4ConfigNameserversProperty = networkManagerDbusIp4ConfigInterface + ".Nameservers"
|
||||
networkManagerDbusIp6ConfigNameserversProperty = networkManagerDbusIp6ConfigInterface + ".Nameservers"
|
||||
networkManagerDbusDefaultBehaviorFlag networkManagerConfigBehavior = 0
|
||||
networkManagerDbusIPv4Key = "ipv4"
|
||||
networkManagerDbusIPv6Key = "ipv6"
|
||||
@@ -51,9 +61,10 @@ var supportedNetworkManagerVersionConstraints = []string{
|
||||
}
|
||||
|
||||
type networkManagerDbusConfigurator struct {
|
||||
dbusLinkObject dbus.ObjectPath
|
||||
routingAll bool
|
||||
ifaceName string
|
||||
dbusLinkObject dbus.ObjectPath
|
||||
routingAll bool
|
||||
ifaceName string
|
||||
origNameservers []netip.Addr
|
||||
}
|
||||
|
||||
// the types below are based on dbus specification, each field is mapped to a dbus type
|
||||
@@ -92,10 +103,200 @@ func newNetworkManagerDbusConfigurator(wgInterface string) (*networkManagerDbusC
|
||||
|
||||
log.Debugf("got network manager dbus Link Object: %s from net interface %s", s, wgInterface)
|
||||
|
||||
return &networkManagerDbusConfigurator{
|
||||
c := &networkManagerDbusConfigurator{
|
||||
dbusLinkObject: dbus.ObjectPath(s),
|
||||
ifaceName: wgInterface,
|
||||
}, nil
|
||||
}
|
||||
|
||||
origNameservers, err := c.captureOriginalNameservers()
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Warnf("capture original nameservers from NetworkManager: %v", err)
|
||||
case len(origNameservers) == 0:
|
||||
log.Warnf("no original nameservers captured from non-WG NetworkManager devices; DNS fallback will be empty")
|
||||
default:
|
||||
log.Debugf("captured %d original nameservers from non-WG NetworkManager devices: %v", len(origNameservers), origNameservers)
|
||||
}
|
||||
c.origNameservers = origNameservers
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// captureOriginalNameservers reads DNS servers from every NM device's
|
||||
// IP4Config / IP6Config except our WG device.
|
||||
func (n *networkManagerDbusConfigurator) captureOriginalNameservers() ([]netip.Addr, error) {
|
||||
devices, err := networkManagerListDevices()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list devices: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[netip.Addr]struct{})
|
||||
var out []netip.Addr
|
||||
for _, dev := range devices {
|
||||
if dev == n.dbusLinkObject {
|
||||
continue
|
||||
}
|
||||
ifaceName := readNetworkManagerDeviceInterface(dev)
|
||||
for _, addr := range readNetworkManagerDeviceDNS(dev) {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() || addr.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
// IP6Config.Nameservers is a byte slice without zone info;
|
||||
// reattach the device's interface name so a captured fe80::…
|
||||
// stays routable.
|
||||
if addr.IsLinkLocalUnicast() && ifaceName != "" {
|
||||
addr = addr.WithZone(ifaceName)
|
||||
}
|
||||
if _, dup := seen[addr]; dup {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
out = append(out, addr)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readNetworkManagerDeviceInterface(devicePath dbus.ObjectPath) string {
|
||||
obj, closeConn, err := getDbusObject(networkManagerDest, devicePath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer closeConn()
|
||||
v, err := obj.GetProperty(networkManagerDbusDeviceIfaceProperty)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
s, _ := v.Value().(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func networkManagerListDevices() ([]dbus.ObjectPath, error) {
|
||||
obj, closeConn, err := getDbusObject(networkManagerDest, networkManagerDbusObjectNode)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dbus NetworkManager: %w", err)
|
||||
}
|
||||
defer closeConn()
|
||||
var devs []dbus.ObjectPath
|
||||
if err := obj.Call(networkManagerDbusGetDevicesMethod, dbusDefaultFlag).Store(&devs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return devs, nil
|
||||
}
|
||||
|
||||
func readNetworkManagerDeviceDNS(devicePath dbus.ObjectPath) []netip.Addr {
|
||||
obj, closeConn, err := getDbusObject(networkManagerDest, devicePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer closeConn()
|
||||
|
||||
var out []netip.Addr
|
||||
if path := readNetworkManagerConfigPath(obj, networkManagerDbusDeviceIp4ConfigProperty); path != "" {
|
||||
out = append(out, readIPv4ConfigDNS(path)...)
|
||||
}
|
||||
if path := readNetworkManagerConfigPath(obj, networkManagerDbusDeviceIp6ConfigProperty); path != "" {
|
||||
out = append(out, readIPv6ConfigDNS(path)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readNetworkManagerConfigPath(obj dbus.BusObject, property string) dbus.ObjectPath {
|
||||
v, err := obj.GetProperty(property)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
path, ok := v.Value().(dbus.ObjectPath)
|
||||
if !ok || path == "/" {
|
||||
return ""
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func readIPv4ConfigDNS(path dbus.ObjectPath) []netip.Addr {
|
||||
obj, closeConn, err := getDbusObject(networkManagerDest, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer closeConn()
|
||||
|
||||
// NameserverData (NM 1.13+) carries strings; older NMs only expose the
|
||||
// legacy uint32 Nameservers property.
|
||||
if out := readIPv4NameserverData(obj); len(out) > 0 {
|
||||
return out
|
||||
}
|
||||
return readIPv4LegacyNameservers(obj)
|
||||
}
|
||||
|
||||
func readIPv4NameserverData(obj dbus.BusObject) []netip.Addr {
|
||||
v, err := obj.GetProperty(networkManagerDbusIp4ConfigNameserverDataProperty)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
entries, ok := v.Value().([]map[string]dbus.Variant)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []netip.Addr
|
||||
for _, entry := range entries {
|
||||
addrVar, ok := entry["address"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
s, ok := addrVar.Value().(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if a, err := netip.ParseAddr(s); err == nil {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readIPv4LegacyNameservers(obj dbus.BusObject) []netip.Addr {
|
||||
v, err := obj.GetProperty(networkManagerDbusIp4ConfigNameserversProperty)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := v.Value().([]uint32)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]netip.Addr, 0, len(raw))
|
||||
for _, n := range raw {
|
||||
var b [4]byte
|
||||
binary.LittleEndian.PutUint32(b[:], n)
|
||||
out = append(out, netip.AddrFrom4(b))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readIPv6ConfigDNS(path dbus.ObjectPath) []netip.Addr {
|
||||
obj, closeConn, err := getDbusObject(networkManagerDest, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer closeConn()
|
||||
v, err := obj.GetProperty(networkManagerDbusIp6ConfigNameserversProperty)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := v.Value().([][]byte)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]netip.Addr, 0, len(raw))
|
||||
for _, b := range raw {
|
||||
if a, ok := netip.AddrFromSlice(b); ok {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (n *networkManagerDbusConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
return slices.Clone(n.origNameservers)
|
||||
}
|
||||
|
||||
func (n *networkManagerDbusConfigurator) supportCustomPort() bool {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
package dns
|
||||
|
||||
func (s *DefaultServer) initialize() (manager hostManager, err error) {
|
||||
return newHostManager()
|
||||
return newHostManager(s.hostsDNSHolder)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -31,8 +32,10 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/statemanager"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbdns "github.com/netbirdio/netbird/dns"
|
||||
"github.com/netbirdio/netbird/formatter"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
@@ -101,16 +104,17 @@ func init() {
|
||||
formatter.SetTextFormatter(log.StandardLogger())
|
||||
}
|
||||
|
||||
func generateDummyHandler(domain string, servers []nbdns.NameServer) *upstreamResolverBase {
|
||||
func generateDummyHandler(d string, servers []nbdns.NameServer) *upstreamResolverBase {
|
||||
var srvs []netip.AddrPort
|
||||
for _, srv := range servers {
|
||||
srvs = append(srvs, srv.AddrPort())
|
||||
}
|
||||
return &upstreamResolverBase{
|
||||
domain: domain,
|
||||
upstreamServers: srvs,
|
||||
cancel: func() {},
|
||||
u := &upstreamResolverBase{
|
||||
domain: domain.Domain(d),
|
||||
cancel: func() {},
|
||||
}
|
||||
u.addRace(srvs)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestUpdateDNSServer(t *testing.T) {
|
||||
@@ -653,74 +657,8 @@ func TestDNSServerStartStop(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSServerUpstreamDeactivateCallback(t *testing.T) {
|
||||
hostManager := &mockHostConfigurator{}
|
||||
server := DefaultServer{
|
||||
ctx: context.Background(),
|
||||
service: NewServiceViaMemory(&mocWGIface{}),
|
||||
localResolver: local.NewResolver(),
|
||||
handlerChain: NewHandlerChain(),
|
||||
hostManager: hostManager,
|
||||
currentConfig: HostDNSConfig{
|
||||
Domains: []DomainConfig{
|
||||
{false, "domain0", false},
|
||||
{false, "domain1", false},
|
||||
{false, "domain2", false},
|
||||
},
|
||||
},
|
||||
statusRecorder: peer.NewRecorder("mgm"),
|
||||
}
|
||||
|
||||
var domainsUpdate string
|
||||
hostManager.applyDNSConfigFunc = func(config HostDNSConfig, statemanager *statemanager.Manager) error {
|
||||
domains := []string{}
|
||||
for _, item := range config.Domains {
|
||||
if item.Disabled {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, item.Domain)
|
||||
}
|
||||
domainsUpdate = strings.Join(domains, ",")
|
||||
return nil
|
||||
}
|
||||
|
||||
deactivate, reactivate := server.upstreamCallbacks(&nbdns.NameServerGroup{
|
||||
Domains: []string{"domain1"},
|
||||
NameServers: []nbdns.NameServer{
|
||||
{IP: netip.MustParseAddr("8.8.0.0"), NSType: nbdns.UDPNameServerType, Port: 53},
|
||||
},
|
||||
}, nil, 0)
|
||||
|
||||
deactivate(nil)
|
||||
expected := "domain0,domain2"
|
||||
domains := []string{}
|
||||
for _, item := range server.currentConfig.Domains {
|
||||
if item.Disabled {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, item.Domain)
|
||||
}
|
||||
got := strings.Join(domains, ",")
|
||||
if expected != got {
|
||||
t.Errorf("expected domains list: %q, got %q", expected, got)
|
||||
}
|
||||
|
||||
reactivate()
|
||||
expected = "domain0,domain1,domain2"
|
||||
domains = []string{}
|
||||
for _, item := range server.currentConfig.Domains {
|
||||
if item.Disabled {
|
||||
continue
|
||||
}
|
||||
domains = append(domains, item.Domain)
|
||||
}
|
||||
got = strings.Join(domains, ",")
|
||||
if expected != got {
|
||||
t.Errorf("expected domains list: %q, got %q", expected, domainsUpdate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSPermanent_updateHostDNS_emptyUpstream(t *testing.T) {
|
||||
skipUnlessAndroid(t)
|
||||
wgIFace, err := createWgInterfaceWithBind(t)
|
||||
if err != nil {
|
||||
t.Fatal("failed to initialize wg interface")
|
||||
@@ -748,6 +686,7 @@ func TestDNSPermanent_updateHostDNS_emptyUpstream(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDNSPermanent_updateUpstream(t *testing.T) {
|
||||
skipUnlessAndroid(t)
|
||||
wgIFace, err := createWgInterfaceWithBind(t)
|
||||
if err != nil {
|
||||
t.Fatal("failed to initialize wg interface")
|
||||
@@ -841,6 +780,7 @@ func TestDNSPermanent_updateUpstream(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDNSPermanent_matchOnly(t *testing.T) {
|
||||
skipUnlessAndroid(t)
|
||||
wgIFace, err := createWgInterfaceWithBind(t)
|
||||
if err != nil {
|
||||
t.Fatal("failed to initialize wg interface")
|
||||
@@ -913,6 +853,18 @@ func TestDNSPermanent_matchOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// skipUnlessAndroid marks tests that exercise the mobile-permanent DNS path,
|
||||
// which only matches a real production setup on android (NewDefaultServerPermanentUpstream
|
||||
// + androidHostManager). On non-android the desktop host manager replaces it
|
||||
// during Initialize and the assertion stops making sense. Skipped here until we
|
||||
// have an android CI runner.
|
||||
func skipUnlessAndroid(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS != "android" {
|
||||
t.Skip("requires android runner; mobile-permanent path doesn't match production on this OS")
|
||||
}
|
||||
}
|
||||
|
||||
func createWgInterfaceWithBind(t *testing.T) (*iface.WGIface, error) {
|
||||
t.Helper()
|
||||
ov := os.Getenv("NB_WG_KERNEL_DISABLED")
|
||||
@@ -1065,7 +1017,6 @@ type mockHandler struct {
|
||||
|
||||
func (m *mockHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {}
|
||||
func (m *mockHandler) Stop() {}
|
||||
func (m *mockHandler) ProbeAvailability(context.Context) {}
|
||||
func (m *mockHandler) ID() types.HandlerID { return types.HandlerID(m.Id) }
|
||||
|
||||
type mockService struct{}
|
||||
@@ -2085,6 +2036,598 @@ func TestLocalResolverPriorityConstants(t *testing.T) {
|
||||
assert.Equal(t, "local.example.com", localMuxUpdates[0].domain)
|
||||
}
|
||||
|
||||
// TestBuildUpstreamHandler_MergesGroupsPerDomain verifies that multiple
|
||||
// admin-defined nameserver groups targeting the same domain collapse into a
|
||||
// single handler with each group preserved as a sequential inner list.
|
||||
func TestBuildUpstreamHandler_MergesGroupsPerDomain(t *testing.T) {
|
||||
wgInterface := &mocWGIface{}
|
||||
service := NewServiceViaMemory(wgInterface)
|
||||
server := &DefaultServer{
|
||||
ctx: context.Background(),
|
||||
wgInterface: wgInterface,
|
||||
service: service,
|
||||
localResolver: local.NewResolver(),
|
||||
handlerChain: NewHandlerChain(),
|
||||
hostManager: &noopHostConfigurator{},
|
||||
dnsMuxMap: make(registeredHandlerMap),
|
||||
}
|
||||
|
||||
groups := []*nbdns.NameServerGroup{
|
||||
{
|
||||
NameServers: []nbdns.NameServer{
|
||||
{IP: netip.MustParseAddr("192.0.2.1"), NSType: nbdns.UDPNameServerType, Port: 53},
|
||||
},
|
||||
Domains: []string{"example.com"},
|
||||
},
|
||||
{
|
||||
NameServers: []nbdns.NameServer{
|
||||
{IP: netip.MustParseAddr("192.0.2.2"), NSType: nbdns.UDPNameServerType, Port: 53},
|
||||
{IP: netip.MustParseAddr("192.0.2.3"), NSType: nbdns.UDPNameServerType, Port: 53},
|
||||
},
|
||||
Domains: []string{"example.com"},
|
||||
},
|
||||
}
|
||||
|
||||
muxUpdates, err := server.buildUpstreamHandlerUpdate(groups)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, muxUpdates, 1, "same-domain groups should merge into one handler")
|
||||
assert.Equal(t, "example.com", muxUpdates[0].domain)
|
||||
assert.Equal(t, PriorityUpstream, muxUpdates[0].priority)
|
||||
|
||||
handler := muxUpdates[0].handler.(*upstreamResolver)
|
||||
require.Len(t, handler.upstreamServers, 2, "handler should have two groups")
|
||||
assert.Equal(t, upstreamRace{netip.MustParseAddrPort("192.0.2.1:53")}, handler.upstreamServers[0])
|
||||
assert.Equal(t, upstreamRace{
|
||||
netip.MustParseAddrPort("192.0.2.2:53"),
|
||||
netip.MustParseAddrPort("192.0.2.3:53"),
|
||||
}, handler.upstreamServers[1])
|
||||
}
|
||||
|
||||
// TestEvaluateNSGroupHealth covers the records-only verdict. The gate
|
||||
// (overlay route selected-but-no-active-peer) is intentionally NOT an
|
||||
// input to the evaluator anymore: the verdict drives the Enabled flag,
|
||||
// which must always reflect what we actually observed. Gate-aware event
|
||||
// suppression is tested separately in the projection test.
|
||||
//
|
||||
// Matrix per upstream: {no record, fresh Ok, fresh Fail, stale Fail,
|
||||
// stale Ok, Ok newer than Fail, Fail newer than Ok}.
|
||||
// Group verdict: any fresh-working → Healthy; any fresh-broken with no
|
||||
// fresh-working → Unhealthy; otherwise Undecided.
|
||||
func TestEvaluateNSGroupHealth(t *testing.T) {
|
||||
now := time.Now()
|
||||
a := netip.MustParseAddrPort("192.0.2.1:53")
|
||||
b := netip.MustParseAddrPort("192.0.2.2:53")
|
||||
|
||||
recentOk := UpstreamHealth{LastOk: now.Add(-2 * time.Second)}
|
||||
recentFail := UpstreamHealth{LastFail: now.Add(-1 * time.Second), LastErr: "timeout"}
|
||||
staleOk := UpstreamHealth{LastOk: now.Add(-10 * time.Minute)}
|
||||
staleFail := UpstreamHealth{LastFail: now.Add(-10 * time.Minute), LastErr: "timeout"}
|
||||
okThenFail := UpstreamHealth{
|
||||
LastOk: now.Add(-10 * time.Second),
|
||||
LastFail: now.Add(-1 * time.Second),
|
||||
LastErr: "timeout",
|
||||
}
|
||||
failThenOk := UpstreamHealth{
|
||||
LastOk: now.Add(-1 * time.Second),
|
||||
LastFail: now.Add(-10 * time.Second),
|
||||
LastErr: "timeout",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
health map[netip.AddrPort]UpstreamHealth
|
||||
servers []netip.AddrPort
|
||||
wantVerdict nsGroupVerdict
|
||||
wantErrSubst string
|
||||
}{
|
||||
{
|
||||
name: "no record, undecided",
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictUndecided,
|
||||
},
|
||||
{
|
||||
name: "fresh success, healthy",
|
||||
health: map[netip.AddrPort]UpstreamHealth{a: recentOk},
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictHealthy,
|
||||
},
|
||||
{
|
||||
name: "fresh failure, unhealthy",
|
||||
health: map[netip.AddrPort]UpstreamHealth{a: recentFail},
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictUnhealthy,
|
||||
wantErrSubst: "timeout",
|
||||
},
|
||||
{
|
||||
name: "only stale success, undecided",
|
||||
health: map[netip.AddrPort]UpstreamHealth{a: staleOk},
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictUndecided,
|
||||
},
|
||||
{
|
||||
name: "only stale failure, undecided",
|
||||
health: map[netip.AddrPort]UpstreamHealth{a: staleFail},
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictUndecided,
|
||||
},
|
||||
{
|
||||
name: "both fresh, fail newer, unhealthy",
|
||||
health: map[netip.AddrPort]UpstreamHealth{a: okThenFail},
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictUnhealthy,
|
||||
wantErrSubst: "timeout",
|
||||
},
|
||||
{
|
||||
name: "both fresh, ok newer, healthy",
|
||||
health: map[netip.AddrPort]UpstreamHealth{a: failThenOk},
|
||||
servers: []netip.AddrPort{a},
|
||||
wantVerdict: nsVerdictHealthy,
|
||||
},
|
||||
{
|
||||
name: "two upstreams, one success wins",
|
||||
health: map[netip.AddrPort]UpstreamHealth{
|
||||
a: recentFail,
|
||||
b: recentOk,
|
||||
},
|
||||
servers: []netip.AddrPort{a, b},
|
||||
wantVerdict: nsVerdictHealthy,
|
||||
},
|
||||
{
|
||||
name: "two upstreams, one fail one unseen, unhealthy",
|
||||
health: map[netip.AddrPort]UpstreamHealth{
|
||||
a: recentFail,
|
||||
},
|
||||
servers: []netip.AddrPort{a, b},
|
||||
wantVerdict: nsVerdictUnhealthy,
|
||||
wantErrSubst: "timeout",
|
||||
},
|
||||
{
|
||||
name: "two upstreams, all recent failures, unhealthy",
|
||||
health: map[netip.AddrPort]UpstreamHealth{
|
||||
a: {LastFail: now.Add(-5 * time.Second), LastErr: "timeout"},
|
||||
b: {LastFail: now.Add(-1 * time.Second), LastErr: "SERVFAIL"},
|
||||
},
|
||||
servers: []netip.AddrPort{a, b},
|
||||
wantVerdict: nsVerdictUnhealthy,
|
||||
wantErrSubst: "SERVFAIL",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
verdict, err := evaluateNSGroupHealth(tc.health, tc.servers, now)
|
||||
assert.Equal(t, tc.wantVerdict, verdict, "verdict mismatch")
|
||||
if tc.wantErrSubst != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErrSubst)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// healthStubHandler is a minimal dnsMuxMap entry that exposes a fixed
|
||||
// UpstreamHealth snapshot, letting tests drive recomputeNSGroupStates
|
||||
// without spinning up real handlers.
|
||||
type healthStubHandler struct {
|
||||
health map[netip.AddrPort]UpstreamHealth
|
||||
}
|
||||
|
||||
func (h *healthStubHandler) ServeDNS(dns.ResponseWriter, *dns.Msg) {}
|
||||
func (h *healthStubHandler) Stop() {}
|
||||
func (h *healthStubHandler) ID() types.HandlerID { return "health-stub" }
|
||||
func (h *healthStubHandler) UpstreamHealth() map[netip.AddrPort]UpstreamHealth {
|
||||
return h.health
|
||||
}
|
||||
|
||||
// TestProjection_SteadyStateIsSilent guards against duplicate events:
|
||||
// while a group stays Unhealthy tick after tick, only the first
|
||||
// Unhealthy transition may emit. Same for staying Healthy.
|
||||
func TestProjection_SteadyStateIsSilent(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectEvent("unreachable", "first fail emits warning")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.tick()
|
||||
fx.expectNoEvent("staying unhealthy must not re-emit")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
fx.tick()
|
||||
fx.expectEvent("recovered", "recovery on transition")
|
||||
|
||||
fx.tick()
|
||||
fx.tick()
|
||||
fx.expectNoEvent("staying healthy must not re-emit")
|
||||
}
|
||||
|
||||
// projTestFixture is the common setup for the projection tests: a
|
||||
// single-upstream group whose route classification the test can flip by
|
||||
// assigning to selected/active. Callers drive failures/successes by
|
||||
// mutating stub.health and calling refreshHealth.
|
||||
type projTestFixture struct {
|
||||
t *testing.T
|
||||
recorder *peer.Status
|
||||
events <-chan *proto.SystemEvent
|
||||
server *DefaultServer
|
||||
stub *healthStubHandler
|
||||
group *nbdns.NameServerGroup
|
||||
srv netip.AddrPort
|
||||
selected route.HAMap
|
||||
active route.HAMap
|
||||
}
|
||||
|
||||
func newProjTestFixture(t *testing.T) *projTestFixture {
|
||||
t.Helper()
|
||||
recorder := peer.NewRecorder("mgm")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
t.Cleanup(func() { recorder.UnsubscribeFromEvents(sub) })
|
||||
|
||||
srv := netip.MustParseAddrPort("100.64.0.1:53")
|
||||
fx := &projTestFixture{
|
||||
t: t,
|
||||
recorder: recorder,
|
||||
events: sub.Events(),
|
||||
stub: &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{}},
|
||||
srv: srv,
|
||||
group: &nbdns.NameServerGroup{
|
||||
Domains: []string{"example.com"},
|
||||
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
|
||||
},
|
||||
}
|
||||
fx.server = &DefaultServer{
|
||||
ctx: context.Background(),
|
||||
wgInterface: &mocWGIface{},
|
||||
statusRecorder: recorder,
|
||||
dnsMuxMap: make(registeredHandlerMap),
|
||||
selectedRoutes: func() route.HAMap { return fx.selected },
|
||||
activeRoutes: func() route.HAMap { return fx.active },
|
||||
warningDelayBase: defaultWarningDelayBase,
|
||||
}
|
||||
fx.server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: fx.stub, priority: PriorityUpstream}
|
||||
|
||||
fx.server.mux.Lock()
|
||||
fx.server.updateNSGroupStates([]*nbdns.NameServerGroup{fx.group})
|
||||
fx.server.mux.Unlock()
|
||||
return fx
|
||||
}
|
||||
|
||||
func (f *projTestFixture) setHealth(h UpstreamHealth) {
|
||||
f.stub.health = map[netip.AddrPort]UpstreamHealth{f.srv: h}
|
||||
}
|
||||
|
||||
func (f *projTestFixture) tick() []peer.NSGroupState {
|
||||
f.server.refreshHealth()
|
||||
return f.recorder.GetDNSStates()
|
||||
}
|
||||
|
||||
func (f *projTestFixture) expectNoEvent(why string) {
|
||||
f.t.Helper()
|
||||
select {
|
||||
case evt := <-f.events:
|
||||
f.t.Fatalf("unexpected event (%s): %+v", why, evt)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func (f *projTestFixture) expectEvent(substr, why string) *proto.SystemEvent {
|
||||
f.t.Helper()
|
||||
select {
|
||||
case evt := <-f.events:
|
||||
assert.Contains(f.t, evt.Message, substr, why)
|
||||
return evt
|
||||
case <-time.After(time.Second):
|
||||
f.t.Fatalf("expected event (%s) with %q", why, substr)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var overlayNetForTest = netip.MustParsePrefix("100.64.0.0/16")
|
||||
var overlayMapForTest = route.HAMap{"overlay": {{Network: overlayNetForTest}}}
|
||||
|
||||
// TestProjection_PublicFailEmitsImmediately covers rule 1: an upstream
|
||||
// that is not inside any selected route (public DNS) fires the warning
|
||||
// on the first Unhealthy tick, no grace period.
|
||||
func TestProjection_PublicFailEmitsImmediately(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
states := fx.tick()
|
||||
require.Len(t, states, 1)
|
||||
assert.False(t, states[0].Enabled)
|
||||
fx.expectEvent("unreachable", "public DNS failure")
|
||||
}
|
||||
|
||||
// TestProjection_OverlayConnectedFailEmitsImmediately covers rule 2:
|
||||
// the upstream is inside a selected route AND the route has a Connected
|
||||
// peer. Tunnel is up, failure is real, emit immediately.
|
||||
func TestProjection_OverlayConnectedFailEmitsImmediately(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
fx.selected = overlayMapForTest
|
||||
fx.active = overlayMapForTest
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
states := fx.tick()
|
||||
require.Len(t, states, 1)
|
||||
assert.False(t, states[0].Enabled)
|
||||
fx.expectEvent("unreachable", "overlay + connected failure")
|
||||
}
|
||||
|
||||
// TestProjection_OverlayNotConnectedDelaysWarning covers rule 3: the
|
||||
// upstream is routed but no peer is Connected (Connecting/Idle/missing).
|
||||
// First tick: Unhealthy display, no warning. After the grace window
|
||||
// elapses with no recovery, the warning fires.
|
||||
func TestProjection_OverlayNotConnectedDelaysWarning(t *testing.T) {
|
||||
grace := 50 * time.Millisecond
|
||||
fx := newProjTestFixture(t)
|
||||
fx.server.warningDelayBase = grace
|
||||
fx.selected = overlayMapForTest
|
||||
// active stays nil: routed but not connected.
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
states := fx.tick()
|
||||
require.Len(t, states, 1)
|
||||
assert.False(t, states[0].Enabled, "display must reflect failure even during grace window")
|
||||
fx.expectNoEvent("first fail tick within grace window")
|
||||
|
||||
time.Sleep(grace + 10*time.Millisecond)
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectEvent("unreachable", "warning after grace window")
|
||||
}
|
||||
|
||||
// TestProjection_OverlayAddrNoRouteDelaysWarning covers an upstream
|
||||
// whose address is inside the WireGuard overlay range but is not
|
||||
// covered by any selected route (peer-to-peer DNS without an explicit
|
||||
// route). Until a peer reports Connected for that address, startup
|
||||
// failures must be held just like the routed case.
|
||||
func TestProjection_OverlayAddrNoRouteDelaysWarning(t *testing.T) {
|
||||
recorder := peer.NewRecorder("mgm")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
t.Cleanup(func() { recorder.UnsubscribeFromEvents(sub) })
|
||||
|
||||
overlayPeer := netip.MustParseAddrPort("100.66.100.5:53")
|
||||
server := &DefaultServer{
|
||||
ctx: context.Background(),
|
||||
wgInterface: &mocWGIface{},
|
||||
statusRecorder: recorder,
|
||||
dnsMuxMap: make(registeredHandlerMap),
|
||||
selectedRoutes: func() route.HAMap { return nil },
|
||||
activeRoutes: func() route.HAMap { return nil },
|
||||
warningDelayBase: 50 * time.Millisecond,
|
||||
}
|
||||
group := &nbdns.NameServerGroup{
|
||||
Domains: []string{"example.com"},
|
||||
NameServers: []nbdns.NameServer{{IP: overlayPeer.Addr(), NSType: nbdns.UDPNameServerType, Port: int(overlayPeer.Port())}},
|
||||
}
|
||||
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{
|
||||
overlayPeer: {LastFail: time.Now(), LastErr: "timeout"},
|
||||
}}
|
||||
server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
|
||||
|
||||
server.mux.Lock()
|
||||
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
||||
server.mux.Unlock()
|
||||
server.refreshHealth()
|
||||
|
||||
select {
|
||||
case evt := <-sub.Events():
|
||||
t.Fatalf("unexpected event during grace window: %+v", evt)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
stub.health = map[netip.AddrPort]UpstreamHealth{overlayPeer: {LastFail: time.Now(), LastErr: "timeout"}}
|
||||
server.refreshHealth()
|
||||
|
||||
select {
|
||||
case evt := <-sub.Events():
|
||||
assert.Contains(t, evt.Message, "unreachable")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected warning after grace window")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProjection_StopClearsHealthState verifies that Stop wipes the
|
||||
// per-group projection state so a subsequent Start doesn't inherit
|
||||
// sticky flags (notably everHealthy) that would bypass the grace
|
||||
// window during the next peer handshake.
|
||||
func TestProjection_StopClearsHealthState(t *testing.T) {
|
||||
wgIface := &mocWGIface{}
|
||||
server := &DefaultServer{
|
||||
ctx: context.Background(),
|
||||
wgInterface: wgIface,
|
||||
service: NewServiceViaMemory(wgIface),
|
||||
hostManager: &noopHostConfigurator{},
|
||||
extraDomains: map[domain.Domain]int{},
|
||||
dnsMuxMap: make(registeredHandlerMap),
|
||||
statusRecorder: peer.NewRecorder("mgm"),
|
||||
selectedRoutes: func() route.HAMap { return nil },
|
||||
activeRoutes: func() route.HAMap { return nil },
|
||||
warningDelayBase: defaultWarningDelayBase,
|
||||
currentConfigHash: ^uint64(0),
|
||||
}
|
||||
server.ctx, server.ctxCancel = context.WithCancel(context.Background())
|
||||
|
||||
srv := netip.MustParseAddrPort("8.8.8.8:53")
|
||||
group := &nbdns.NameServerGroup{
|
||||
Domains: []string{"example.com"},
|
||||
NameServers: []nbdns.NameServer{{IP: srv.Addr(), NSType: nbdns.UDPNameServerType, Port: int(srv.Port())}},
|
||||
}
|
||||
stub := &healthStubHandler{health: map[netip.AddrPort]UpstreamHealth{srv: {LastOk: time.Now()}}}
|
||||
server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
|
||||
|
||||
server.mux.Lock()
|
||||
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
||||
server.mux.Unlock()
|
||||
server.refreshHealth()
|
||||
|
||||
server.healthProjectMu.Lock()
|
||||
p, ok := server.nsGroupProj[generateGroupKey(group)]
|
||||
server.healthProjectMu.Unlock()
|
||||
require.True(t, ok, "projection state should exist after tick")
|
||||
require.True(t, p.everHealthy, "tick with success must set everHealthy")
|
||||
|
||||
server.Stop()
|
||||
|
||||
server.healthProjectMu.Lock()
|
||||
cleared := server.nsGroupProj == nil
|
||||
server.healthProjectMu.Unlock()
|
||||
assert.True(t, cleared, "Stop must clear nsGroupProj")
|
||||
}
|
||||
|
||||
// TestProjection_OverlayRecoversDuringGrace covers the happy path of
|
||||
// rule 3: startup failures while the peer is handshaking, then the peer
|
||||
// comes up and a query succeeds before the grace window elapses. No
|
||||
// warning should ever have fired, and no recovery either.
|
||||
func TestProjection_OverlayRecoversDuringGrace(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
fx.server.warningDelayBase = 200 * time.Millisecond
|
||||
fx.selected = overlayMapForTest
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectNoEvent("fail within grace, warning suppressed")
|
||||
|
||||
fx.active = overlayMapForTest
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
states := fx.tick()
|
||||
require.Len(t, states, 1)
|
||||
assert.True(t, states[0].Enabled)
|
||||
fx.expectNoEvent("recovery without prior warning must not emit")
|
||||
}
|
||||
|
||||
// TestProjection_RecoveryOnlyAfterWarning enforces the invariant the
|
||||
// whole design leans on: recovery events only appear when a warning
|
||||
// event was actually emitted for the current streak. A Healthy verdict
|
||||
// without a prior warning is silent, so the user never sees "recovered"
|
||||
// out of thin air.
|
||||
func TestProjection_RecoveryOnlyAfterWarning(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
states := fx.tick()
|
||||
require.Len(t, states, 1)
|
||||
assert.True(t, states[0].Enabled)
|
||||
fx.expectNoEvent("first healthy tick should not recover anything")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectEvent("unreachable", "public fail emits immediately")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
fx.tick()
|
||||
fx.expectEvent("recovered", "recovery follows real warning")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectEvent("unreachable", "second cycle warning")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
fx.tick()
|
||||
fx.expectEvent("recovered", "second cycle recovery")
|
||||
}
|
||||
|
||||
// TestProjection_EverHealthyOverridesDelay covers rule 4: once a group
|
||||
// has ever been Healthy, subsequent failures skip the grace window even
|
||||
// if classification says "routed + not connected". The system has
|
||||
// proved it can work, so any new failure is real.
|
||||
func TestProjection_EverHealthyOverridesDelay(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
// Large base so any emission must come from the everHealthy bypass, not elapsed time.
|
||||
fx.server.warningDelayBase = time.Hour
|
||||
fx.selected = overlayMapForTest
|
||||
fx.active = overlayMapForTest
|
||||
|
||||
// Establish "ever healthy".
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
fx.tick()
|
||||
fx.expectNoEvent("first healthy tick")
|
||||
|
||||
// Peer drops. Query fails. Routed + not connected → normally grace,
|
||||
// but everHealthy flag bypasses it.
|
||||
fx.active = nil
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectEvent("unreachable", "failure after ever-healthy must be immediate")
|
||||
}
|
||||
|
||||
// TestProjection_ReconnectBlipEmitsPair covers the explicit tradeoff
|
||||
// from the design discussion: once a group has been healthy, a brief
|
||||
// reconnect that produces a failing tick will fire warning + recovery.
|
||||
// This is by design: user-visible blips are accurate signal, not noise.
|
||||
func TestProjection_ReconnectBlipEmitsPair(t *testing.T) {
|
||||
fx := newProjTestFixture(t)
|
||||
fx.selected = overlayMapForTest
|
||||
fx.active = overlayMapForTest
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
fx.tick()
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastFail: time.Now(), LastErr: "timeout"})
|
||||
fx.tick()
|
||||
fx.expectEvent("unreachable", "blip warning")
|
||||
|
||||
fx.setHealth(UpstreamHealth{LastOk: time.Now()})
|
||||
fx.tick()
|
||||
fx.expectEvent("recovered", "blip recovery")
|
||||
}
|
||||
|
||||
// TestProjection_MixedGroupEmitsImmediately covers the multi-upstream
|
||||
// rule: a group with at least one public upstream is in the "immediate"
|
||||
// category regardless of the other upstreams' routing, because the
|
||||
// public one has no peer-startup excuse. Prevents public-DNS failures
|
||||
// from being hidden behind a routed sibling.
|
||||
func TestProjection_MixedGroupEmitsImmediately(t *testing.T) {
|
||||
recorder := peer.NewRecorder("mgm")
|
||||
sub := recorder.SubscribeToEvents()
|
||||
t.Cleanup(func() { recorder.UnsubscribeFromEvents(sub) })
|
||||
events := sub.Events()
|
||||
|
||||
public := netip.MustParseAddrPort("8.8.8.8:53")
|
||||
overlay := netip.MustParseAddrPort("100.64.0.1:53")
|
||||
overlayMap := route.HAMap{"overlay": {{Network: netip.MustParsePrefix("100.64.0.0/16")}}}
|
||||
|
||||
server := &DefaultServer{
|
||||
ctx: context.Background(),
|
||||
statusRecorder: recorder,
|
||||
dnsMuxMap: make(registeredHandlerMap),
|
||||
selectedRoutes: func() route.HAMap { return overlayMap },
|
||||
activeRoutes: func() route.HAMap { return nil },
|
||||
warningDelayBase: time.Hour,
|
||||
}
|
||||
group := &nbdns.NameServerGroup{
|
||||
Domains: []string{"example.com"},
|
||||
NameServers: []nbdns.NameServer{
|
||||
{IP: public.Addr(), NSType: nbdns.UDPNameServerType, Port: int(public.Port())},
|
||||
{IP: overlay.Addr(), NSType: nbdns.UDPNameServerType, Port: int(overlay.Port())},
|
||||
},
|
||||
}
|
||||
stub := &healthStubHandler{
|
||||
health: map[netip.AddrPort]UpstreamHealth{
|
||||
public: {LastFail: time.Now(), LastErr: "servfail"},
|
||||
overlay: {LastFail: time.Now(), LastErr: "timeout"},
|
||||
},
|
||||
}
|
||||
server.dnsMuxMap["example.com"] = handlerWrapper{domain: "example.com", handler: stub, priority: PriorityUpstream}
|
||||
|
||||
server.mux.Lock()
|
||||
server.updateNSGroupStates([]*nbdns.NameServerGroup{group})
|
||||
server.mux.Unlock()
|
||||
server.refreshHealth()
|
||||
|
||||
select {
|
||||
case evt := <-events:
|
||||
assert.Contains(t, evt.Message, "unreachable")
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("expected immediate warning because group contains a public upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDNSLoopPrevention(t *testing.T) {
|
||||
wgInterface := &mocWGIface{}
|
||||
service := NewServiceViaMemory(wgInterface)
|
||||
@@ -2183,17 +2726,18 @@ func TestDNSLoopPrevention(t *testing.T) {
|
||||
|
||||
if tt.expectedHandlers > 0 {
|
||||
handler := muxUpdates[0].handler.(*upstreamResolver)
|
||||
assert.Len(t, handler.upstreamServers, len(tt.expectedServers))
|
||||
flat := handler.flatUpstreams()
|
||||
assert.Len(t, flat, len(tt.expectedServers))
|
||||
|
||||
if tt.shouldFilterOwnIP {
|
||||
for _, upstream := range handler.upstreamServers {
|
||||
for _, upstream := range flat {
|
||||
assert.NotEqual(t, dnsServerIP, upstream.Addr())
|
||||
}
|
||||
}
|
||||
|
||||
for _, expected := range tt.expectedServers {
|
||||
found := false
|
||||
for _, upstream := range handler.upstreamServers {
|
||||
for _, upstream := range flat {
|
||||
if upstream.Addr() == expected {
|
||||
found = true
|
||||
break
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/godbus/dbus/v5"
|
||||
@@ -40,10 +41,17 @@ const (
|
||||
)
|
||||
|
||||
type systemdDbusConfigurator struct {
|
||||
dbusLinkObject dbus.ObjectPath
|
||||
ifaceName string
|
||||
dbusLinkObject dbus.ObjectPath
|
||||
ifaceName string
|
||||
wgIndex int
|
||||
origNameservers []netip.Addr
|
||||
}
|
||||
|
||||
const (
|
||||
systemdDbusLinkDNSProperty = systemdDbusLinkInterface + ".DNS"
|
||||
systemdDbusLinkDefaultRouteProperty = systemdDbusLinkInterface + ".DefaultRoute"
|
||||
)
|
||||
|
||||
// the types below are based on dbus specification, each field is mapped to a dbus type
|
||||
// see https://dbus.freedesktop.org/doc/dbus-specification.html#basic-types for more details on dbus types
|
||||
// see https://www.freedesktop.org/software/systemd/man/org.freedesktop.resolve1.html on resolve1 input types
|
||||
@@ -79,10 +87,145 @@ func newSystemdDbusConfigurator(wgInterface string) (*systemdDbusConfigurator, e
|
||||
|
||||
log.Debugf("got dbus Link interface: %s from net interface %s and index %d", s, iface.Name, iface.Index)
|
||||
|
||||
return &systemdDbusConfigurator{
|
||||
c := &systemdDbusConfigurator{
|
||||
dbusLinkObject: dbus.ObjectPath(s),
|
||||
ifaceName: wgInterface,
|
||||
}, nil
|
||||
wgIndex: iface.Index,
|
||||
}
|
||||
|
||||
origNameservers, err := c.captureOriginalNameservers()
|
||||
switch {
|
||||
case err != nil:
|
||||
log.Warnf("capture original nameservers from systemd-resolved: %v", err)
|
||||
case len(origNameservers) == 0:
|
||||
log.Warnf("no original nameservers captured from systemd-resolved default-route links; DNS fallback will be empty")
|
||||
default:
|
||||
log.Debugf("captured %d original nameservers from systemd-resolved default-route links: %v", len(origNameservers), origNameservers)
|
||||
}
|
||||
c.origNameservers = origNameservers
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// captureOriginalNameservers reads per-link DNS from systemd-resolved for
|
||||
// every default-route link except our own WG link. Non-default-route links
|
||||
// (VPNs, docker bridges) are skipped because their upstreams wouldn't
|
||||
// actually serve host queries.
|
||||
func (s *systemdDbusConfigurator) captureOriginalNameservers() ([]netip.Addr, error) {
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list interfaces: %w", err)
|
||||
}
|
||||
|
||||
seen := make(map[netip.Addr]struct{})
|
||||
var out []netip.Addr
|
||||
for _, iface := range ifaces {
|
||||
if !s.isCandidateLink(iface) {
|
||||
continue
|
||||
}
|
||||
linkPath, err := getSystemdLinkPath(iface.Index)
|
||||
if err != nil || !isSystemdLinkDefaultRoute(linkPath) {
|
||||
continue
|
||||
}
|
||||
for _, addr := range readSystemdLinkDNS(linkPath) {
|
||||
addr = normalizeSystemdAddr(addr, iface.Name)
|
||||
if !addr.IsValid() {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[addr]; dup {
|
||||
continue
|
||||
}
|
||||
seen[addr] = struct{}{}
|
||||
out = append(out, addr)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *systemdDbusConfigurator) isCandidateLink(iface net.Interface) bool {
|
||||
if iface.Index == s.wgIndex {
|
||||
return false
|
||||
}
|
||||
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// normalizeSystemdAddr unmaps v4-mapped-v6, drops unspecified, and reattaches
|
||||
// the link's iface name as zone for link-local v6 (Link.DNS strips it).
|
||||
// Returns the zero Addr to signal "skip this entry".
|
||||
func normalizeSystemdAddr(addr netip.Addr, ifaceName string) netip.Addr {
|
||||
addr = addr.Unmap()
|
||||
if !addr.IsValid() || addr.IsUnspecified() {
|
||||
return netip.Addr{}
|
||||
}
|
||||
if addr.IsLinkLocalUnicast() {
|
||||
return addr.WithZone(ifaceName)
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
func getSystemdLinkPath(ifIndex int) (dbus.ObjectPath, error) {
|
||||
obj, closeConn, err := getDbusObject(systemdResolvedDest, systemdDbusObjectNode)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("dbus resolve1: %w", err)
|
||||
}
|
||||
defer closeConn()
|
||||
var p string
|
||||
if err := obj.Call(systemdDbusGetLinkMethod, dbusDefaultFlag, int32(ifIndex)).Store(&p); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return dbus.ObjectPath(p), nil
|
||||
}
|
||||
|
||||
func isSystemdLinkDefaultRoute(linkPath dbus.ObjectPath) bool {
|
||||
obj, closeConn, err := getDbusObject(systemdResolvedDest, linkPath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer closeConn()
|
||||
v, err := obj.GetProperty(systemdDbusLinkDefaultRouteProperty)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
b, ok := v.Value().(bool)
|
||||
return ok && b
|
||||
}
|
||||
|
||||
func readSystemdLinkDNS(linkPath dbus.ObjectPath) []netip.Addr {
|
||||
obj, closeConn, err := getDbusObject(systemdResolvedDest, linkPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer closeConn()
|
||||
v, err := obj.GetProperty(systemdDbusLinkDNSProperty)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
entries, ok := v.Value().([][]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []netip.Addr
|
||||
for _, entry := range entries {
|
||||
if len(entry) < 2 {
|
||||
continue
|
||||
}
|
||||
raw, ok := entry[1].([]byte)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
addr, ok := netip.AddrFromSlice(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out = append(out, addr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *systemdDbusConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
return slices.Clone(s.origNameservers)
|
||||
}
|
||||
|
||||
func (s *systemdDbusConfigurator) supportCustomPort() bool {
|
||||
|
||||
@@ -1,3 +1,32 @@
|
||||
// Package dns implements the client-side DNS stack: listener/service on the
|
||||
// peer's tunnel address, handler chain that routes questions by domain and
|
||||
// priority, and upstream resolvers that forward what remains to configured
|
||||
// nameservers.
|
||||
//
|
||||
// # Upstream resolution and the race model
|
||||
//
|
||||
// When two or more nameserver groups target the same domain, DefaultServer
|
||||
// merges them into one upstream handler whose state is:
|
||||
//
|
||||
// upstreamResolverBase
|
||||
// └── upstreamServers []upstreamRace // one entry per source NS group
|
||||
// └── []netip.AddrPort // primary, fallback, ...
|
||||
//
|
||||
// Each source nameserver group contributes one upstreamRace. Within a race
|
||||
// upstreams are tried in order: the next is used only on failure (timeout,
|
||||
// SERVFAIL, REFUSED, no response). NXDOMAIN is a valid answer and stops
|
||||
// the walk. When more than one race exists, ServeDNS fans out one
|
||||
// goroutine per race and returns the first valid answer, cancelling the
|
||||
// rest. A handler with a single race skips the fan-out.
|
||||
//
|
||||
// # Health projection
|
||||
//
|
||||
// Query outcomes are recorded per-upstream in UpstreamHealth. The server
|
||||
// periodically merges these snapshots across handlers and projects them
|
||||
// into peer.NSGroupState. There is no active probing: a group is marked
|
||||
// unhealthy only when every seen upstream has a recent failure and none
|
||||
// has a recent success. Healthy→unhealthy fires a single
|
||||
// SystemEvent_WARNING; steady-state refreshes do not duplicate it.
|
||||
package dns
|
||||
|
||||
import (
|
||||
@@ -11,11 +40,8 @@ import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/cenkalti/backoff/v4"
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/miekg/dns"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
@@ -25,7 +51,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/dns/resutil"
|
||||
"github.com/netbirdio/netbird/client/internal/dns/types"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
var currentMTU uint16 = iface.DefaultMTU
|
||||
@@ -67,15 +94,17 @@ const (
|
||||
// Set longer than UpstreamTimeout to ensure context timeout takes precedence
|
||||
ClientTimeout = 5 * time.Second
|
||||
|
||||
reactivatePeriod = 30 * time.Second
|
||||
probeTimeout = 2 * time.Second
|
||||
|
||||
// ipv6HeaderSize + udpHeaderSize, used to derive the maximum DNS UDP
|
||||
// payload from the tunnel MTU.
|
||||
ipUDPHeaderSize = 60 + 8
|
||||
)
|
||||
|
||||
const testRecord = "com."
|
||||
// raceMaxTotalTimeout caps the combined time spent walking all upstreams
|
||||
// within one race, so a slow primary can't eat the whole race budget.
|
||||
raceMaxTotalTimeout = 5 * time.Second
|
||||
// raceMinPerUpstreamTimeout is the floor applied when dividing
|
||||
// raceMaxTotalTimeout across upstreams within a race.
|
||||
raceMinPerUpstreamTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
const (
|
||||
protoUDP = "udp"
|
||||
@@ -84,6 +113,69 @@ const (
|
||||
|
||||
type dnsProtocolKey struct{}
|
||||
|
||||
type upstreamProtocolKey struct{}
|
||||
|
||||
// upstreamProtocolResult holds the protocol used for the upstream exchange.
|
||||
// Stored as a pointer in context so the exchange function can set it.
|
||||
type upstreamProtocolResult struct {
|
||||
protocol string
|
||||
}
|
||||
|
||||
type upstreamClient interface {
|
||||
exchange(ctx context.Context, upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error)
|
||||
}
|
||||
|
||||
type UpstreamResolver interface {
|
||||
serveDNS(r *dns.Msg) (*dns.Msg, time.Duration, error)
|
||||
upstreamExchange(upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error)
|
||||
}
|
||||
|
||||
// upstreamRace is an ordered list of upstreams derived from one configured
|
||||
// nameserver group. Order matters: the first upstream is tried first, the
|
||||
// second only on failure, and so on. Multiple upstreamRace values coexist
|
||||
// inside one resolver when overlapping nameserver groups target the same
|
||||
// domain; those races run in parallel and the first valid answer wins.
|
||||
type upstreamRace []netip.AddrPort
|
||||
|
||||
// UpstreamHealth is the last query-path outcome for a single upstream,
|
||||
// consumed by nameserver-group status projection.
|
||||
type UpstreamHealth struct {
|
||||
LastOk time.Time
|
||||
LastFail time.Time
|
||||
LastErr string
|
||||
}
|
||||
|
||||
type upstreamResolverBase struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
upstreamClient upstreamClient
|
||||
upstreamServers []upstreamRace
|
||||
domain domain.Domain
|
||||
upstreamTimeout time.Duration
|
||||
|
||||
healthMu sync.RWMutex
|
||||
health map[netip.AddrPort]*UpstreamHealth
|
||||
|
||||
statusRecorder *peer.Status
|
||||
// selectedRoutes returns the current set of client routes the admin
|
||||
// has enabled. Called lazily from the query hot path when an upstream
|
||||
// might need a tunnel-bound client (iOS) and from health projection.
|
||||
selectedRoutes func() route.HAMap
|
||||
}
|
||||
|
||||
type upstreamFailure struct {
|
||||
upstream netip.AddrPort
|
||||
reason string
|
||||
}
|
||||
|
||||
type raceResult struct {
|
||||
msg *dns.Msg
|
||||
upstream netip.AddrPort
|
||||
protocol string
|
||||
ede string
|
||||
failures []upstreamFailure
|
||||
}
|
||||
|
||||
// contextWithDNSProtocol stores the inbound DNS protocol ("udp" or "tcp") in context.
|
||||
func contextWithDNSProtocol(ctx context.Context, network string) context.Context {
|
||||
return context.WithValue(ctx, dnsProtocolKey{}, network)
|
||||
@@ -100,16 +192,8 @@ func dnsProtocolFromContext(ctx context.Context) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
type upstreamProtocolKey struct{}
|
||||
|
||||
// upstreamProtocolResult holds the protocol used for the upstream exchange.
|
||||
// Stored as a pointer in context so the exchange function can set it.
|
||||
type upstreamProtocolResult struct {
|
||||
protocol string
|
||||
}
|
||||
|
||||
// contextWithupstreamProtocolResult stores a mutable result holder in the context.
|
||||
func contextWithupstreamProtocolResult(ctx context.Context) (context.Context, *upstreamProtocolResult) {
|
||||
// contextWithUpstreamProtocolResult stores a mutable result holder in the context.
|
||||
func contextWithUpstreamProtocolResult(ctx context.Context) (context.Context, *upstreamProtocolResult) {
|
||||
r := &upstreamProtocolResult{}
|
||||
return context.WithValue(ctx, upstreamProtocolKey{}, r), r
|
||||
}
|
||||
@@ -124,67 +208,37 @@ func setUpstreamProtocol(ctx context.Context, protocol string) {
|
||||
}
|
||||
}
|
||||
|
||||
type upstreamClient interface {
|
||||
exchange(ctx context.Context, upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error)
|
||||
}
|
||||
|
||||
type UpstreamResolver interface {
|
||||
serveDNS(r *dns.Msg) (*dns.Msg, time.Duration, error)
|
||||
upstreamExchange(upstream string, r *dns.Msg) (*dns.Msg, time.Duration, error)
|
||||
}
|
||||
|
||||
type upstreamResolverBase struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
upstreamClient upstreamClient
|
||||
upstreamServers []netip.AddrPort
|
||||
domain string
|
||||
disabled bool
|
||||
successCount atomic.Int32
|
||||
mutex sync.Mutex
|
||||
reactivatePeriod time.Duration
|
||||
upstreamTimeout time.Duration
|
||||
wg sync.WaitGroup
|
||||
|
||||
deactivate func(error)
|
||||
reactivate func()
|
||||
statusRecorder *peer.Status
|
||||
routeMatch func(netip.Addr) bool
|
||||
}
|
||||
|
||||
type upstreamFailure struct {
|
||||
upstream netip.AddrPort
|
||||
reason string
|
||||
}
|
||||
|
||||
func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status, domain string) *upstreamResolverBase {
|
||||
func newUpstreamResolverBase(ctx context.Context, statusRecorder *peer.Status, d domain.Domain) *upstreamResolverBase {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
return &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
domain: domain,
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
reactivatePeriod: reactivatePeriod,
|
||||
statusRecorder: statusRecorder,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
domain: d,
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
statusRecorder: statusRecorder,
|
||||
}
|
||||
}
|
||||
|
||||
// String returns a string representation of the upstream resolver
|
||||
func (u *upstreamResolverBase) String() string {
|
||||
return fmt.Sprintf("Upstream %s", u.upstreamServers)
|
||||
return fmt.Sprintf("Upstream %s", u.flatUpstreams())
|
||||
}
|
||||
|
||||
// ID returns the unique handler ID
|
||||
// ID returns the unique handler ID. Race groupings and within-race
|
||||
// ordering are both part of the identity: [[A,B]] and [[A],[B]] query
|
||||
// the same servers but with different semantics (serial fallback vs
|
||||
// parallel race), so their handlers must not collide.
|
||||
func (u *upstreamResolverBase) ID() types.HandlerID {
|
||||
servers := slices.Clone(u.upstreamServers)
|
||||
slices.SortFunc(servers, func(a, b netip.AddrPort) int { return a.Compare(b) })
|
||||
|
||||
hash := sha256.New()
|
||||
hash.Write([]byte(u.domain + ":"))
|
||||
for _, s := range servers {
|
||||
hash.Write([]byte(s.String()))
|
||||
hash.Write([]byte("|"))
|
||||
hash.Write([]byte(u.domain.PunycodeString() + ":"))
|
||||
for _, race := range u.upstreamServers {
|
||||
hash.Write([]byte("["))
|
||||
for _, s := range race {
|
||||
hash.Write([]byte(s.String()))
|
||||
hash.Write([]byte("|"))
|
||||
}
|
||||
hash.Write([]byte("]"))
|
||||
}
|
||||
return types.HandlerID("upstream-" + hex.EncodeToString(hash.Sum(nil)[:8]))
|
||||
}
|
||||
@@ -194,13 +248,31 @@ func (u *upstreamResolverBase) MatchSubdomains() bool {
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) Stop() {
|
||||
log.Debugf("stopping serving DNS for upstreams %s", u.upstreamServers)
|
||||
log.Debugf("stopping serving DNS for upstreams %s", u.flatUpstreams())
|
||||
u.cancel()
|
||||
}
|
||||
|
||||
u.mutex.Lock()
|
||||
u.wg.Wait()
|
||||
u.mutex.Unlock()
|
||||
// flatUpstreams is for logging and ID hashing only, not for dispatch.
|
||||
func (u *upstreamResolverBase) flatUpstreams() []netip.AddrPort {
|
||||
var out []netip.AddrPort
|
||||
for _, g := range u.upstreamServers {
|
||||
out = append(out, g...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setSelectedRoutes swaps the accessor used to classify overlay-routed
|
||||
// upstreams. Called when route sources are wired after the handler was
|
||||
// built (permanent / iOS constructors).
|
||||
func (u *upstreamResolverBase) setSelectedRoutes(selected func() route.HAMap) {
|
||||
u.selectedRoutes = selected
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) addRace(servers []netip.AddrPort) {
|
||||
if len(servers) == 0 {
|
||||
return
|
||||
}
|
||||
u.upstreamServers = append(u.upstreamServers, slices.Clone(servers))
|
||||
}
|
||||
|
||||
// ServeDNS handles a DNS request
|
||||
@@ -242,82 +314,201 @@ func (u *upstreamResolverBase) prepareRequest(r *dns.Msg) {
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) tryUpstreamServers(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, logger *log.Entry) (bool, []upstreamFailure) {
|
||||
timeout := u.upstreamTimeout
|
||||
if len(u.upstreamServers) > 1 {
|
||||
maxTotal := 5 * time.Second
|
||||
minPerUpstream := 2 * time.Second
|
||||
scaledTimeout := maxTotal / time.Duration(len(u.upstreamServers))
|
||||
if scaledTimeout > minPerUpstream {
|
||||
timeout = scaledTimeout
|
||||
} else {
|
||||
timeout = minPerUpstream
|
||||
}
|
||||
groups := u.upstreamServers
|
||||
switch len(groups) {
|
||||
case 0:
|
||||
return false, nil
|
||||
case 1:
|
||||
return u.tryOnlyRace(ctx, w, r, groups[0], logger)
|
||||
default:
|
||||
return u.raceAll(ctx, w, r, groups, logger)
|
||||
}
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) tryOnlyRace(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, group upstreamRace, logger *log.Entry) (bool, []upstreamFailure) {
|
||||
res := u.tryRace(ctx, r, group)
|
||||
if res.msg == nil {
|
||||
return false, res.failures
|
||||
}
|
||||
if res.ede != "" {
|
||||
resutil.SetMeta(w, "ede", res.ede)
|
||||
}
|
||||
u.writeSuccessResponse(w, res.msg, res.upstream, r.Question[0].Name, res.protocol, logger)
|
||||
return true, res.failures
|
||||
}
|
||||
|
||||
// raceAll runs one worker per group in parallel, taking the first valid
|
||||
// answer and cancelling the rest.
|
||||
func (u *upstreamResolverBase) raceAll(ctx context.Context, w dns.ResponseWriter, r *dns.Msg, groups []upstreamRace, logger *log.Entry) (bool, []upstreamFailure) {
|
||||
raceCtx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// Buffer sized to len(groups) so workers never block on send, even
|
||||
// after the coordinator has returned.
|
||||
results := make(chan raceResult, len(groups))
|
||||
for _, g := range groups {
|
||||
// tryRace clones the request per attempt, so workers never share
|
||||
// a *dns.Msg and concurrent EDNS0 mutations can't race.
|
||||
go func(g upstreamRace) {
|
||||
results <- u.tryRace(raceCtx, r, g)
|
||||
}(g)
|
||||
}
|
||||
|
||||
var failures []upstreamFailure
|
||||
for _, upstream := range u.upstreamServers {
|
||||
if failure := u.queryUpstream(ctx, w, r, upstream, timeout, logger); failure != nil {
|
||||
failures = append(failures, *failure)
|
||||
} else {
|
||||
return true, failures
|
||||
for range groups {
|
||||
select {
|
||||
case res := <-results:
|
||||
failures = append(failures, res.failures...)
|
||||
if res.msg != nil {
|
||||
if res.ede != "" {
|
||||
resutil.SetMeta(w, "ede", res.ede)
|
||||
}
|
||||
u.writeSuccessResponse(w, res.msg, res.upstream, r.Question[0].Name, res.protocol, logger)
|
||||
return true, failures
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return false, failures
|
||||
}
|
||||
}
|
||||
return false, failures
|
||||
}
|
||||
|
||||
// queryUpstream queries a single upstream server. Returns nil on success, or failure info to try next upstream.
|
||||
func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.ResponseWriter, r *dns.Msg, upstream netip.AddrPort, timeout time.Duration, logger *log.Entry) *upstreamFailure {
|
||||
var rm *dns.Msg
|
||||
var t time.Duration
|
||||
var err error
|
||||
func (u *upstreamResolverBase) tryRace(ctx context.Context, r *dns.Msg, group upstreamRace) raceResult {
|
||||
timeout := u.upstreamTimeout
|
||||
if len(group) > 1 {
|
||||
// Cap the whole walk at raceMaxTotalTimeout: per-upstream timeouts
|
||||
// still honor raceMinPerUpstreamTimeout as a floor for correctness
|
||||
// on slow links, but the outer context ensures the combined walk
|
||||
// cannot exceed the cap regardless of group size.
|
||||
timeout = max(raceMaxTotalTimeout/time.Duration(len(group)), raceMinPerUpstreamTimeout)
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, raceMaxTotalTimeout)
|
||||
defer cancel()
|
||||
}
|
||||
|
||||
var failures []upstreamFailure
|
||||
for _, upstream := range group {
|
||||
if ctx.Err() != nil {
|
||||
return raceResult{failures: failures}
|
||||
}
|
||||
// Clone the request per attempt: the exchange path mutates EDNS0
|
||||
// options in-place, so reusing the same *dns.Msg across sequential
|
||||
// upstreams would carry those mutations (e.g. a reduced UDP size)
|
||||
// into the next attempt.
|
||||
res, failure := u.queryUpstream(ctx, r.Copy(), upstream, timeout)
|
||||
if failure != nil {
|
||||
failures = append(failures, *failure)
|
||||
continue
|
||||
}
|
||||
res.failures = failures
|
||||
return res
|
||||
}
|
||||
return raceResult{failures: failures}
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, r *dns.Msg, upstream netip.AddrPort, timeout time.Duration) (raceResult, *upstreamFailure) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, timeout)
|
||||
defer cancel()
|
||||
ctx, upstreamProto := contextWithUpstreamProtocolResult(ctx)
|
||||
|
||||
// Advertise EDNS0 so the upstream may include Extended DNS Errors
|
||||
// (RFC 8914) in failure responses; we use those to short-circuit
|
||||
// failover for definitive answers like DNSSEC validation failures.
|
||||
// Operate on a copy so the inbound request is unchanged: a client that
|
||||
// did not advertise EDNS0 must not see an OPT in the response.
|
||||
// The caller already passed a per-attempt copy, so we can mutate r
|
||||
// directly; hadEdns reflects the original client request's state and
|
||||
// controls whether we strip the OPT from the response.
|
||||
hadEdns := r.IsEdns0() != nil
|
||||
reqUp := r
|
||||
if !hadEdns {
|
||||
reqUp = r.Copy()
|
||||
reqUp.SetEdns0(upstreamUDPSize(), false)
|
||||
r.SetEdns0(upstreamUDPSize(), false)
|
||||
}
|
||||
|
||||
var startTime time.Time
|
||||
var upstreamProto *upstreamProtocolResult
|
||||
func() {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, timeout)
|
||||
defer cancel()
|
||||
ctx, upstreamProto = contextWithupstreamProtocolResult(ctx)
|
||||
startTime = time.Now()
|
||||
rm, t, err = u.upstreamClient.exchange(ctx, upstream.String(), reqUp)
|
||||
}()
|
||||
startTime := time.Now()
|
||||
rm, _, err := u.upstreamClient.exchange(ctx, upstream.String(), r)
|
||||
|
||||
if err != nil {
|
||||
return u.handleUpstreamError(err, upstream, startTime)
|
||||
// A parent cancellation (e.g., another race won and the coordinator
|
||||
// cancelled the losers) is not an upstream failure. Check both the
|
||||
// error chain and the parent context: a transport may surface the
|
||||
// cancellation as a read/deadline error rather than context.Canceled.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(parentCtx.Err(), context.Canceled) {
|
||||
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "canceled"}
|
||||
}
|
||||
failure := u.handleUpstreamError(err, upstream, startTime)
|
||||
u.markUpstreamFail(upstream, failure.reason)
|
||||
return raceResult{}, failure
|
||||
}
|
||||
|
||||
if rm == nil || !rm.Response {
|
||||
return &upstreamFailure{upstream: upstream, reason: "no response"}
|
||||
u.markUpstreamFail(upstream, "no response")
|
||||
return raceResult{}, &upstreamFailure{upstream: upstream, reason: "no response"}
|
||||
}
|
||||
|
||||
proto := ""
|
||||
if upstreamProto != nil {
|
||||
proto = upstreamProto.protocol
|
||||
}
|
||||
|
||||
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
|
||||
if code, ok := nonRetryableEDE(rm); ok {
|
||||
resutil.SetMeta(w, "ede", edeName(code))
|
||||
if !hadEdns {
|
||||
stripOPT(rm)
|
||||
}
|
||||
u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger)
|
||||
return nil
|
||||
u.markUpstreamOk(upstream)
|
||||
return raceResult{msg: rm, upstream: upstream, protocol: proto, ede: edeName(code)}, nil
|
||||
}
|
||||
return &upstreamFailure{upstream: upstream, reason: dns.RcodeToString[rm.Rcode]}
|
||||
reason := dns.RcodeToString[rm.Rcode]
|
||||
u.markUpstreamFail(upstream, reason)
|
||||
return raceResult{}, &upstreamFailure{upstream: upstream, reason: reason}
|
||||
}
|
||||
|
||||
if !hadEdns {
|
||||
stripOPT(rm)
|
||||
}
|
||||
u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger)
|
||||
return nil
|
||||
|
||||
u.markUpstreamOk(upstream)
|
||||
return raceResult{msg: rm, upstream: upstream, protocol: proto}, nil
|
||||
}
|
||||
|
||||
// healthEntry returns the mutable health record for addr, lazily creating
|
||||
// the map and the entry. Caller must hold u.healthMu.
|
||||
func (u *upstreamResolverBase) healthEntry(addr netip.AddrPort) *UpstreamHealth {
|
||||
if u.health == nil {
|
||||
u.health = make(map[netip.AddrPort]*UpstreamHealth)
|
||||
}
|
||||
h := u.health[addr]
|
||||
if h == nil {
|
||||
h = &UpstreamHealth{}
|
||||
u.health[addr] = h
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) markUpstreamOk(addr netip.AddrPort) {
|
||||
u.healthMu.Lock()
|
||||
defer u.healthMu.Unlock()
|
||||
h := u.healthEntry(addr)
|
||||
h.LastOk = time.Now()
|
||||
h.LastFail = time.Time{}
|
||||
h.LastErr = ""
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) markUpstreamFail(addr netip.AddrPort, reason string) {
|
||||
u.healthMu.Lock()
|
||||
defer u.healthMu.Unlock()
|
||||
h := u.healthEntry(addr)
|
||||
h.LastFail = time.Now()
|
||||
h.LastErr = reason
|
||||
}
|
||||
|
||||
// UpstreamHealth returns a snapshot of per-upstream query outcomes.
|
||||
func (u *upstreamResolverBase) UpstreamHealth() map[netip.AddrPort]UpstreamHealth {
|
||||
u.healthMu.RLock()
|
||||
defer u.healthMu.RUnlock()
|
||||
out := make(map[netip.AddrPort]UpstreamHealth, len(u.health))
|
||||
for k, v := range u.health {
|
||||
out[k] = *v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// upstreamUDPSize returns the EDNS0 UDP buffer size we advertise to upstreams,
|
||||
@@ -358,12 +549,23 @@ func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.Add
|
||||
return &upstreamFailure{upstream: upstream, reason: reason}
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, t time.Duration, upstreamProto *upstreamProtocolResult, logger *log.Entry) bool {
|
||||
u.successCount.Add(1)
|
||||
func (u *upstreamResolverBase) debugUpstreamTimeout(upstream netip.AddrPort) string {
|
||||
if u.statusRecorder == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
peerInfo := findPeerForIP(upstream.Addr(), u.statusRecorder)
|
||||
if peerInfo == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("(routes through NetBird peer %s)", FormatPeerStatus(peerInfo))
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dns.Msg, upstream netip.AddrPort, domain string, proto string, logger *log.Entry) {
|
||||
resutil.SetMeta(w, "upstream", upstream.String())
|
||||
if upstreamProto != nil && upstreamProto.protocol != "" {
|
||||
resutil.SetMeta(w, "upstream_protocol", upstreamProto.protocol)
|
||||
if proto != "" {
|
||||
resutil.SetMeta(w, "upstream_protocol", proto)
|
||||
}
|
||||
|
||||
// Clear Zero bit from external responses to prevent upstream servers from
|
||||
@@ -372,14 +574,11 @@ func (u *upstreamResolverBase) writeSuccessResponse(w dns.ResponseWriter, rm *dn
|
||||
|
||||
if err := w.WriteMsg(rm); err != nil {
|
||||
logger.Errorf("failed to write DNS response for question domain=%s: %s", domain, err)
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) logUpstreamFailures(domain string, failures []upstreamFailure, succeeded bool, logger *log.Entry) {
|
||||
totalUpstreams := len(u.upstreamServers)
|
||||
totalUpstreams := len(u.flatUpstreams())
|
||||
failedCount := len(failures)
|
||||
failureSummary := formatFailures(failures)
|
||||
|
||||
@@ -434,119 +633,6 @@ func edeName(code uint16) string {
|
||||
return fmt.Sprintf("EDE %d", code)
|
||||
}
|
||||
|
||||
// ProbeAvailability tests all upstream servers simultaneously and
|
||||
// disables the resolver if none work
|
||||
func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) {
|
||||
u.mutex.Lock()
|
||||
defer u.mutex.Unlock()
|
||||
|
||||
// avoid probe if upstreams could resolve at least one query
|
||||
if u.successCount.Load() > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var success bool
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
var errs *multierror.Error
|
||||
for _, upstream := range u.upstreamServers {
|
||||
wg.Add(1)
|
||||
go func(upstream netip.AddrPort) {
|
||||
defer wg.Done()
|
||||
err := u.testNameserver(u.ctx, ctx, upstream, 500*time.Millisecond)
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
errs = multierror.Append(errs, err)
|
||||
mu.Unlock()
|
||||
log.Warnf("probing upstream nameserver %s: %s", upstream, err)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
success = true
|
||||
mu.Unlock()
|
||||
}(upstream)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-u.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
// didn't find a working upstream server, let's disable and try later
|
||||
if !success {
|
||||
u.disable(errs.ErrorOrNil())
|
||||
|
||||
if u.statusRecorder == nil {
|
||||
return
|
||||
}
|
||||
|
||||
u.statusRecorder.PublishEvent(
|
||||
proto.SystemEvent_WARNING,
|
||||
proto.SystemEvent_DNS,
|
||||
"All upstream servers failed (probe failed)",
|
||||
"Unable to reach one or more DNS servers. This might affect your ability to connect to some services.",
|
||||
map[string]string{"upstreams": u.upstreamServersString()},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// waitUntilResponse retries, in an exponential interval, querying the upstream servers until it gets a positive response
|
||||
func (u *upstreamResolverBase) waitUntilResponse() {
|
||||
exponentialBackOff := &backoff.ExponentialBackOff{
|
||||
InitialInterval: 500 * time.Millisecond,
|
||||
RandomizationFactor: 0.5,
|
||||
Multiplier: 1.1,
|
||||
MaxInterval: u.reactivatePeriod,
|
||||
MaxElapsedTime: 0,
|
||||
Stop: backoff.Stop,
|
||||
Clock: backoff.SystemClock,
|
||||
}
|
||||
|
||||
operation := func() error {
|
||||
select {
|
||||
case <-u.ctx.Done():
|
||||
return backoff.Permanent(fmt.Errorf("exiting upstream retry loop for upstreams %s: parent context has been canceled", u.upstreamServersString()))
|
||||
default:
|
||||
}
|
||||
|
||||
for _, upstream := range u.upstreamServers {
|
||||
if err := u.testNameserver(u.ctx, nil, upstream, probeTimeout); err != nil {
|
||||
log.Tracef("upstream check for %s: %s", upstream, err)
|
||||
} else {
|
||||
// at least one upstream server is available, stop probing
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Tracef("checking connectivity with upstreams %s failed. Retrying in %s", u.upstreamServersString(), exponentialBackOff.NextBackOff())
|
||||
return fmt.Errorf("upstream check call error")
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backoff.WithContext(exponentialBackOff, u.ctx))
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
log.Debugf("upstream retry loop exited for upstreams %s", u.upstreamServersString())
|
||||
} else {
|
||||
log.Warnf("upstream retry loop exited for upstreams %s: %v", u.upstreamServersString(), err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
log.Infof("upstreams %s are responsive again. Adding them back to system", u.upstreamServersString())
|
||||
u.successCount.Add(1)
|
||||
u.reactivate()
|
||||
u.mutex.Lock()
|
||||
u.disabled = false
|
||||
u.mutex.Unlock()
|
||||
}
|
||||
|
||||
// isTimeout returns true if the given error is a network timeout error.
|
||||
//
|
||||
// Copied from k8s.io/apimachinery/pkg/util/net.IsTimeout
|
||||
@@ -558,45 +644,6 @@ func isTimeout(err error) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) disable(err error) {
|
||||
if u.disabled {
|
||||
return
|
||||
}
|
||||
|
||||
log.Warnf("Upstream resolving is Disabled for %v", reactivatePeriod)
|
||||
u.successCount.Store(0)
|
||||
u.deactivate(err)
|
||||
u.disabled = true
|
||||
u.wg.Add(1)
|
||||
go func() {
|
||||
defer u.wg.Done()
|
||||
u.waitUntilResponse()
|
||||
}()
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) upstreamServersString() string {
|
||||
var servers []string
|
||||
for _, server := range u.upstreamServers {
|
||||
servers = append(servers, server.String())
|
||||
}
|
||||
return strings.Join(servers, ", ")
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) testNameserver(baseCtx context.Context, externalCtx context.Context, server netip.AddrPort, timeout time.Duration) error {
|
||||
mergedCtx, cancel := context.WithTimeout(baseCtx, timeout)
|
||||
defer cancel()
|
||||
|
||||
if externalCtx != nil {
|
||||
stop2 := context.AfterFunc(externalCtx, cancel)
|
||||
defer stop2()
|
||||
}
|
||||
|
||||
r := new(dns.Msg).SetQuestion(testRecord, dns.TypeSOA)
|
||||
|
||||
_, _, err := u.upstreamClient.exchange(mergedCtx, server.String(), r)
|
||||
return err
|
||||
}
|
||||
|
||||
// clientUDPMaxSize returns the maximum UDP response size the client accepts.
|
||||
func clientUDPMaxSize(r *dns.Msg) int {
|
||||
if opt := r.IsEdns0(); opt != nil {
|
||||
@@ -608,13 +655,10 @@ func clientUDPMaxSize(r *dns.Msg) int {
|
||||
// ExchangeWithFallback exchanges a DNS message with the upstream server.
|
||||
// It first tries to use UDP, and if it is truncated, it falls back to TCP.
|
||||
// If the inbound request came over TCP (via context), it skips the UDP attempt.
|
||||
// If the passed context is nil, this will use Exchange instead of ExchangeContext.
|
||||
func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, upstream string) (*dns.Msg, time.Duration, error) {
|
||||
// If the request came in over TCP, go straight to TCP upstream.
|
||||
if dnsProtocolFromContext(ctx) == protoTCP {
|
||||
tcpClient := *client
|
||||
tcpClient.Net = protoTCP
|
||||
rm, t, err := tcpClient.ExchangeContext(ctx, r, upstream)
|
||||
rm, t, err := toTCPClient(client).ExchangeContext(ctx, r, upstream)
|
||||
if err != nil {
|
||||
return nil, t, fmt.Errorf("with tcp: %w", err)
|
||||
}
|
||||
@@ -634,18 +678,7 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u
|
||||
opt.SetUDPSize(maxUDPPayload)
|
||||
}
|
||||
|
||||
var (
|
||||
rm *dns.Msg
|
||||
t time.Duration
|
||||
err error
|
||||
)
|
||||
|
||||
if ctx == nil {
|
||||
rm, t, err = client.Exchange(r, upstream)
|
||||
} else {
|
||||
rm, t, err = client.ExchangeContext(ctx, r, upstream)
|
||||
}
|
||||
|
||||
rm, t, err := client.ExchangeContext(ctx, r, upstream)
|
||||
if err != nil {
|
||||
return nil, t, fmt.Errorf("with udp: %w", err)
|
||||
}
|
||||
@@ -659,15 +692,7 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u
|
||||
// data than the client's buffer, we could truncate locally and skip
|
||||
// the TCP retry.
|
||||
|
||||
tcpClient := *client
|
||||
tcpClient.Net = protoTCP
|
||||
|
||||
if ctx == nil {
|
||||
rm, t, err = tcpClient.Exchange(r, upstream)
|
||||
} else {
|
||||
rm, t, err = tcpClient.ExchangeContext(ctx, r, upstream)
|
||||
}
|
||||
|
||||
rm, t, err = toTCPClient(client).ExchangeContext(ctx, r, upstream)
|
||||
if err != nil {
|
||||
return nil, t, fmt.Errorf("with tcp: %w", err)
|
||||
}
|
||||
@@ -681,6 +706,25 @@ func ExchangeWithFallback(ctx context.Context, client *dns.Client, r *dns.Msg, u
|
||||
return rm, t, nil
|
||||
}
|
||||
|
||||
// toTCPClient returns a copy of c configured for TCP. If c's Dialer has a
|
||||
// *net.UDPAddr bound as LocalAddr (iOS does this to keep the source IP on
|
||||
// the tunnel interface), it is converted to the equivalent *net.TCPAddr
|
||||
// so net.Dialer doesn't reject the TCP dial with "mismatched local
|
||||
// address type".
|
||||
func toTCPClient(c *dns.Client) *dns.Client {
|
||||
tcp := *c
|
||||
tcp.Net = protoTCP
|
||||
if tcp.Dialer == nil {
|
||||
return &tcp
|
||||
}
|
||||
d := *tcp.Dialer
|
||||
if ua, ok := d.LocalAddr.(*net.UDPAddr); ok {
|
||||
d.LocalAddr = &net.TCPAddr{IP: ua.IP, Port: ua.Port, Zone: ua.Zone}
|
||||
}
|
||||
tcp.Dialer = &d
|
||||
return &tcp
|
||||
}
|
||||
|
||||
// ExchangeWithNetstack performs a DNS exchange using netstack for dialing.
|
||||
// This is needed when netstack is enabled to reach peer IPs through the tunnel.
|
||||
func ExchangeWithNetstack(ctx context.Context, nsNet *netstack.Net, r *dns.Msg, upstream string) (*dns.Msg, error) {
|
||||
@@ -822,15 +866,36 @@ func findPeerForIP(ip netip.Addr, statusRecorder *peer.Status) *peer.State {
|
||||
return bestMatch
|
||||
}
|
||||
|
||||
func (u *upstreamResolverBase) debugUpstreamTimeout(upstream netip.AddrPort) string {
|
||||
if u.statusRecorder == nil {
|
||||
return ""
|
||||
// haMapRouteCount returns the total number of routes across all HA
|
||||
// groups in the map. route.HAMap is keyed by HAUniqueID with slices of
|
||||
// routes per key, so len(hm) is the number of HA groups, not routes.
|
||||
func haMapRouteCount(hm route.HAMap) int {
|
||||
total := 0
|
||||
for _, routes := range hm {
|
||||
total += len(routes)
|
||||
}
|
||||
|
||||
peerInfo := findPeerForIP(upstream.Addr(), u.statusRecorder)
|
||||
if peerInfo == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return fmt.Sprintf("(routes through NetBird peer %s)", FormatPeerStatus(peerInfo))
|
||||
return total
|
||||
}
|
||||
|
||||
// haMapContains checks whether ip is covered by any concrete prefix in
|
||||
// the HA map. haveDynamic is reported separately: dynamic (domain-based)
|
||||
// routes carry a placeholder Network that can't be prefix-checked, so we
|
||||
// can't know at this point whether ip is reached through one. Callers
|
||||
// decide how to interpret the unknown: health projection treats it as
|
||||
// "possibly routed" to avoid emitting false-positive warnings during
|
||||
// startup, while iOS dial selection requires a concrete match before
|
||||
// binding to the tunnel.
|
||||
func haMapContains(hm route.HAMap, ip netip.Addr) (matched, haveDynamic bool) {
|
||||
for _, routes := range hm {
|
||||
for _, r := range routes {
|
||||
if r.IsDynamic() {
|
||||
haveDynamic = true
|
||||
continue
|
||||
}
|
||||
if r.Network.Contains(ip) {
|
||||
return true, haveDynamic
|
||||
}
|
||||
}
|
||||
}
|
||||
return false, haveDynamic
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
type upstreamResolver struct {
|
||||
@@ -26,9 +27,9 @@ func newUpstreamResolver(
|
||||
_ WGIface,
|
||||
statusRecorder *peer.Status,
|
||||
hostsDNSHolder *hostsDNSHolder,
|
||||
domain string,
|
||||
d domain.Domain,
|
||||
) (*upstreamResolver, error) {
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain)
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, d)
|
||||
c := &upstreamResolver{
|
||||
upstreamResolverBase: upstreamResolverBase,
|
||||
hostsDNSHolder: hostsDNSHolder,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
type upstreamResolver struct {
|
||||
@@ -24,9 +25,9 @@ func newUpstreamResolver(
|
||||
wgIface WGIface,
|
||||
statusRecorder *peer.Status,
|
||||
_ *hostsDNSHolder,
|
||||
domain string,
|
||||
d domain.Domain,
|
||||
) (*upstreamResolver, error) {
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain)
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, d)
|
||||
nonIOS := &upstreamResolver{
|
||||
upstreamResolverBase: upstreamResolverBase,
|
||||
nsNet: wgIface.GetNet(),
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
)
|
||||
|
||||
type upstreamResolverIOS struct {
|
||||
@@ -27,9 +28,9 @@ func newUpstreamResolver(
|
||||
wgIface WGIface,
|
||||
statusRecorder *peer.Status,
|
||||
_ *hostsDNSHolder,
|
||||
domain string,
|
||||
d domain.Domain,
|
||||
) (*upstreamResolverIOS, error) {
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, domain)
|
||||
upstreamResolverBase := newUpstreamResolverBase(ctx, statusRecorder, d)
|
||||
|
||||
ios := &upstreamResolverIOS{
|
||||
upstreamResolverBase: upstreamResolverBase,
|
||||
@@ -62,9 +63,16 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r *
|
||||
upstreamIP = upstreamIP.Unmap()
|
||||
}
|
||||
addr := u.wgIface.Address()
|
||||
var routed bool
|
||||
if u.selectedRoutes != nil {
|
||||
// Only a concrete prefix match binds to the tunnel: dialing
|
||||
// through a private client for an upstream we can't prove is
|
||||
// routed would break public resolvers.
|
||||
routed, _ = haMapContains(u.selectedRoutes(), upstreamIP)
|
||||
}
|
||||
needsPrivate := addr.Network.Contains(upstreamIP) ||
|
||||
addr.IPv6Net.Contains(upstreamIP) ||
|
||||
(u.routeMatch != nil && u.routeMatch(upstreamIP))
|
||||
routed
|
||||
if needsPrivate {
|
||||
log.Debugf("using private client to query %s via upstream %s", r.Question[0].Name, upstream)
|
||||
client, err = GetClientPrivate(u.wgIface, upstreamIP, timeout)
|
||||
@@ -73,8 +81,7 @@ func (u *upstreamResolverIOS) exchange(ctx context.Context, upstream string, r *
|
||||
}
|
||||
}
|
||||
|
||||
// Cannot use client.ExchangeContext because it overwrites our Dialer
|
||||
return ExchangeWithFallback(nil, client, r, upstream)
|
||||
return ExchangeWithFallback(ctx, client, r, upstream)
|
||||
}
|
||||
|
||||
// GetClientPrivate returns a new DNS client bound to the local IP of the Netbird interface.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -73,7 +74,7 @@ func TestUpstreamResolver_ServeDNS(t *testing.T) {
|
||||
servers = append(servers, netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port()))
|
||||
}
|
||||
}
|
||||
resolver.upstreamServers = servers
|
||||
resolver.addRace(servers)
|
||||
resolver.upstreamTimeout = testCase.timeout
|
||||
if testCase.cancelCTX {
|
||||
cancel()
|
||||
@@ -132,20 +133,10 @@ func (m *mockNetstackProvider) GetInterfaceGUIDString() (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
type mockUpstreamResolver struct {
|
||||
r *dns.Msg
|
||||
rtt time.Duration
|
||||
err error
|
||||
}
|
||||
|
||||
// exchange mock implementation of exchange from upstreamResolver
|
||||
func (c mockUpstreamResolver) exchange(_ context.Context, _ string, _ *dns.Msg) (*dns.Msg, time.Duration, error) {
|
||||
return c.r, c.rtt, c.err
|
||||
}
|
||||
|
||||
type mockUpstreamResponse struct {
|
||||
msg *dns.Msg
|
||||
err error
|
||||
msg *dns.Msg
|
||||
err error
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
type mockUpstreamResolverPerServer struct {
|
||||
@@ -153,63 +144,19 @@ type mockUpstreamResolverPerServer struct {
|
||||
rtt time.Duration
|
||||
}
|
||||
|
||||
func (c mockUpstreamResolverPerServer) exchange(_ context.Context, upstream string, _ *dns.Msg) (*dns.Msg, time.Duration, error) {
|
||||
if r, ok := c.responses[upstream]; ok {
|
||||
return r.msg, c.rtt, r.err
|
||||
func (c mockUpstreamResolverPerServer) exchange(ctx context.Context, upstream string, _ *dns.Msg) (*dns.Msg, time.Duration, error) {
|
||||
r, ok := c.responses[upstream]
|
||||
if !ok {
|
||||
return nil, c.rtt, fmt.Errorf("no mock response for %s", upstream)
|
||||
}
|
||||
return nil, c.rtt, fmt.Errorf("no mock response for %s", upstream)
|
||||
}
|
||||
|
||||
func TestUpstreamResolver_DeactivationReactivation(t *testing.T) {
|
||||
mockClient := &mockUpstreamResolver{
|
||||
err: dns.ErrTime,
|
||||
r: new(dns.Msg),
|
||||
rtt: time.Millisecond,
|
||||
}
|
||||
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: context.TODO(),
|
||||
upstreamClient: mockClient,
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
reactivatePeriod: time.Microsecond * 100,
|
||||
}
|
||||
addrPort, _ := netip.ParseAddrPort("0.0.0.0:1") // Use valid port for parsing, test will still fail on connection
|
||||
resolver.upstreamServers = []netip.AddrPort{netip.AddrPortFrom(addrPort.Addr().Unmap(), addrPort.Port())}
|
||||
|
||||
failed := false
|
||||
resolver.deactivate = func(error) {
|
||||
failed = true
|
||||
// After deactivation, make the mock client work again
|
||||
mockClient.err = nil
|
||||
}
|
||||
|
||||
reactivated := false
|
||||
resolver.reactivate = func() {
|
||||
reactivated = true
|
||||
}
|
||||
|
||||
resolver.ProbeAvailability(context.TODO())
|
||||
|
||||
if !failed {
|
||||
t.Errorf("expected that resolving was deactivated")
|
||||
return
|
||||
}
|
||||
|
||||
if !resolver.disabled {
|
||||
t.Errorf("resolver should be Disabled")
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Millisecond * 200)
|
||||
|
||||
if !reactivated {
|
||||
t.Errorf("expected that resolving was reactivated")
|
||||
return
|
||||
}
|
||||
|
||||
if resolver.disabled {
|
||||
t.Errorf("should be enabled")
|
||||
if r.delay > 0 {
|
||||
select {
|
||||
case <-time.After(r.delay):
|
||||
case <-ctx.Done():
|
||||
return nil, c.rtt, ctx.Err()
|
||||
}
|
||||
}
|
||||
return r.msg, c.rtt, r.err
|
||||
}
|
||||
|
||||
func TestUpstreamResolver_Failover(t *testing.T) {
|
||||
@@ -339,9 +286,9 @@ func TestUpstreamResolver_Failover(t *testing.T) {
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
upstreamClient: trackingClient,
|
||||
upstreamServers: []netip.AddrPort{upstream1, upstream2},
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
}
|
||||
resolver.addRace([]netip.AddrPort{upstream1, upstream2})
|
||||
|
||||
var responseMSG *dns.Msg
|
||||
responseWriter := &test.MockResponseWriter{
|
||||
@@ -421,9 +368,9 @@ func TestUpstreamResolver_SingleUpstreamFailure(t *testing.T) {
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
upstreamClient: mockClient,
|
||||
upstreamServers: []netip.AddrPort{upstream},
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
}
|
||||
resolver.addRace([]netip.AddrPort{upstream})
|
||||
|
||||
var responseMSG *dns.Msg
|
||||
responseWriter := &test.MockResponseWriter{
|
||||
@@ -440,6 +387,136 @@ func TestUpstreamResolver_SingleUpstreamFailure(t *testing.T) {
|
||||
assert.Equal(t, dns.RcodeServerFailure, responseMSG.Rcode, "single upstream SERVFAIL should return SERVFAIL")
|
||||
}
|
||||
|
||||
// TestUpstreamResolver_RaceAcrossGroups covers two nameserver groups
|
||||
// configured for the same domain, with one broken group. The merge+race
|
||||
// path should answer as fast as the working group and not pay the timeout
|
||||
// of the broken one on every query.
|
||||
func TestUpstreamResolver_RaceAcrossGroups(t *testing.T) {
|
||||
broken := netip.MustParseAddrPort("192.0.2.1:53")
|
||||
working := netip.MustParseAddrPort("192.0.2.2:53")
|
||||
successAnswer := "192.0.2.100"
|
||||
timeoutErr := &net.OpError{Op: "read", Err: fmt.Errorf("i/o timeout")}
|
||||
|
||||
mockClient := &mockUpstreamResolverPerServer{
|
||||
responses: map[string]mockUpstreamResponse{
|
||||
// Force the broken upstream to only unblock via timeout /
|
||||
// cancellation so the assertion below can't pass if races
|
||||
// were run serially.
|
||||
broken.String(): {err: timeoutErr, delay: 500 * time.Millisecond},
|
||||
working.String(): {msg: buildMockResponse(dns.RcodeSuccess, successAnswer)},
|
||||
},
|
||||
rtt: time.Millisecond,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
upstreamClient: mockClient,
|
||||
upstreamTimeout: 250 * time.Millisecond,
|
||||
}
|
||||
resolver.addRace([]netip.AddrPort{broken})
|
||||
resolver.addRace([]netip.AddrPort{working})
|
||||
|
||||
var responseMSG *dns.Msg
|
||||
responseWriter := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
responseMSG = m
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
inputMSG := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
|
||||
start := time.Now()
|
||||
resolver.ServeDNS(responseWriter, inputMSG)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.NotNil(t, responseMSG, "should write a response")
|
||||
assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode)
|
||||
require.NotEmpty(t, responseMSG.Answer)
|
||||
assert.Contains(t, responseMSG.Answer[0].String(), successAnswer)
|
||||
// Working group answers in a single RTT; the broken group's
|
||||
// timeout (100ms) must not block the response.
|
||||
assert.Less(t, elapsed, 100*time.Millisecond, "race must not wait for broken group's timeout")
|
||||
}
|
||||
|
||||
// TestUpstreamResolver_AllGroupsFail checks that when every group fails the
|
||||
// resolver returns SERVFAIL rather than leaking a partial response.
|
||||
func TestUpstreamResolver_AllGroupsFail(t *testing.T) {
|
||||
a := netip.MustParseAddrPort("192.0.2.1:53")
|
||||
b := netip.MustParseAddrPort("192.0.2.2:53")
|
||||
|
||||
mockClient := &mockUpstreamResolverPerServer{
|
||||
responses: map[string]mockUpstreamResponse{
|
||||
a.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")},
|
||||
b.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")},
|
||||
},
|
||||
rtt: time.Millisecond,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
upstreamClient: mockClient,
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
}
|
||||
resolver.addRace([]netip.AddrPort{a})
|
||||
resolver.addRace([]netip.AddrPort{b})
|
||||
|
||||
var responseMSG *dns.Msg
|
||||
responseWriter := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
responseMSG = m
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))
|
||||
require.NotNil(t, responseMSG)
|
||||
assert.Equal(t, dns.RcodeServerFailure, responseMSG.Rcode)
|
||||
}
|
||||
|
||||
// TestUpstreamResolver_HealthTracking verifies that query-path results are
|
||||
// recorded into per-upstream health, which is what projects back to
|
||||
// NSGroupState for status reporting.
|
||||
func TestUpstreamResolver_HealthTracking(t *testing.T) {
|
||||
ok := netip.MustParseAddrPort("192.0.2.10:53")
|
||||
bad := netip.MustParseAddrPort("192.0.2.11:53")
|
||||
|
||||
mockClient := &mockUpstreamResolverPerServer{
|
||||
responses: map[string]mockUpstreamResponse{
|
||||
ok.String(): {msg: buildMockResponse(dns.RcodeSuccess, "192.0.2.100")},
|
||||
bad.String(): {msg: buildMockResponse(dns.RcodeServerFailure, "")},
|
||||
},
|
||||
rtt: time.Millisecond,
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
upstreamClient: mockClient,
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
}
|
||||
resolver.addRace([]netip.AddrPort{ok, bad})
|
||||
|
||||
responseWriter := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { return nil }}
|
||||
resolver.ServeDNS(responseWriter, new(dns.Msg).SetQuestion("example.com.", dns.TypeA))
|
||||
|
||||
health := resolver.UpstreamHealth()
|
||||
require.Contains(t, health, ok)
|
||||
assert.False(t, health[ok].LastOk.IsZero(), "ok upstream should have LastOk set")
|
||||
assert.Empty(t, health[ok].LastErr)
|
||||
|
||||
// bad upstream was never tried because ok answered first; its health
|
||||
// should remain unset.
|
||||
assert.NotContains(t, health, bad, "sibling upstream should not be queried when primary answers")
|
||||
}
|
||||
|
||||
func TestFormatFailures(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
@@ -665,10 +742,10 @@ func TestExchangeWithFallback_EDNS0Capped(t *testing.T) {
|
||||
// Verify that a client EDNS0 larger than our MTU-derived limit gets
|
||||
// capped in the outgoing request so the upstream doesn't send a
|
||||
// response larger than our read buffer.
|
||||
var receivedUDPSize uint16
|
||||
var receivedUDPSize atomic.Uint32
|
||||
udpHandler := dns.HandlerFunc(func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
if opt := r.IsEdns0(); opt != nil {
|
||||
receivedUDPSize = opt.UDPSize()
|
||||
receivedUDPSize.Store(uint32(opt.UDPSize()))
|
||||
}
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
@@ -699,7 +776,7 @@ func TestExchangeWithFallback_EDNS0Capped(t *testing.T) {
|
||||
require.NotNil(t, rm)
|
||||
|
||||
expectedMax := uint16(currentMTU - ipUDPHeaderSize)
|
||||
assert.Equal(t, expectedMax, receivedUDPSize,
|
||||
assert.Equal(t, expectedMax, uint16(receivedUDPSize.Load()),
|
||||
"upstream should see capped EDNS0, not the client's 4096")
|
||||
}
|
||||
|
||||
@@ -874,7 +951,7 @@ func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
|
||||
resolver := &upstreamResolverBase{
|
||||
ctx: ctx,
|
||||
upstreamClient: tracking,
|
||||
upstreamServers: []netip.AddrPort{upstream1, upstream2},
|
||||
upstreamServers: []upstreamRace{{upstream1, upstream2}},
|
||||
upstreamTimeout: UpstreamTimeout,
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,7 @@ type EngineConfig struct {
|
||||
RosenpassPermissive bool
|
||||
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -205,6 +206,7 @@ type Engine struct {
|
||||
networkMonitor *networkmonitor.NetworkMonitor
|
||||
|
||||
sshServer sshServer
|
||||
vncSrv vncServer
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
@@ -320,6 +322,10 @@ func (e *Engine) Stop() error {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
if err := e.stopVNCServer(); err != nil {
|
||||
log.Warnf("failed to stop VNC server: %v", err)
|
||||
}
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -512,16 +518,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
|
||||
e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener)
|
||||
|
||||
e.dnsServer.SetRouteChecker(func(ip netip.Addr) bool {
|
||||
for _, routes := range e.routeManager.GetSelectedClientRoutes() {
|
||||
for _, r := range routes {
|
||||
if r.Network.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
e.dnsServer.SetRouteSources(e.routeManager.GetSelectedClientRoutes, e.routeManager.GetActiveClientRoutes)
|
||||
|
||||
if err = e.wgInterfaceCreate(); err != nil {
|
||||
log.Errorf("failed creating tunnel interface %s: [%s]", e.config.WgIfaceName, err.Error())
|
||||
@@ -1019,6 +1016,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1066,6 +1064,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.updateVNC(); err != nil {
|
||||
log.Warnf("failed handling VNC server setup: %v", err)
|
||||
}
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1191,6 +1193,7 @@ func (e *Engine) receiveManagementEvents() {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1380,15 +1383,17 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
||||
}
|
||||
|
||||
// VNC auth: always sync, including nil so cleared auth on the management
|
||||
// side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty
|
||||
// cleanup path.
|
||||
e.updateVNCServerAuth(networkMap.GetVncAuth())
|
||||
|
||||
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||
|
||||
e.networkSerial = serial
|
||||
|
||||
// Test received (upstream) servers for availability right away instead of upon usage.
|
||||
// If no server of a server group responds this will disable the respective handler and retry later.
|
||||
go e.dnsServer.ProbeAvailability()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1838,6 +1843,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1932,7 +1938,7 @@ func (e *Engine) newDnsServer(dnsConfig *nbdns.Config) (dns.Server, error) {
|
||||
return dnsServer, nil
|
||||
|
||||
case "ios":
|
||||
dnsServer := dns.NewDefaultServerIos(e.ctx, e.wgInterface, e.mobileDep.DnsManager, e.mobileDep.HostDNSAddresses, e.statusRecorder, e.config.DisableDNS)
|
||||
dnsServer := dns.NewDefaultServerIos(e.ctx, e.wgInterface, e.mobileDep.DnsManager, e.statusRecorder, e.config.DisableDNS)
|
||||
return dnsServer, nil
|
||||
|
||||
default:
|
||||
|
||||
236
client/internal/engine_vnc.go
Normal file
236
client/internal/engine_vnc.go
Normal file
@@ -0,0 +1,236 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/internal/metrics"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
const (
|
||||
vncExternalPort uint16 = 5900
|
||||
vncInternalPort uint16 = 25900
|
||||
)
|
||||
|
||||
type vncServer interface {
|
||||
Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
|
||||
Stop() error
|
||||
ActiveSessions() []vncserver.ActiveSessionInfo
|
||||
}
|
||||
|
||||
func (e *Engine) setupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil {
|
||||
return fmt.Errorf("add VNC port redirection: %w", err)
|
||||
}
|
||||
log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vncExternalPort, localAddr, vncInternalPort)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil {
|
||||
return fmt.Errorf("remove VNC port redirection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNC handles starting/stopping the VNC server based on the config flag.
|
||||
func (e *Engine) updateVNC() error {
|
||||
if !e.config.ServerVNCAllowed {
|
||||
if e.vncSrv != nil {
|
||||
log.Info("VNC server disabled, stopping")
|
||||
}
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.config.BlockInbound {
|
||||
log.Info("VNC server disabled because inbound connections are blocked")
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.vncSrv != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.startVNCServer()
|
||||
}
|
||||
|
||||
func (e *Engine) startVNCServer() error {
|
||||
if e.wgInterface == nil {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
|
||||
capturer, injector, ok := newPlatformVNC()
|
||||
if !ok {
|
||||
log.Debug("VNC server not supported on this platform")
|
||||
return nil
|
||||
}
|
||||
|
||||
netbirdIP := e.wgInterface.Address().IP
|
||||
|
||||
srv := vncserver.New(capturer, injector, e.config.WgPrivateKey[:])
|
||||
if e.clientMetrics != nil {
|
||||
srv.SetSessionRecorder(func(t vncserver.SessionTick) {
|
||||
e.clientMetrics.RecordVNCSessionTick(e.ctx, metrics.VNCSessionTick{
|
||||
Period: t.Period,
|
||||
BytesOut: t.BytesOut,
|
||||
Writes: t.Writes,
|
||||
FBUs: t.FBUs,
|
||||
MaxFBUBytes: t.MaxFBUBytes,
|
||||
MaxFBURects: t.MaxFBURects,
|
||||
MaxWriteBytes: t.MaxWriteBytes,
|
||||
WriteNanos: t.WriteNanos,
|
||||
})
|
||||
})
|
||||
}
|
||||
if vncNeedsServiceMode() {
|
||||
log.Info("VNC: running in Session 0, enabling service mode (agent proxy)")
|
||||
srv.SetServiceMode(true)
|
||||
}
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
srv.SetNetstackNet(netstackNet)
|
||||
}
|
||||
|
||||
listenAddr := netip.AddrPortFrom(netbirdIP, vncInternalPort)
|
||||
network := e.wgInterface.Address().Network
|
||||
if err := srv.Start(e.ctx, listenAddr, network); err != nil {
|
||||
return fmt.Errorf("start VNC server: %w", err)
|
||||
}
|
||||
|
||||
e.vncSrv = srv
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, vncInternalPort)
|
||||
log.Debugf("registered VNC service with netstack for TCP:%d", vncInternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.setupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("setup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
log.Info("VNC server enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
// updateVNCServerAuth updates VNC fine-grained access control from management.
|
||||
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
|
||||
if vncAuth == nil || e.vncSrv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
vncSrv, ok := e.vncSrv.(*vncserver.Server)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
protoUsers := vncAuth.GetAuthorizedUsers()
|
||||
authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers))
|
||||
for i, hash := range protoUsers {
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash))
|
||||
return
|
||||
}
|
||||
authorizedUsers[i] = sshuserhash.UserIDHash(hash)
|
||||
}
|
||||
|
||||
machineUsers := make(map[string][]uint32)
|
||||
for osUser, indexes := range vncAuth.GetMachineUsers() {
|
||||
machineUsers[osUser] = indexes.GetIndexes()
|
||||
}
|
||||
|
||||
sessionPubKeys := make([]sshauth.SessionPubKey, 0, len(vncAuth.GetSessionPubKeys()))
|
||||
for _, e := range vncAuth.GetSessionPubKeys() {
|
||||
pub := e.GetPubKey()
|
||||
if len(pub) != 32 {
|
||||
log.Warnf("VNC session pubkey wrong length %d", len(pub))
|
||||
continue
|
||||
}
|
||||
hash := e.GetUserIdHash()
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("VNC session user id hash wrong length %d", len(hash))
|
||||
continue
|
||||
}
|
||||
sessionPubKeys = append(sessionPubKeys, sshauth.SessionPubKey{
|
||||
PubKey: pub,
|
||||
UserIDHash: sshuserhash.UserIDHash(hash),
|
||||
})
|
||||
}
|
||||
|
||||
vncSrv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: authorizedUsers,
|
||||
MachineUsers: machineUsers,
|
||||
SessionPubKeys: sessionPubKeys,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVNCServerStatus returns whether the VNC server is running and the list
|
||||
// of active VNC sessions.
|
||||
func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) {
|
||||
if e.vncSrv == nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, e.vncSrv.ActiveSessions()
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error {
|
||||
if e.vncSrv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.cleanupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("cleanup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, vncInternalPort)
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("stopping VNC server")
|
||||
err := e.vncSrv.Stop()
|
||||
e.vncSrv = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop VNC server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
client/internal/engine_vnc_console_freebsd.go
Normal file
31
client/internal/engine_vnc_console_freebsd.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer
|
||||
// for capture, /dev/uinput for input. The uinput device requires the
|
||||
// `uinput` kernel module (`kldload uinput`); without it, input init
|
||||
// fails and we drop to a stub injector so the user still gets a
|
||||
// view-only screen mirror.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)")
|
||||
}
|
||||
if inj, err := vncserver.NewUInputInjector(w, h); err == nil {
|
||||
return poller, inj, nil
|
||||
} else {
|
||||
log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
}
|
||||
30
client/internal/engine_vnc_console_linux.go
Normal file
30
client/internal/engine_vnc_console_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes
|
||||
// without a running X server. Used as the auto-fallback when
|
||||
// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or
|
||||
// /dev/uinput aren't usable so the caller can drop back to a stub.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)")
|
||||
}
|
||||
inj, err := vncserver.NewUInputInjector(w, h)
|
||||
if err != nil {
|
||||
log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
return poller, inj, nil
|
||||
}
|
||||
34
client/internal/engine_vnc_darwin.go
Normal file
34
client/internal/engine_vnc_darwin.go
Normal file
@@ -0,0 +1,34 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
// Prompt for Screen Recording at server-enable time rather than first
|
||||
// client-connect. The native prompt is far easier for users to act on
|
||||
// in the moment they toggled VNC on than later when "the screen looks
|
||||
// like wallpaper" would otherwise be the only clue.
|
||||
vncserver.PrimeScreenCapturePermission()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
log.Debugf("VNC: macOS input injector: %v", err)
|
||||
return capturer, &vncserver.StubInputInjector{}, true
|
||||
}
|
||||
return capturer, injector, true
|
||||
}
|
||||
|
||||
// vncNeedsServiceMode reports whether the running process is a system
|
||||
// LaunchDaemon (root, parented by launchd). Daemons sit in the global
|
||||
// bootstrap namespace and cannot talk to WindowServer; we route capture
|
||||
// through a per-user agent in that case.
|
||||
func vncNeedsServiceMode() bool {
|
||||
return os.Geteuid() == 0 && os.Getppid() == 1
|
||||
}
|
||||
17
client/internal/engine_vnc_stub.go
Normal file
17
client/internal/engine_vnc_stub.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build js || ios || android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
)
|
||||
|
||||
type vncServer interface{}
|
||||
|
||||
func (e *Engine) updateVNC() error { return nil }
|
||||
|
||||
func (e *Engine) updateVNCServerAuth(_ *mgmProto.VNCAuth) {
|
||||
// no-op on platforms without a VNC server
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error { return nil }
|
||||
13
client/internal/engine_vnc_windows.go
Normal file
13
client/internal/engine_vnc_windows.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package internal
|
||||
|
||||
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return vncserver.GetCurrentSessionID() == 0
|
||||
}
|
||||
35
client/internal/engine_vnc_x11.go
Normal file
35
client/internal/engine_vnc_x11.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
// Prefer X11 when an X server is reachable. NewX11InputInjector probes
|
||||
// DISPLAY (and /proc) eagerly, so a non-nil error here means no X.
|
||||
injector, err := vncserver.NewX11InputInjector("")
|
||||
if err == nil {
|
||||
return vncserver.NewX11Poller(""), injector, true
|
||||
}
|
||||
log.Debugf("VNC: X11 not available: %v", err)
|
||||
|
||||
// Fallback for headless / pre-X states (kernel console, login manager
|
||||
// without X, physical server in recovery): stream the framebuffer and
|
||||
// inject input via /dev/uinput.
|
||||
consoleCap, consoleInj, err := newConsoleVNC()
|
||||
if err == nil {
|
||||
log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height())
|
||||
return consoleCap, consoleInj, true
|
||||
}
|
||||
log.Debugf("VNC: framebuffer console fallback unavailable: %v", err)
|
||||
|
||||
return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return false
|
||||
}
|
||||
@@ -120,6 +120,36 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
|
||||
m.trimLocked()
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordVNCSessionTick(_ context.Context, agentInfo AgentInfo, tick VNCSessionTick) {
|
||||
tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s",
|
||||
agentInfo.DeploymentType.String(),
|
||||
agentInfo.Version,
|
||||
agentInfo.OS,
|
||||
agentInfo.Arch,
|
||||
agentInfo.peerID,
|
||||
)
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.samples = append(m.samples, influxSample{
|
||||
measurement: "netbird_vnc_traffic",
|
||||
tags: tags,
|
||||
fields: map[string]float64{
|
||||
"period_seconds": tick.Period.Seconds(),
|
||||
"bytes_out": float64(tick.BytesOut),
|
||||
"writes": float64(tick.Writes),
|
||||
"fbus": float64(tick.FBUs),
|
||||
"max_fbu_bytes": float64(tick.MaxFBUBytes),
|
||||
"max_fbu_rects": float64(tick.MaxFBURects),
|
||||
"max_write_bytes": float64(tick.MaxWriteBytes),
|
||||
"write_time_seconds": float64(tick.WriteNanos) / 1e9,
|
||||
},
|
||||
timestamp: time.Now(),
|
||||
})
|
||||
m.trimLocked()
|
||||
}
|
||||
|
||||
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
|
||||
result := "success"
|
||||
if !success {
|
||||
|
||||
@@ -59,6 +59,11 @@ type metricsImplementation interface {
|
||||
// RecordLoginDuration records how long the login to management took
|
||||
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
|
||||
|
||||
// RecordVNCSessionTick records a periodic snapshot of one VNC
|
||||
// session's wire activity. Called once per metricsConn tick interval
|
||||
// (and once at session close), only when the tick saw activity.
|
||||
RecordVNCSessionTick(ctx context.Context, agentInfo AgentInfo, tick VNCSessionTick)
|
||||
|
||||
// Export exports metrics in InfluxDB line protocol format
|
||||
Export(w io.Writer) error
|
||||
|
||||
@@ -78,6 +83,21 @@ type ClientMetrics struct {
|
||||
pushCancel context.CancelFunc
|
||||
}
|
||||
|
||||
// VNCSessionTick is one sampling slice of a VNC session's wire activity.
|
||||
// BytesOut / Writes / FBUs / WriteNanos are deltas observed during this
|
||||
// tick; Max* fields are the high-water marks observed during the tick.
|
||||
// Period is the wall-clock duration the deltas cover.
|
||||
type VNCSessionTick struct {
|
||||
Period time.Duration
|
||||
BytesOut uint64
|
||||
Writes uint64
|
||||
FBUs uint64
|
||||
MaxFBUBytes uint64
|
||||
MaxFBURects uint64
|
||||
MaxWriteBytes uint64
|
||||
WriteNanos uint64
|
||||
}
|
||||
|
||||
// ConnectionStageTimestamps holds timestamps for each connection stage
|
||||
type ConnectionStageTimestamps struct {
|
||||
SignalingReceived time.Time // First signal received from remote peer (both initial and reconnection)
|
||||
@@ -127,6 +147,17 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
|
||||
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
|
||||
}
|
||||
|
||||
// RecordVNCSessionTick records a periodic snapshot of one VNC session.
|
||||
func (c *ClientMetrics) RecordVNCSessionTick(ctx context.Context, tick VNCSessionTick) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
c.mu.RLock()
|
||||
agentInfo := c.agentInfo
|
||||
c.mu.RUnlock()
|
||||
c.impl.RecordVNCSessionTick(ctx, agentInfo, tick)
|
||||
}
|
||||
|
||||
// RecordLoginDuration records how long the login to management server took
|
||||
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
|
||||
if c == nil {
|
||||
|
||||
@@ -73,6 +73,9 @@ func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.
|
||||
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
|
||||
}
|
||||
|
||||
func (m *mockMetrics) RecordVNCSessionTick(_ context.Context, _ AgentInfo, _ VNCSessionTick) {
|
||||
}
|
||||
|
||||
func (m *mockMetrics) Export(w io.Writer) error {
|
||||
if m.exportData != "" {
|
||||
_, err := w.Write([]byte(m.exportData))
|
||||
|
||||
@@ -65,6 +65,7 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -116,6 +117,7 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -418,6 +420,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ServerVNCAllowed != nil {
|
||||
if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed {
|
||||
if *input.ServerVNCAllowed {
|
||||
log.Infof("enabling VNC server")
|
||||
} else {
|
||||
log.Infof("disabling VNC server")
|
||||
}
|
||||
config.ServerVNCAllowed = input.ServerVNCAllowed
|
||||
updated = true
|
||||
}
|
||||
} else if config.ServerVNCAllowed == nil {
|
||||
config.ServerVNCAllowed = util.True()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
|
||||
@@ -53,6 +53,7 @@ type Manager interface {
|
||||
GetRouteSelector() *routeselector.RouteSelector
|
||||
GetClientRoutes() route.HAMap
|
||||
GetSelectedClientRoutes() route.HAMap
|
||||
GetActiveClientRoutes() route.HAMap
|
||||
GetClientRoutesWithNetID() map[route.NetID][]*route.Route
|
||||
SetRouteChangeListener(listener listener.NetworkChangeListener)
|
||||
InitialRouteRange() []string
|
||||
@@ -485,6 +486,39 @@ func (m *DefaultManager) GetSelectedClientRoutes() route.HAMap {
|
||||
return m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes))
|
||||
}
|
||||
|
||||
// GetActiveClientRoutes returns the subset of selected client routes
|
||||
// that are currently reachable: the route's peer is Connected and is
|
||||
// the one actively carrying the route (not just an HA sibling).
|
||||
func (m *DefaultManager) GetActiveClientRoutes() route.HAMap {
|
||||
m.mux.Lock()
|
||||
selected := m.routeSelector.FilterSelectedExitNodes(maps.Clone(m.clientRoutes))
|
||||
recorder := m.statusRecorder
|
||||
m.mux.Unlock()
|
||||
|
||||
if recorder == nil {
|
||||
return selected
|
||||
}
|
||||
|
||||
out := make(route.HAMap, len(selected))
|
||||
for id, routes := range selected {
|
||||
for _, r := range routes {
|
||||
st, err := recorder.GetPeer(r.Peer)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if st.ConnStatus != peer.StatusConnected {
|
||||
continue
|
||||
}
|
||||
if _, hasRoute := st.GetRoutes()[r.Network.String()]; !hasRoute {
|
||||
continue
|
||||
}
|
||||
out[id] = routes
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// GetClientRoutesWithNetID returns the current routes from the route map, but the keys consist of the network ID only
|
||||
func (m *DefaultManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route {
|
||||
m.mux.Lock()
|
||||
@@ -704,7 +738,10 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
|
||||
}
|
||||
|
||||
func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool {
|
||||
return len(routes) > 0 && routes[0].Network.String() == vars.ExitNodeCIDR
|
||||
if len(routes) == 0 {
|
||||
return false
|
||||
}
|
||||
return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)
|
||||
}
|
||||
|
||||
func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) {
|
||||
|
||||
@@ -19,6 +19,7 @@ type MockManager struct {
|
||||
GetRouteSelectorFunc func() *routeselector.RouteSelector
|
||||
GetClientRoutesFunc func() route.HAMap
|
||||
GetSelectedClientRoutesFunc func() route.HAMap
|
||||
GetActiveClientRoutesFunc func() route.HAMap
|
||||
GetClientRoutesWithNetIDFunc func() map[route.NetID][]*route.Route
|
||||
StopFunc func(manager *statemanager.Manager)
|
||||
}
|
||||
@@ -78,6 +79,14 @@ func (m *MockManager) GetSelectedClientRoutes() route.HAMap {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetActiveClientRoutes mock implementation of GetActiveClientRoutes from the Manager interface
|
||||
func (m *MockManager) GetActiveClientRoutes() route.HAMap {
|
||||
if m.GetActiveClientRoutesFunc != nil {
|
||||
return m.GetActiveClientRoutesFunc()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetClientRoutesWithNetID mock implementation of GetClientRoutesWithNetID from Manager interface
|
||||
func (m *MockManager) GetClientRoutesWithNetID() map[route.NetID][]*route.Route {
|
||||
if m.GetClientRoutesWithNetIDFunc != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
@@ -12,10 +13,6 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
)
|
||||
|
||||
const (
|
||||
exitNodeCIDR = "0.0.0.0/0"
|
||||
)
|
||||
|
||||
type RouteSelector struct {
|
||||
mu sync.RWMutex
|
||||
deselectedRoutes map[route.NetID]struct{}
|
||||
@@ -124,13 +121,7 @@ func (rs *RouteSelector) IsSelected(routeID route.NetID) bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
|
||||
if rs.deselectAll {
|
||||
return false
|
||||
}
|
||||
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
isSelected := !deselected
|
||||
return isSelected
|
||||
return rs.isSelectedLocked(routeID)
|
||||
}
|
||||
|
||||
// FilterSelected removes unselected routes from the provided map.
|
||||
@@ -144,23 +135,22 @@ func (rs *RouteSelector) FilterSelected(routes route.HAMap) route.HAMap {
|
||||
|
||||
filtered := route.HAMap{}
|
||||
for id, rt := range routes {
|
||||
netID := id.NetID()
|
||||
_, deselected := rs.deselectedRoutes[netID]
|
||||
if !deselected {
|
||||
if !rs.isDeselectedLocked(id.NetID()) {
|
||||
filtered[id] = rt
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this specific route
|
||||
// HasUserSelectionForRoute returns true if the user has explicitly selected or deselected this route.
|
||||
// Intended for exit-node code paths: a v6 exit-node pair (e.g. "MyExit-v6") with no explicit state of
|
||||
// its own inherits its v4 base's state, so legacy persisted selections that predate v6 pairing
|
||||
// transparently apply to the synthesized v6 entry.
|
||||
func (rs *RouteSelector) HasUserSelectionForRoute(routeID route.NetID) bool {
|
||||
rs.mu.RLock()
|
||||
defer rs.mu.RUnlock()
|
||||
|
||||
_, selected := rs.selectedRoutes[routeID]
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return selected || deselected
|
||||
return rs.hasUserSelectionForRouteLocked(rs.effectiveNetID(routeID))
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap {
|
||||
@@ -174,7 +164,7 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
|
||||
filtered := make(route.HAMap, len(routes))
|
||||
for id, rt := range routes {
|
||||
netID := id.NetID()
|
||||
if rs.isDeselected(netID) {
|
||||
if rs.isDeselectedLocked(netID) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -189,13 +179,48 @@ func (rs *RouteSelector) FilterSelectedExitNodes(routes route.HAMap) route.HAMap
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isDeselected(netID route.NetID) bool {
|
||||
// effectiveNetID returns the v4 base for a "-v6" exit pair entry that has no explicit
|
||||
// state of its own, so selections made on the v4 entry govern the v6 entry automatically.
|
||||
// Only call this from exit-node-specific code paths: applying it to a non-exit "-v6" route
|
||||
// would make it inherit unrelated v4 state. Must be called with rs.mu held.
|
||||
func (rs *RouteSelector) effectiveNetID(id route.NetID) route.NetID {
|
||||
name := string(id)
|
||||
if !strings.HasSuffix(name, route.V6ExitSuffix) {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.selectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
if _, ok := rs.deselectedRoutes[id]; ok {
|
||||
return id
|
||||
}
|
||||
return route.NetID(strings.TrimSuffix(name, route.V6ExitSuffix))
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isSelectedLocked(routeID route.NetID) bool {
|
||||
if rs.deselectAll {
|
||||
return false
|
||||
}
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return !deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) isDeselectedLocked(netID route.NetID) bool {
|
||||
if rs.deselectAll {
|
||||
return true
|
||||
}
|
||||
_, deselected := rs.deselectedRoutes[netID]
|
||||
return deselected || rs.deselectAll
|
||||
return deselected
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) hasUserSelectionForRouteLocked(routeID route.NetID) bool {
|
||||
_, selected := rs.selectedRoutes[routeID]
|
||||
_, deselected := rs.deselectedRoutes[routeID]
|
||||
return selected || deselected
|
||||
}
|
||||
|
||||
func isExitNode(rt []*route.Route) bool {
|
||||
return len(rt) > 0 && rt[0].Network.String() == exitNodeCIDR
|
||||
return len(rt) > 0 && (route.IsV4DefaultRoute(rt[0].Network) || route.IsV6DefaultRoute(rt[0].Network))
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) applyExitNodeFilter(
|
||||
@@ -204,26 +229,23 @@ func (rs *RouteSelector) applyExitNodeFilter(
|
||||
rt []*route.Route,
|
||||
out route.HAMap,
|
||||
) {
|
||||
|
||||
if rs.hasUserSelections() {
|
||||
// user made explicit selects/deselects
|
||||
if rs.IsSelected(netID) {
|
||||
// Exit-node path: apply the v4/v6 pair mirror so a deselect on the v4 base also
|
||||
// drops the synthesized v6 entry that lacks its own explicit state.
|
||||
effective := rs.effectiveNetID(netID)
|
||||
if rs.hasUserSelectionForRouteLocked(effective) {
|
||||
if rs.isSelectedLocked(effective) {
|
||||
out[id] = rt
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// no explicit selections: only include routes marked !SkipAutoApply (=AutoApply)
|
||||
// no explicit selection for this route: defer to management's SkipAutoApply flag
|
||||
sel := collectSelected(rt)
|
||||
if len(sel) > 0 {
|
||||
out[id] = sel
|
||||
}
|
||||
}
|
||||
|
||||
func (rs *RouteSelector) hasUserSelections() bool {
|
||||
return len(rs.selectedRoutes) > 0 || len(rs.deselectedRoutes) > 0
|
||||
}
|
||||
|
||||
func collectSelected(rt []*route.Route) []*route.Route {
|
||||
var sel []*route.Route
|
||||
for _, r := range rt {
|
||||
|
||||
@@ -330,6 +330,137 @@ func TestRouteSelector_FilterSelectedExitNodes(t *testing.T) {
|
||||
assert.Len(t, filtered, 0) // No routes should be selected
|
||||
}
|
||||
|
||||
// TestRouteSelector_V6ExitPairInherits covers the v4/v6 exit-node pair selection
|
||||
// mirror. The mirror is scoped to exit-node code paths: HasUserSelectionForRoute
|
||||
// and FilterSelectedExitNodes resolve a "-v6" entry without explicit state to its
|
||||
// v4 base, so legacy persisted selections that predate v6 pairing transparently
|
||||
// apply to the synthesized v6 entry. General lookups (IsSelected, FilterSelected)
|
||||
// stay literal so unrelated routes named "*-v6" don't inherit unrelated state.
|
||||
func TestRouteSelector_V6ExitPairInherits(t *testing.T) {
|
||||
all := []route.NetID{"exit1", "exit1-v6", "exit2", "exit2-v6", "corp", "corp-v6"}
|
||||
|
||||
t.Run("HasUserSelectionForRoute mirrors deselected v4 base", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
assert.True(t, rs.HasUserSelectionForRoute("exit1-v6"), "v6 pair sees v4 base's user selection")
|
||||
|
||||
// unrelated v6 with no v4 base touched is unaffected
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit2-v6"))
|
||||
})
|
||||
|
||||
t.Run("IsSelected stays literal for non-exit lookups", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
|
||||
|
||||
// A non-exit route literally named "corp-v6" must not inherit "corp"'s state
|
||||
// via the mirror; the mirror only applies in exit-node code paths.
|
||||
assert.False(t, rs.IsSelected("corp"))
|
||||
assert.True(t, rs.IsSelected("corp-v6"), "non-exit *-v6 routes must not inherit unrelated v4 state")
|
||||
})
|
||||
|
||||
t.Run("explicit v6 state overrides v4 base in filter", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
require.NoError(t, rs.SelectRoutes([]route.NetID{"exit1-v6"}, true, all))
|
||||
|
||||
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
|
||||
routes := route.HAMap{
|
||||
"exit1|0.0.0.0/0": {v4Route},
|
||||
"exit1-v6|::/0": {v6Route},
|
||||
}
|
||||
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.NotContains(t, filtered, route.HAUniqueID("exit1|0.0.0.0/0"))
|
||||
assert.Contains(t, filtered, route.HAUniqueID("exit1-v6|::/0"), "explicit v6 select wins over v4 base")
|
||||
})
|
||||
|
||||
t.Run("non-v6-suffix routes unaffected", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
// A route literally named "exit1-something" must not pair-resolve.
|
||||
assert.False(t, rs.HasUserSelectionForRoute("exit1-something"))
|
||||
})
|
||||
|
||||
t.Run("filter v6 paired with deselected v4 base", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"exit1"}, all))
|
||||
|
||||
v4Route := &route.Route{NetID: "exit1", Network: netip.MustParsePrefix("0.0.0.0/0")}
|
||||
v6Route := &route.Route{NetID: "exit1-v6", Network: netip.MustParsePrefix("::/0")}
|
||||
routes := route.HAMap{
|
||||
"exit1|0.0.0.0/0": {v4Route},
|
||||
"exit1-v6|::/0": {v6Route},
|
||||
}
|
||||
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.Empty(t, filtered, "deselecting v4 base must also drop the v6 pair")
|
||||
})
|
||||
|
||||
t.Run("non-exit *-v6 routes pass through FilterSelectedExitNodes", func(t *testing.T) {
|
||||
rs := routeselector.NewRouteSelector()
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"corp"}, all))
|
||||
|
||||
// A non-default-route entry named "corp-v6" is not an exit node and
|
||||
// must not be skipped because its v4 base "corp" is deselected.
|
||||
corpV6 := &route.Route{NetID: "corp-v6", Network: netip.MustParsePrefix("10.0.0.0/8")}
|
||||
routes := route.HAMap{
|
||||
"corp-v6|10.0.0.0/8": {corpV6},
|
||||
}
|
||||
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.Contains(t, filtered, route.HAUniqueID("corp-v6|10.0.0.0/8"),
|
||||
"non-exit *-v6 routes must not inherit unrelated v4 state in FilterSelectedExitNodes")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRouteSelector_SkipAutoApplyPerRoute verifies that management's
|
||||
// SkipAutoApply flag governs each untouched route independently, even when
|
||||
// the user has explicit selections on other routes.
|
||||
func TestRouteSelector_SkipAutoApplyPerRoute(t *testing.T) {
|
||||
autoApplied := &route.Route{
|
||||
NetID: "Auto",
|
||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||
SkipAutoApply: false,
|
||||
}
|
||||
skipApply := &route.Route{
|
||||
NetID: "Skip",
|
||||
Network: netip.MustParsePrefix("0.0.0.0/0"),
|
||||
SkipAutoApply: true,
|
||||
}
|
||||
routes := route.HAMap{
|
||||
"Auto|0.0.0.0/0": {autoApplied},
|
||||
"Skip|0.0.0.0/0": {skipApply},
|
||||
}
|
||||
|
||||
rs := routeselector.NewRouteSelector()
|
||||
// User makes an unrelated explicit selection elsewhere.
|
||||
require.NoError(t, rs.DeselectRoutes([]route.NetID{"Unrelated"}, []route.NetID{"Auto", "Skip", "Unrelated"}))
|
||||
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.Contains(t, filtered, route.HAUniqueID("Auto|0.0.0.0/0"), "AutoApply route should be included")
|
||||
assert.NotContains(t, filtered, route.HAUniqueID("Skip|0.0.0.0/0"), "SkipAutoApply route should be excluded without explicit user selection")
|
||||
}
|
||||
|
||||
// TestRouteSelector_V6ExitIsExitNode verifies that ::/0 routes are recognized
|
||||
// as exit nodes by the selector's filter path.
|
||||
func TestRouteSelector_V6ExitIsExitNode(t *testing.T) {
|
||||
v6Exit := &route.Route{
|
||||
NetID: "V6Only",
|
||||
Network: netip.MustParsePrefix("::/0"),
|
||||
SkipAutoApply: true,
|
||||
}
|
||||
routes := route.HAMap{
|
||||
"V6Only|::/0": {v6Exit},
|
||||
}
|
||||
|
||||
rs := routeselector.NewRouteSelector()
|
||||
filtered := rs.FilterSelectedExitNodes(routes)
|
||||
assert.Empty(t, filtered, "::/0 should be treated as an exit node and respect SkipAutoApply")
|
||||
}
|
||||
|
||||
func TestRouteSelector_NewRoutesBehavior(t *testing.T) {
|
||||
initialRoutes := []route.NetID{"route1", "route2", "route3"}
|
||||
newRoutes := []route.NetID{"route1", "route2", "route3", "route4", "route5"}
|
||||
|
||||
@@ -188,7 +188,9 @@ func (d *Detector) triggerCallback(event EventType, cb func(event EventType), do
|
||||
}
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
timeout := time.NewTimer(500 * time.Millisecond)
|
||||
// macOS forces sleep ~30s after kIOMessageSystemWillSleep, so block long
|
||||
// enough for teardown to finish while staying under that deadline.
|
||||
timeout := time.NewTimer(20 * time.Second)
|
||||
defer timeout.Stop()
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -74,6 +74,14 @@ func New(filePath string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// FilePath returns the path of the underlying state file.
|
||||
func (m *Manager) FilePath() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.filePath
|
||||
}
|
||||
|
||||
// Start starts the state manager periodic save routine
|
||||
func (m *Manager) Start() {
|
||||
if m == nil {
|
||||
|
||||
@@ -162,11 +162,7 @@ func (c *Client) Run(fd int32, interfaceName string, envList *EnvList) error {
|
||||
cfg.WgIface = interfaceName
|
||||
|
||||
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
|
||||
hostDNS := []netip.AddrPort{
|
||||
netip.MustParseAddrPort("9.9.9.9:53"),
|
||||
netip.MustParseAddrPort("149.112.112.112:53"),
|
||||
}
|
||||
return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, hostDNS, c.stateFile)
|
||||
return c.connectClient.RunOniOS(fd, c.networkChangeListener, c.dnsManager, c.stateFile)
|
||||
}
|
||||
|
||||
// Stop the internal client and free the resources
|
||||
|
||||
@@ -64,6 +64,13 @@
|
||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
<!-- Drop the HKCU Run\Netbird value written by legacy NSIS installers. -->
|
||||
<Component Id="NetbirdLegacyHKCUCleanup" Guid="*">
|
||||
<RegistryValue Root="HKCU" Key="Software\NetBird GmbH\Installer"
|
||||
Name="LegacyHKCUCleanup" Type="integer" Value="1" KeyPath="yes" />
|
||||
<RemoveRegistryValue Root="HKCU"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
||||
</Component>
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
@@ -76,10 +83,28 @@
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<!-- Drop Run, App Paths and Uninstall entries written by legacy NSIS
|
||||
installers into the 32-bit registry view (HKLM\Software\Wow6432Node). -->
|
||||
<Component Id="NetbirdLegacyWow6432Cleanup" Directory="NetbirdInstallDir"
|
||||
Guid="bda5d628-16bd-4086-b2c1-5099d8d51763" Bitness="always32">
|
||||
<RegistryValue Root="HKLM" Key="Software\NetBird GmbH\Installer"
|
||||
Name="LegacyWow6432Cleanup" Type="integer" Value="1" KeyPath="yes" />
|
||||
<RemoveRegistryValue Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird" />
|
||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird-ui" />
|
||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Netbird" />
|
||||
</Component>
|
||||
|
||||
<ComponentGroup Id="NetbirdFilesComponent">
|
||||
<ComponentRef Id="NetbirdFiles" />
|
||||
<ComponentRef Id="NetbirdAumidRegistry" />
|
||||
<ComponentRef Id="NetbirdAutoStart" />
|
||||
<ComponentRef Id="NetbirdLegacyHKCUCleanup" />
|
||||
<ComponentRef Id="NetbirdLegacyWow6432Cleanup" />
|
||||
</ComponentGroup>
|
||||
|
||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,8 @@ message LoginRequest {
|
||||
optional bool disableSSHAuth = 38;
|
||||
optional int32 sshJWTCacheTTL = 39;
|
||||
optional bool disable_ipv6 = 40;
|
||||
|
||||
optional bool serverVNCAllowed = 41;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -314,6 +316,8 @@ message GetConfigResponse {
|
||||
int32 sshJWTCacheTTL = 26;
|
||||
|
||||
bool disable_ipv6 = 27;
|
||||
|
||||
bool serverVNCAllowed = 28;
|
||||
}
|
||||
|
||||
// PeerState contains the latest state of a peer
|
||||
@@ -394,6 +398,22 @@ message SSHServerState {
|
||||
repeated SSHSessionInfo sessions = 2;
|
||||
}
|
||||
|
||||
// VNCSessionInfo contains information about an active VNC session
|
||||
message VNCSessionInfo {
|
||||
string remoteAddress = 1;
|
||||
string mode = 2;
|
||||
string username = 3;
|
||||
// userID is the Noise-verified session identity (hashed user ID from
|
||||
// the ACL session-key entry), empty when auth is disabled.
|
||||
string userID = 4;
|
||||
}
|
||||
|
||||
// VNCServerState contains the latest state of the VNC server
|
||||
message VNCServerState {
|
||||
bool enabled = 1;
|
||||
repeated VNCSessionInfo sessions = 2;
|
||||
}
|
||||
|
||||
// FullStatus contains the full state held by the Status instance
|
||||
message FullStatus {
|
||||
ManagementState managementState = 1;
|
||||
@@ -408,6 +428,7 @@ message FullStatus {
|
||||
|
||||
bool lazyConnectionEnabled = 9;
|
||||
SSHServerState sshServerState = 10;
|
||||
VNCServerState vncServerState = 11;
|
||||
}
|
||||
|
||||
// Networks
|
||||
@@ -678,6 +699,8 @@ message SetConfigRequest {
|
||||
optional bool disableSSHAuth = 33;
|
||||
optional int32 sshJWTCacheTTL = 34;
|
||||
optional bool disable_ipv6 = 35;
|
||||
|
||||
optional bool serverVNCAllowed = 36;
|
||||
}
|
||||
|
||||
message SetConfigResponse{}
|
||||
|
||||
@@ -376,6 +376,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||
config.ServerVNCAllowed = msg.ServerVNCAllowed
|
||||
config.NetworkMonitor = msg.NetworkMonitor
|
||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||
@@ -1136,6 +1137,7 @@ func (s *Server) Status(
|
||||
pbFullStatus := fullStatus.ToProto()
|
||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||
pbFullStatus.VncServerState = s.getVNCServerState()
|
||||
statusResponse.FullStatus = pbFullStatus
|
||||
}
|
||||
|
||||
@@ -1175,6 +1177,37 @@ func (s *Server) getSSHServerState() *proto.SSHServerState {
|
||||
return sshServerState
|
||||
}
|
||||
|
||||
// getVNCServerState retrieves the current VNC server state.
|
||||
func (s *Server) getVNCServerState() *proto.VNCServerState {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if connectClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabled, sessions := engine.GetVNCServerStatus()
|
||||
pbSessions := make([]*proto.VNCSessionInfo, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
pbSessions = append(pbSessions, &proto.VNCSessionInfo{
|
||||
RemoteAddress: sess.RemoteAddress,
|
||||
Mode: sess.Mode,
|
||||
Username: sess.Username,
|
||||
UserID: sess.UserID,
|
||||
})
|
||||
}
|
||||
return &proto.VNCServerState{
|
||||
Enabled: enabled,
|
||||
Sessions: pbSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
||||
func (s *Server) GetPeerSSHHostKey(
|
||||
ctx context.Context,
|
||||
@@ -1531,6 +1564,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
Mtu: int64(cfg.MTU),
|
||||
DisableAutoConnect: cfg.DisableAutoConnect,
|
||||
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
||||
ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed,
|
||||
RosenpassEnabled: cfg.RosenpassEnabled,
|
||||
RosenpassPermissive: cfg.RosenpassPermissive,
|
||||
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
||||
|
||||
@@ -58,6 +58,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
rosenpassEnabled := true
|
||||
rosenpassPermissive := true
|
||||
serverSSHAllowed := true
|
||||
serverVNCAllowed := true
|
||||
interfaceName := "utun100"
|
||||
wireguardPort := int64(51820)
|
||||
preSharedKey := "test-psk"
|
||||
@@ -83,6 +84,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
RosenpassPermissive: &rosenpassPermissive,
|
||||
ServerSSHAllowed: &serverSSHAllowed,
|
||||
ServerVNCAllowed: &serverVNCAllowed,
|
||||
InterfaceName: &interfaceName,
|
||||
WireguardPort: &wireguardPort,
|
||||
OptionalPreSharedKey: &preSharedKey,
|
||||
@@ -127,6 +129,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
|
||||
require.NotNil(t, cfg.ServerVNCAllowed)
|
||||
require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed)
|
||||
require.Equal(t, interfaceName, cfg.WgIface)
|
||||
require.Equal(t, int(wireguardPort), cfg.WgPort)
|
||||
require.Equal(t, preSharedKey, cfg.PreSharedKey)
|
||||
@@ -179,6 +183,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"RosenpassEnabled": true,
|
||||
"RosenpassPermissive": true,
|
||||
"ServerSSHAllowed": true,
|
||||
"ServerVNCAllowed": true,
|
||||
"InterfaceName": true,
|
||||
"WireguardPort": true,
|
||||
"OptionalPreSharedKey": true,
|
||||
@@ -240,6 +245,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
||||
"enable-rosenpass": "RosenpassEnabled",
|
||||
"rosenpass-permissive": "RosenpassPermissive",
|
||||
"allow-server-ssh": "ServerSSHAllowed",
|
||||
"allow-server-vnc": "ServerVNCAllowed",
|
||||
"interface-name": "InterfaceName",
|
||||
"wireguard-port": "WireguardPort",
|
||||
"preshared-key": "OptionalPreSharedKey",
|
||||
|
||||
@@ -15,13 +15,16 @@ const (
|
||||
DefaultUserIDClaim = "sub"
|
||||
// Wildcard is a special user ID that matches all users
|
||||
Wildcard = "*"
|
||||
// sessionPubKeyLen is the size of an X25519 static public key in bytes.
|
||||
sessionPubKeyLen = 32
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyUserID = errors.New("JWT user ID is empty")
|
||||
ErrUserNotAuthorized = errors.New("user is not authorized to access this peer")
|
||||
ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user")
|
||||
ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user")
|
||||
ErrEmptyUserID = errors.New("JWT user ID is empty")
|
||||
ErrUserNotAuthorized = errors.New("user is not authorized to access this peer")
|
||||
ErrNoMachineUserMapping = errors.New("no authorization mapping for OS user")
|
||||
ErrUserNotMappedToOSUser = errors.New("user is not authorized to login as OS user")
|
||||
ErrSessionKeyNotKnown = errors.New("session pubkey not registered")
|
||||
)
|
||||
|
||||
// Authorizer handles SSH fine-grained access control authorization
|
||||
@@ -35,6 +38,12 @@ type Authorizer struct {
|
||||
// machineUsers maps OS login usernames to lists of authorized user indexes
|
||||
machineUsers map[string][]uint32
|
||||
|
||||
// sessionPubKeys maps an X25519 static public key (as map-safe
|
||||
// array) to the hashed user identity that key authenticates as.
|
||||
// Populated from management's temporary-access flow; used by VNC to
|
||||
// authenticate via the Noise_IK handshake.
|
||||
sessionPubKeys map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash
|
||||
|
||||
// mu protects the list of users
|
||||
mu sync.RWMutex
|
||||
}
|
||||
@@ -50,13 +59,25 @@ type Config struct {
|
||||
// MachineUsers maps OS login usernames to indexes in AuthorizedUsers
|
||||
// If a user wants to login as a specific OS user, their index must be in the corresponding list
|
||||
MachineUsers map[string][]uint32
|
||||
|
||||
// SessionPubKeys binds ephemeral X25519 static public keys to hashed
|
||||
// user identities. Populated for VNC; ignored on the SSH side.
|
||||
SessionPubKeys []SessionPubKey
|
||||
}
|
||||
|
||||
// SessionPubKey is a single ephemeral-key entry: the 32-byte X25519
|
||||
// static public key plus the hashed user identity it authenticates as.
|
||||
type SessionPubKey struct {
|
||||
PubKey []byte
|
||||
UserIDHash sshuserhash.UserIDHash
|
||||
}
|
||||
|
||||
// NewAuthorizer creates a new SSH authorizer with empty configuration
|
||||
func NewAuthorizer() *Authorizer {
|
||||
a := &Authorizer{
|
||||
userIDClaim: DefaultUserIDClaim,
|
||||
machineUsers: make(map[string][]uint32),
|
||||
userIDClaim: DefaultUserIDClaim,
|
||||
machineUsers: make(map[string][]uint32),
|
||||
sessionPubKeys: make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash),
|
||||
}
|
||||
|
||||
return a
|
||||
@@ -72,6 +93,7 @@ func (a *Authorizer) Update(config *Config) {
|
||||
a.userIDClaim = DefaultUserIDClaim
|
||||
a.authorizedUsers = []sshuserhash.UserIDHash{}
|
||||
a.machineUsers = make(map[string][]uint32)
|
||||
a.sessionPubKeys = make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash)
|
||||
log.Info("SSH authorization cleared")
|
||||
return
|
||||
}
|
||||
@@ -94,8 +116,29 @@ func (a *Authorizer) Update(config *Config) {
|
||||
}
|
||||
a.machineUsers = machineUsers
|
||||
|
||||
log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings",
|
||||
len(config.AuthorizedUsers), len(machineUsers))
|
||||
sessionPubKeys := make(map[[sessionPubKeyLen]byte]sshuserhash.UserIDHash, len(config.SessionPubKeys))
|
||||
conflicted := make(map[[sessionPubKeyLen]byte]struct{})
|
||||
for _, e := range config.SessionPubKeys {
|
||||
if len(e.PubKey) != sessionPubKeyLen {
|
||||
continue
|
||||
}
|
||||
var key [sessionPubKeyLen]byte
|
||||
copy(key[:], e.PubKey)
|
||||
if _, bad := conflicted[key]; bad {
|
||||
continue
|
||||
}
|
||||
if existing, ok := sessionPubKeys[key]; ok && existing != e.UserIDHash {
|
||||
log.Warnf("SSH auth: session pubkey bound to conflicting user hashes; dropping binding")
|
||||
delete(sessionPubKeys, key)
|
||||
conflicted[key] = struct{}{}
|
||||
continue
|
||||
}
|
||||
sessionPubKeys[key] = e.UserIDHash
|
||||
}
|
||||
a.sessionPubKeys = sessionPubKeys
|
||||
|
||||
log.Debugf("SSH auth: updated with %d authorized users, %d machine user mappings, %d session pubkeys",
|
||||
len(config.AuthorizedUsers), len(machineUsers), len(sessionPubKeys))
|
||||
}
|
||||
|
||||
// Authorize validates if a user is authorized to login as the specified OS user.
|
||||
@@ -155,6 +198,38 @@ func (a *Authorizer) GetUserIDClaim() string {
|
||||
return a.userIDClaim
|
||||
}
|
||||
|
||||
// LookupSessionKey resolves a Noise-verified static public key to the
|
||||
// hashed user identity registered with it. Fails closed when the key is
|
||||
// unknown.
|
||||
func (a *Authorizer) LookupSessionKey(pubKey []byte) (sshuserhash.UserIDHash, error) {
|
||||
var zero sshuserhash.UserIDHash
|
||||
if len(pubKey) != sessionPubKeyLen {
|
||||
return zero, fmt.Errorf("session pubkey wrong length: %d", len(pubKey))
|
||||
}
|
||||
var key [sessionPubKeyLen]byte
|
||||
copy(key[:], pubKey)
|
||||
a.mu.RLock()
|
||||
hash, ok := a.sessionPubKeys[key]
|
||||
a.mu.RUnlock()
|
||||
if !ok {
|
||||
return zero, ErrSessionKeyNotKnown
|
||||
}
|
||||
return hash, nil
|
||||
}
|
||||
|
||||
// AuthorizeOSUserBySessionKey resolves the OS-user mapping for a session
|
||||
// key. Mirrors Authorize but skips the JWT-hash step since the key has
|
||||
// already been verified and the user identity hash is in hand.
|
||||
func (a *Authorizer) AuthorizeOSUserBySessionKey(userIDHash sshuserhash.UserIDHash, osUsername string) (string, error) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
userIndex, found := a.findUserIndex(userIDHash)
|
||||
if !found {
|
||||
return "", fmt.Errorf("session user (hash: %s) not in authorized list for OS user %q: %w", userIDHash, osUsername, ErrUserNotAuthorized)
|
||||
}
|
||||
return a.checkMachineUserMapping("session", osUsername, userIndex)
|
||||
}
|
||||
|
||||
// findUserIndex finds the index of a hashed user ID in the authorized users list
|
||||
// Returns the index and true if found, 0 and false if not found
|
||||
func (a *Authorizer) findUserIndex(hashedUserID sshuserhash.UserIDHash) (int, bool) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -610,3 +611,61 @@ func TestAuthorizer_Wildcard_WithPartialIndexes_AllowsAllUsers(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrUserNotAuthorized, "unauthorized user should be denied")
|
||||
}
|
||||
|
||||
func TestAuthorizer_LookupSessionKey_Valid(t *testing.T) {
|
||||
pub := bytesRepeat(0x11, sessionPubKeyLen)
|
||||
userHash, err := sshauth.HashUserID("alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
a := NewAuthorizer()
|
||||
a.Update(&Config{
|
||||
AuthorizedUsers: []sshauth.UserIDHash{userHash},
|
||||
MachineUsers: map[string][]uint32{Wildcard: {0}},
|
||||
SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}},
|
||||
})
|
||||
|
||||
got, err := a.LookupSessionKey(pub)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, userHash, got)
|
||||
|
||||
if _, err := a.AuthorizeOSUserBySessionKey(got, "alice"); err != nil {
|
||||
t.Fatalf("AuthorizeOSUserBySessionKey: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthorizer_LookupSessionKey_UnknownPub(t *testing.T) {
|
||||
a := NewAuthorizer()
|
||||
a.Update(&Config{})
|
||||
_, err := a.LookupSessionKey(bytesRepeat(0x22, sessionPubKeyLen))
|
||||
require.ErrorIs(t, err, ErrSessionKeyNotKnown)
|
||||
}
|
||||
|
||||
func TestAuthorizer_LookupSessionKey_WrongLength(t *testing.T) {
|
||||
a := NewAuthorizer()
|
||||
_, err := a.LookupSessionKey([]byte("short"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAuthorizer_LookupSessionKey_UpdateClears(t *testing.T) {
|
||||
pub := bytesRepeat(0x33, sessionPubKeyLen)
|
||||
userHash, err := sshauth.HashUserID("alice")
|
||||
require.NoError(t, err)
|
||||
|
||||
a := NewAuthorizer()
|
||||
a.Update(&Config{SessionPubKeys: []SessionPubKey{{PubKey: pub, UserIDHash: userHash}}})
|
||||
if _, err := a.LookupSessionKey(pub); err != nil {
|
||||
t.Fatalf("setup lookup: %v", err)
|
||||
}
|
||||
a.Update(&Config{})
|
||||
if _, err := a.LookupSessionKey(pub); !errors.Is(err, ErrSessionKeyNotKnown) {
|
||||
t.Fatalf("expected ErrSessionKeyNotKnown, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func bytesRepeat(b byte, n int) []byte {
|
||||
out := make([]byte, n)
|
||||
for i := range out {
|
||||
out[i] = b
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -252,6 +252,10 @@ func (m *Manager) writeSSHConfig(sshConfig string) error {
|
||||
return fmt.Errorf("write SSH config file %s: %w", tmpPath, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(tmpPath, 0644); err != nil {
|
||||
return fmt.Errorf("chmod SSH config file %s: %w", tmpPath, err)
|
||||
}
|
||||
|
||||
if err := os.Rename(tmpPath, sshConfigPath); err != nil {
|
||||
return fmt.Errorf("rename SSH config %s -> %s: %w", tmpPath, sshConfigPath, err)
|
||||
}
|
||||
|
||||
@@ -131,6 +131,18 @@ type SSHServerStateOutput struct {
|
||||
Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type VNCSessionOutput struct {
|
||||
RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
UserID string `json:"userID,omitempty" yaml:"userID,omitempty"`
|
||||
}
|
||||
|
||||
type VNCServerStateOutput struct {
|
||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||
Sessions []VNCSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type OutputOverview struct {
|
||||
Peers PeersStateOutput `json:"peers" yaml:"peers"`
|
||||
CliVersion string `json:"cliVersion" yaml:"cliVersion"`
|
||||
@@ -153,6 +165,7 @@ type OutputOverview struct {
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
||||
ProfileName string `json:"profileName" yaml:"profileName"`
|
||||
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
||||
VNCServerState VNCServerStateOutput `json:"vncServer" yaml:"vncServer"`
|
||||
}
|
||||
|
||||
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
|
||||
@@ -173,6 +186,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
relayOverview := mapRelays(pbFullStatus.GetRelays())
|
||||
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
|
||||
vncServerOverview := mapVNCServer(pbFullStatus.GetVncServerState())
|
||||
peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter)
|
||||
|
||||
overview := OutputOverview{
|
||||
@@ -197,6 +211,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(),
|
||||
ProfileName: opts.ProfileName,
|
||||
SSHServerState: sshServerOverview,
|
||||
VNCServerState: vncServerOverview,
|
||||
}
|
||||
|
||||
if opts.Anonymize {
|
||||
@@ -271,6 +286,25 @@ func mapSSHServer(sshServerState *proto.SSHServerState) SSHServerStateOutput {
|
||||
}
|
||||
}
|
||||
|
||||
func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput {
|
||||
if state == nil {
|
||||
return VNCServerStateOutput{Sessions: []VNCSessionOutput{}}
|
||||
}
|
||||
sessions := make([]VNCSessionOutput, 0, len(state.GetSessions()))
|
||||
for _, sess := range state.GetSessions() {
|
||||
sessions = append(sessions, VNCSessionOutput{
|
||||
RemoteAddress: sess.GetRemoteAddress(),
|
||||
Mode: sess.GetMode(),
|
||||
Username: sess.GetUsername(),
|
||||
UserID: sess.GetUserID(),
|
||||
})
|
||||
}
|
||||
return VNCServerStateOutput{
|
||||
Enabled: state.GetEnabled(),
|
||||
Sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
func mapPeers(
|
||||
peers []*proto.PeerState,
|
||||
statusFilter string,
|
||||
@@ -533,6 +567,34 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
}
|
||||
}
|
||||
|
||||
vncServerStatus := "Disabled"
|
||||
if o.VNCServerState.Enabled {
|
||||
vncSessionCount := len(o.VNCServerState.Sessions)
|
||||
if vncSessionCount > 0 {
|
||||
sessionWord := "session"
|
||||
if vncSessionCount > 1 {
|
||||
sessionWord = "sessions"
|
||||
}
|
||||
vncServerStatus = fmt.Sprintf("Enabled (%d active %s)", vncSessionCount, sessionWord)
|
||||
} else {
|
||||
vncServerStatus = "Enabled"
|
||||
}
|
||||
|
||||
if showSSHSessions && vncSessionCount > 0 {
|
||||
for _, sess := range o.VNCServerState.Sessions {
|
||||
var line string
|
||||
if sess.UserID != "" {
|
||||
line = fmt.Sprintf("[%s@%s -> %s] mode=%s",
|
||||
sess.UserID, sess.RemoteAddress, sess.Username, sess.Mode)
|
||||
} else {
|
||||
line = fmt.Sprintf("[%s] mode=%s user=%s",
|
||||
sess.RemoteAddress, sess.Mode, sess.Username)
|
||||
}
|
||||
vncServerStatus += "\n " + line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||
|
||||
var forwardingRulesString string
|
||||
@@ -563,6 +625,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"Quantum resistance: %s\n"+
|
||||
"Lazy connection: %s\n"+
|
||||
"SSH Server: %s\n"+
|
||||
"VNC Server: %s\n"+
|
||||
"Networks: %s\n"+
|
||||
"%s"+
|
||||
"Peers count: %s\n",
|
||||
@@ -581,6 +644,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
vncServerStatus,
|
||||
networks,
|
||||
forwardingRulesString,
|
||||
peersCountString,
|
||||
@@ -960,6 +1024,19 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.Relays.Details[i] = detail
|
||||
}
|
||||
|
||||
anonymizeNSServerGroups(a, overview)
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
anonymizeEvents(a, overview)
|
||||
anonymizeServerSessions(a, overview)
|
||||
}
|
||||
|
||||
func anonymizeNSServerGroups(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, nsGroup := range overview.NSServerGroups {
|
||||
for j, domain := range nsGroup.Domains {
|
||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
||||
@@ -971,13 +1048,9 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, event := range overview.Events {
|
||||
overview.Events[i].Message = a.AnonymizeString(event.Message)
|
||||
overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
|
||||
@@ -986,13 +1059,23 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
event.Metadata[k] = a.AnonymizeString(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func anonymizeRemoteAddress(a *anonymize.Anonymizer, addr string) string {
|
||||
if host, port, err := net.SplitHostPort(addr); err == nil {
|
||||
return fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
}
|
||||
return a.AnonymizeIPString(addr)
|
||||
}
|
||||
|
||||
func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, session := range overview.SSHServerState.Sessions {
|
||||
if host, port, err := net.SplitHostPort(session.RemoteAddress); err == nil {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
} else {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(session.RemoteAddress)
|
||||
}
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, session.RemoteAddress)
|
||||
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
||||
}
|
||||
for i, sess := range overview.VNCServerState.Sessions {
|
||||
overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress)
|
||||
overview.VNCServerState.Sessions[i].Username = a.AnonymizeString(sess.Username)
|
||||
overview.VNCServerState.Sessions[i].UserID = a.AnonymizeString(sess.UserID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,10 @@ var overview = OutputOverview{
|
||||
Enabled: false,
|
||||
Sessions: []SSHSessionOutput{},
|
||||
},
|
||||
VNCServerState: VNCServerStateOutput{
|
||||
Enabled: false,
|
||||
Sessions: []VNCSessionOutput{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
|
||||
@@ -404,6 +408,10 @@ func TestParsingToJSON(t *testing.T) {
|
||||
"sshServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
},
|
||||
"vncServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
}
|
||||
}`
|
||||
// @formatter:on
|
||||
@@ -513,6 +521,9 @@ profileName: ""
|
||||
sshServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
vncServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
`
|
||||
|
||||
assert.Equal(t, expectedYAML, yaml)
|
||||
@@ -582,6 +593,7 @@ Interface type: Kernel
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
||||
@@ -607,6 +619,7 @@ Interface type: Kernel
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`
|
||||
|
||||
@@ -62,6 +62,7 @@ type Info struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -83,6 +84,7 @@ type Info struct {
|
||||
func (i *Info) SetFlags(
|
||||
rosenpassEnabled, rosenpassPermissive bool,
|
||||
serverSSHAllowed *bool,
|
||||
serverVNCAllowed *bool,
|
||||
disableClientRoutes, disableServerRoutes,
|
||||
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
|
||||
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
||||
@@ -93,6 +95,9 @@ func (i *Info) SetFlags(
|
||||
if serverSSHAllowed != nil {
|
||||
i.ServerSSHAllowed = *serverSSHAllowed
|
||||
}
|
||||
if serverVNCAllowed != nil {
|
||||
i.ServerVNCAllowed = *serverVNCAllowed
|
||||
}
|
||||
|
||||
i.DisableClientRoutes = disableClientRoutes
|
||||
i.DisableServerRoutes = disableServerRoutes
|
||||
|
||||
@@ -249,6 +249,7 @@ type serviceClient struct {
|
||||
mQuit *systray.MenuItem
|
||||
mNetworks *systray.MenuItem
|
||||
mAllowSSH *systray.MenuItem
|
||||
mAllowVNC *systray.MenuItem
|
||||
mAutoConnect *systray.MenuItem
|
||||
mEnableRosenpass *systray.MenuItem
|
||||
mLazyConnEnabled *systray.MenuItem
|
||||
@@ -1045,6 +1046,7 @@ func (s *serviceClient) onTrayReady() {
|
||||
|
||||
s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr)
|
||||
s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false)
|
||||
s.mAllowVNC = s.mSettings.AddSubMenuItemCheckbox("Allow VNC", allowVNCMenuDescr, false)
|
||||
s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false)
|
||||
s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false)
|
||||
s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false)
|
||||
@@ -1452,6 +1454,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
|
||||
|
||||
config.DisableAutoConnect = cfg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = &cfg.ServerSSHAllowed
|
||||
config.ServerVNCAllowed = &cfg.ServerVNCAllowed
|
||||
config.RosenpassEnabled = cfg.RosenpassEnabled
|
||||
config.RosenpassPermissive = cfg.RosenpassPermissive
|
||||
config.DisableNotifications = &cfg.DisableNotifications
|
||||
@@ -1547,6 +1550,12 @@ func (s *serviceClient) loadSettings() {
|
||||
s.mAllowSSH.Uncheck()
|
||||
}
|
||||
|
||||
if cfg.ServerVNCAllowed {
|
||||
s.mAllowVNC.Check()
|
||||
} else {
|
||||
s.mAllowVNC.Uncheck()
|
||||
}
|
||||
|
||||
if cfg.DisableAutoConnect {
|
||||
s.mAutoConnect.Uncheck()
|
||||
} else {
|
||||
@@ -1586,6 +1595,7 @@ func (s *serviceClient) loadSettings() {
|
||||
func (s *serviceClient) updateConfig() error {
|
||||
disableAutoStart := !s.mAutoConnect.Checked()
|
||||
sshAllowed := s.mAllowSSH.Checked()
|
||||
vncAllowed := s.mAllowVNC.Checked()
|
||||
rosenpassEnabled := s.mEnableRosenpass.Checked()
|
||||
lazyConnectionEnabled := s.mLazyConnEnabled.Checked()
|
||||
blockInbound := s.mBlockInbound.Checked()
|
||||
@@ -1614,6 +1624,7 @@ func (s *serviceClient) updateConfig() error {
|
||||
Username: currUser.Username,
|
||||
DisableAutoConnect: &disableAutoStart,
|
||||
ServerSSHAllowed: &sshAllowed,
|
||||
ServerVNCAllowed: &vncAllowed,
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
LazyConnectionEnabled: &lazyConnectionEnabled,
|
||||
BlockInbound: &blockInbound,
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
const (
|
||||
allowSSHMenuDescr = "Allow SSH connections"
|
||||
allowVNCMenuDescr = "Allow embedded VNC server"
|
||||
autoConnectMenuDescr = "Connect automatically when the service starts"
|
||||
quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass"
|
||||
lazyConnMenuDescr = "[Experimental] Enable lazy connections"
|
||||
|
||||
@@ -39,6 +39,8 @@ func (h *eventHandler) listen(ctx context.Context) {
|
||||
h.handleDisconnectClick()
|
||||
case <-h.client.mAllowSSH.ClickedCh:
|
||||
h.handleAllowSSHClick()
|
||||
case <-h.client.mAllowVNC.ClickedCh:
|
||||
h.handleAllowVNCClick()
|
||||
case <-h.client.mAutoConnect.ClickedCh:
|
||||
h.handleAutoConnectClick()
|
||||
case <-h.client.mEnableRosenpass.ClickedCh:
|
||||
@@ -134,6 +136,15 @@ func (h *eventHandler) handleAllowSSHClick() {
|
||||
|
||||
}
|
||||
|
||||
func (h *eventHandler) handleAllowVNCClick() {
|
||||
h.toggleCheckbox(h.client.mAllowVNC)
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mAllowVNC) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.notifier.Send("Error", "Failed to update VNC settings")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHandler) handleAutoConnectClick() {
|
||||
h.toggleCheckbox(h.client.mAutoConnect)
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
|
||||
@@ -193,7 +193,15 @@ func getOverlappingNetworks(routes []*proto.Network) []*proto.Network {
|
||||
}
|
||||
|
||||
func isDefaultRoute(routeRange string) bool {
|
||||
return routeRange == "0.0.0.0/0" || routeRange == "::/0"
|
||||
// routeRange is the merged display string from the daemon, e.g. "0.0.0.0/0",
|
||||
// "::/0", or "0.0.0.0/0, ::/0" when a v4 exit node has a paired v6 entry.
|
||||
for _, part := range strings.Split(routeRange, ",") {
|
||||
switch strings.TrimSpace(part) {
|
||||
case "0.0.0.0/0", "::/0":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getExitNodeNetworks(routes []*proto.Network) []*proto.Network {
|
||||
|
||||
327
client/vnc/server/agent_darwin.go
Normal file
327
client/vnc/server/agent_darwin.go
Normal file
@@ -0,0 +1,327 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// darwinAgentManager spawns a per-user VNC agent on demand and keeps it
|
||||
// alive across multiple client connections within the same console-user
|
||||
// session. A new agent is spawned the first time a client connects, or
|
||||
// whenever the console user changes underneath us.
|
||||
//
|
||||
// Lifecycle is lazy by design: a daemon that never receives a VNC
|
||||
// connection never spawns anything. The trade-off versus an eager spawn
|
||||
// (the Windows model) is that the first VNC client pays the launchctl
|
||||
// asuser + listen-readiness wait, ~hundreds of milliseconds in practice.
|
||||
// That cost only repeats on user switch.
|
||||
type darwinAgentManager struct {
|
||||
mu sync.Mutex
|
||||
authToken string
|
||||
port uint16
|
||||
uid uint32
|
||||
running bool
|
||||
}
|
||||
|
||||
func newDarwinAgentManager(ctx context.Context) *darwinAgentManager {
|
||||
m := &darwinAgentManager{port: agentPort}
|
||||
go m.watchConsoleUser(ctx)
|
||||
return m
|
||||
}
|
||||
|
||||
// watchConsoleUser kills the cached agent whenever the console user
|
||||
// changes (logout, fast user switch, login window). Without it the daemon
|
||||
// keeps proxying to an agent whose TCC grant and WindowServer access
|
||||
// belong to a user who is no longer at the screen, so the new user only
|
||||
// ever sees the locked-screen wallpaper. Killing the agent breaks the
|
||||
// loopback TCP that the daemon proxies into, the client disconnects, and
|
||||
// the next reconnect runs ensure() against the new console uid.
|
||||
func (m *darwinAgentManager) watchConsoleUser(ctx context.Context) {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
uid, err := consoleUserID()
|
||||
m.mu.Lock()
|
||||
if !m.running {
|
||||
m.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
if err != nil || uid != m.uid {
|
||||
prev := m.uid
|
||||
m.killLocked()
|
||||
m.mu.Unlock()
|
||||
if err != nil {
|
||||
log.Infof("console user gone (was uid=%d): %v; agent stopped", prev, err)
|
||||
} else {
|
||||
log.Infof("console user changed %d -> %d; agent stopped, will respawn on next connect", prev, uid)
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensure returns a token good for proxyToAgent. It spawns or respawns the
|
||||
// per-user agent process as needed and waits until it is listening on the
|
||||
// loopback port. Each ensure call is serialized so concurrent VNC clients
|
||||
// share the same agent.
|
||||
func (m *darwinAgentManager) ensure(ctx context.Context) (string, error) {
|
||||
consoleUID, err := consoleUserID()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("no console user: %w", err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if m.running && m.uid == consoleUID && vncAgentRunning() {
|
||||
return m.authToken, nil
|
||||
}
|
||||
m.killLocked()
|
||||
// Reap any stray external vnc-agent so the new token is the only one
|
||||
// the freshly spawned agent will accept on the loopback port.
|
||||
killAllVNCAgents()
|
||||
|
||||
token, err := generateAuthToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("generate agent auth token: %w", err)
|
||||
}
|
||||
if err := spawnAgentForUser(consoleUID, m.port, token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := waitForAgent(ctx, m.port, 5*time.Second); err != nil {
|
||||
killAllVNCAgents()
|
||||
return "", fmt.Errorf("agent did not start listening: %w", err)
|
||||
}
|
||||
m.authToken = token
|
||||
m.uid = consoleUID
|
||||
m.running = true
|
||||
log.Infof("spawned VNC agent for console uid=%d on port %d", consoleUID, m.port)
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// stop terminates the spawned agent, if any. Intended for daemon shutdown.
|
||||
func (m *darwinAgentManager) stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.killLocked()
|
||||
}
|
||||
|
||||
func (m *darwinAgentManager) killLocked() {
|
||||
if !m.running {
|
||||
return
|
||||
}
|
||||
killAllVNCAgents()
|
||||
m.running = false
|
||||
m.authToken = ""
|
||||
m.uid = 0
|
||||
}
|
||||
|
||||
// errNoConsoleUser is the sentinel callers use to recognise the
|
||||
// "login window showing, no user signed in" state and surface it as a
|
||||
// distinct condition to the VNC client.
|
||||
var errNoConsoleUser = errors.New("no user logged into console")
|
||||
|
||||
// consoleUserID returns the uid of the user currently sitting at the
|
||||
// console (the one whose Aqua session is active). Returns
|
||||
// errNoConsoleUser when nobody is logged in: at the login window
|
||||
// /dev/console is owned by root.
|
||||
func consoleUserID() (uint32, error) {
|
||||
info, err := os.Stat("/dev/console")
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("stat /dev/console: %w", err)
|
||||
}
|
||||
st, ok := info.Sys().(*syscall.Stat_t)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("/dev/console stat has unexpected type")
|
||||
}
|
||||
if st.Uid == 0 {
|
||||
return 0, errNoConsoleUser
|
||||
}
|
||||
return st.Uid, nil
|
||||
}
|
||||
|
||||
// spawnAgentForUser uses launchctl asuser to start a netbird vnc-agent
|
||||
// process inside the target user's launchd bootstrap namespace. That is
|
||||
// the only spawn mode on macOS that gives the child access to the user's
|
||||
// WindowServer. The agent's stderr is relogged into the daemon log so
|
||||
// startup failures are not silently lost when the readiness check times
|
||||
// out.
|
||||
func spawnAgentForUser(uid uint32, port uint16, token string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve own executable: %w", err)
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"/bin/launchctl", "asuser", strconv.FormatUint(uint64(uid), 10),
|
||||
exe, vncAgentSubcommand, "--port", strconv.FormatUint(uint64(port), 10),
|
||||
)
|
||||
cmd.Env = append(os.Environ(), agentTokenEnvVar+"="+token)
|
||||
stderr, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return fmt.Errorf("agent stderr pipe: %w", err)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("launchctl asuser: %w", err)
|
||||
}
|
||||
go func() {
|
||||
defer stderr.Close()
|
||||
relogAgentStream(stderr)
|
||||
}()
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForAgent dials the loopback port until the agent answers. Used to
|
||||
// gate proxy attempts until the spawned process has finished its Start.
|
||||
func waitForAgent(ctx context.Context, port uint16, wait time.Duration) error {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
deadline := time.Now().Add(wait)
|
||||
for time.Now().Before(deadline) {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
c, err := net.DialTimeout("tcp", addr, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
_ = c.Close()
|
||||
return nil
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("timeout dialing %s", addr)
|
||||
}
|
||||
|
||||
// vncAgentRunning reports whether any vnc-agent process exists on the
|
||||
// system. The daemon owns the only port-15900 listener model, so any
|
||||
// match is "the" agent.
|
||||
func vncAgentRunning() bool {
|
||||
pids, err := vncAgentPIDs()
|
||||
if err != nil {
|
||||
log.Debugf("scan for vnc-agent: %v", err)
|
||||
return false
|
||||
}
|
||||
return len(pids) > 0
|
||||
}
|
||||
|
||||
// killAllVNCAgents sends SIGTERM to every process whose argv contains
|
||||
// "vnc-agent", waits briefly for them to exit, and escalates to SIGKILL
|
||||
// for any that remain. We enumerate kern.proc.all rather than
|
||||
// kern.proc.uid because launchctl asuser preserves the caller's uid
|
||||
// (root) on the spawned child, so a uid-scoped filter would never match.
|
||||
func killAllVNCAgents() {
|
||||
pids, err := vncAgentPIDs()
|
||||
if err != nil {
|
||||
log.Debugf("scan for vnc-agent: %v", err)
|
||||
return
|
||||
}
|
||||
for _, pid := range pids {
|
||||
_ = syscall.Kill(pid, syscall.SIGTERM)
|
||||
}
|
||||
if len(pids) == 0 {
|
||||
return
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
remaining, _ := vncAgentPIDs()
|
||||
if len(remaining) == 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
leftover, _ := vncAgentPIDs()
|
||||
for _, pid := range leftover {
|
||||
_ = syscall.Kill(pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
|
||||
// vncAgentPIDs returns the pids of vnc-agent subprocesses spawned from
|
||||
// this binary. Matches exactly on argv[0] == our own executable path
|
||||
// AND argv[1] == "vnc-agent" so unrelated processes that happen to have
|
||||
// the same name elsewhere in argv are not targeted. Skips pid 0 and 1
|
||||
// defensively.
|
||||
func vncAgentPIDs() ([]int, error) {
|
||||
procs, err := unix.SysctlKinfoProcSlice("kern.proc.all")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sysctl kern.proc.all: %w", err)
|
||||
}
|
||||
ownExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve own executable: %w", err)
|
||||
}
|
||||
var out []int
|
||||
for i := range procs {
|
||||
pid := int(procs[i].Proc.P_pid)
|
||||
if pid <= 1 {
|
||||
continue
|
||||
}
|
||||
argv, err := procArgv(pid)
|
||||
if err != nil || !argvIsVNCAgent(argv, ownExe) {
|
||||
continue
|
||||
}
|
||||
out = append(out, pid)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// procArgv reads the kernel's stored argv for pid via the kern.procargs2
|
||||
// sysctl. Format: 4-byte argc, then argv[0..argc) each NUL-terminated,
|
||||
// then envp, then padding. We only need argv so we stop after argc.
|
||||
func procArgv(pid int) ([]string, error) {
|
||||
raw, err := unix.SysctlRaw("kern.procargs2", pid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) < 4 {
|
||||
return nil, fmt.Errorf("procargs2 truncated")
|
||||
}
|
||||
argc := int(raw[0]) | int(raw[1])<<8 | int(raw[2])<<16 | int(raw[3])<<24
|
||||
body := raw[4:]
|
||||
// Skip the executable path (NUL-terminated) and any zero padding that
|
||||
// follows before argv[0].
|
||||
end := bytes.IndexByte(body, 0)
|
||||
if end < 0 {
|
||||
return nil, fmt.Errorf("procargs2 path unterminated")
|
||||
}
|
||||
body = body[end+1:]
|
||||
for len(body) > 0 && body[0] == 0 {
|
||||
body = body[1:]
|
||||
}
|
||||
args := make([]string, 0, argc)
|
||||
for i := 0; i < argc; i++ {
|
||||
end := bytes.IndexByte(body, 0)
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
args = append(args, string(body[:end]))
|
||||
body = body[end+1:]
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
// argvIsVNCAgent reports whether argv belongs to a vnc-agent subprocess
|
||||
// spawned from our binary. Requires argv[0] to match ownExe exactly and
|
||||
// argv[1] to be the vnc-agent subcommand. Matches the spawn shape in
|
||||
// spawnAgentForUser and rejects anything else.
|
||||
func argvIsVNCAgent(argv []string, ownExe string) bool {
|
||||
if len(argv) < 2 || ownExe == "" {
|
||||
return false
|
||||
}
|
||||
return argv[0] == ownExe && argv[1] == vncAgentSubcommand
|
||||
}
|
||||
178
client/vnc/server/agent_ipc.go
Normal file
178
client/vnc/server/agent_ipc.go
Normal file
@@ -0,0 +1,178 @@
|
||||
//go:build darwin || windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// agentPort is the TCP loopback port on which a per-session VNC agent
|
||||
// listens. The daemon dials this port and presents agentToken before
|
||||
// proxying VNC bytes. The choice of TCP (rather than a Unix socket or
|
||||
// named pipe) is intentional: it lets the same proxy/handshake code
|
||||
// run on every platform; the token does the access control.
|
||||
agentPort uint16 = 15900
|
||||
|
||||
// agentTokenLen is the size of the random per-spawn token in bytes.
|
||||
agentTokenLen = 32
|
||||
|
||||
// agentTokenEnvVar names the environment variable the daemon uses to
|
||||
// hand the per-spawn token to the agent child. Out-of-band channels
|
||||
// like this keep the secret out of the command line, where listings
|
||||
// such as `ps` or Windows tasklist would expose it.
|
||||
agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" // #nosec G101 -- env var name, not a credential
|
||||
|
||||
// vncAgentSubcommand is the CLI subcommand the daemon invokes to start
|
||||
// the per-session agent process. Must match cmd.vncAgentCmd.Use in
|
||||
// client/cmd/vnc_agent.go.
|
||||
vncAgentSubcommand = "vnc-agent"
|
||||
)
|
||||
|
||||
// generateAuthToken returns a fresh hex-encoded random token for one
|
||||
// daemon→agent session. The daemon hands this to the spawned agent
|
||||
// out-of-band (env var on Windows) and verifies it on every connection
|
||||
// the agent accepts.
|
||||
func generateAuthToken() (string, error) {
|
||||
b := make([]byte, agentTokenLen)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
return "", fmt.Errorf("read random: %w", err)
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// proxyToAgent dials the per-session agent on TCP loopback, writes the
|
||||
// raw token bytes, and then copies bytes in both directions until either
|
||||
// side closes. The token has to land on the wire before any VNC byte so
|
||||
// the agent's listening Server can apply verifyAgentToken before letting
|
||||
// real RFB traffic through.
|
||||
func proxyToAgent(ctx context.Context, client net.Conn, port uint16, authToken string) {
|
||||
defer client.Close()
|
||||
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
agentConn, err := dialAgentWithRetry(ctx, addr)
|
||||
if err != nil {
|
||||
log.Warnf("proxy cannot reach agent at %s: %v", addr, err)
|
||||
return
|
||||
}
|
||||
defer agentConn.Close()
|
||||
|
||||
tokenBytes, err := hex.DecodeString(authToken)
|
||||
if err != nil || len(tokenBytes) != agentTokenLen {
|
||||
log.Warnf("invalid auth token (len=%d): %v", len(tokenBytes), err)
|
||||
return
|
||||
}
|
||||
if _, err := agentConn.Write(tokenBytes); err != nil {
|
||||
log.Warnf("send auth token to agent: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("proxy connected to agent, starting bidirectional copy")
|
||||
done := make(chan struct{}, 2)
|
||||
cp := func(label string, dst, src net.Conn) {
|
||||
n, err := io.Copy(dst, src)
|
||||
log.Debugf("proxy %s: %d bytes, err=%v", label, n, err)
|
||||
done <- struct{}{}
|
||||
}
|
||||
go cp("client→agent", agentConn, client)
|
||||
go cp("agent→client", client, agentConn)
|
||||
<-done
|
||||
}
|
||||
|
||||
// relogAgentStream reads log lines from the agent's stderr and re-emits
|
||||
// them through the daemon's logrus, so the merged log keeps a single
|
||||
// format. JSON lines (the agent's normal output) are parsed and dispatched
|
||||
// by level; plain-text lines (cobra errors, panic traces) are forwarded
|
||||
// verbatim so early-startup failures stay visible.
|
||||
func relogAgentStream(r io.Reader) {
|
||||
entry := log.WithField("component", "vnc-agent")
|
||||
scanner := bufio.NewScanner(r)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if line[0] != '{' {
|
||||
entry.Warn(string(line))
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(line, &m); err != nil {
|
||||
entry.Warn(string(line))
|
||||
continue
|
||||
}
|
||||
msg, _ := m["msg"].(string)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
fields := make(log.Fields)
|
||||
for k, v := range m {
|
||||
switch k {
|
||||
case "msg", "level", "time", "func":
|
||||
continue
|
||||
case "caller":
|
||||
fields["source"] = v
|
||||
default:
|
||||
fields[k] = v
|
||||
}
|
||||
}
|
||||
e := entry.WithFields(fields)
|
||||
switch m["level"] {
|
||||
case "error":
|
||||
e.Error(msg)
|
||||
case "warning":
|
||||
e.Warn(msg)
|
||||
case "debug":
|
||||
e.Debug(msg)
|
||||
case "trace":
|
||||
e.Trace(msg)
|
||||
default:
|
||||
e.Info(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dialAgentWithRetry retries the loopback connect for up to ~10 s so the
|
||||
// daemon does not race the agent's first listen. Returns the live conn or
|
||||
// the final error. Aborts early when ctx is cancelled so a Stop() during
|
||||
// service-mode startup doesn't leave a goroutine sleeping for 10 s.
|
||||
func dialAgentWithRetry(ctx context.Context, addr string) (net.Conn, error) {
|
||||
var d net.Dialer
|
||||
var lastErr error
|
||||
for range 50 {
|
||||
if err := ctx.Err(); err != nil {
|
||||
if lastErr == nil {
|
||||
lastErr = err
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
dialCtx, cancel := context.WithTimeout(ctx, time.Second)
|
||||
c, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
cancel()
|
||||
if err == nil {
|
||||
return c, nil
|
||||
}
|
||||
lastErr = err
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if errors.Is(lastErr, context.Canceled) || errors.Is(lastErr, context.DeadlineExceeded) {
|
||||
lastErr = ctx.Err()
|
||||
}
|
||||
return nil, lastErr
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil, lastErr
|
||||
}
|
||||
692
client/vnc/server/agent_windows.go
Normal file
692
client/vnc/server/agent_windows.go
Normal file
@@ -0,0 +1,692 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
stillActive = 259
|
||||
|
||||
tokenPrimary = 1
|
||||
securityImpersonation = 2
|
||||
tokenSessionID = 12
|
||||
|
||||
createUnicodeEnvironment = 0x00000400
|
||||
createNoWindow = 0x08000000
|
||||
createSuspended = 0x00000004
|
||||
createBreakawayFromJob = 0x01000000
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
userenv = windows.NewLazySystemDLL("userenv.dll")
|
||||
|
||||
procWTSGetActiveConsoleSessionId = kernel32.NewProc("WTSGetActiveConsoleSessionId")
|
||||
procCreateJobObjectW = kernel32.NewProc("CreateJobObjectW")
|
||||
procSetInformationJobObject = kernel32.NewProc("SetInformationJobObject")
|
||||
procAssignProcessToJobObject = kernel32.NewProc("AssignProcessToJobObject")
|
||||
procSetTokenInformation = advapi32.NewProc("SetTokenInformation")
|
||||
procCreateEnvironmentBlock = userenv.NewProc("CreateEnvironmentBlock")
|
||||
procDestroyEnvironmentBlock = userenv.NewProc("DestroyEnvironmentBlock")
|
||||
|
||||
wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
|
||||
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
|
||||
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
|
||||
procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW")
|
||||
|
||||
iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable")
|
||||
)
|
||||
|
||||
// GetCurrentSessionID returns the session ID of the current process.
|
||||
func GetCurrentSessionID() uint32 {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(),
|
||||
windows.TOKEN_QUERY, &token); err != nil {
|
||||
return 0
|
||||
}
|
||||
defer token.Close()
|
||||
var id uint32
|
||||
var ret uint32
|
||||
_ = windows.GetTokenInformation(token, windows.TokenSessionId,
|
||||
(*byte)(unsafe.Pointer(&id)), 4, &ret)
|
||||
return id
|
||||
}
|
||||
|
||||
func getConsoleSessionID() uint32 {
|
||||
r, _, _ := procWTSGetActiveConsoleSessionId.Call()
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
const (
|
||||
wtsActive = 0
|
||||
wtsConnected = 1
|
||||
wtsDisconnected = 4
|
||||
)
|
||||
|
||||
// getActiveSessionID returns the session ID of the best session to attach to.
|
||||
// On a Windows Server with no console display attached, session 1 still
|
||||
// reports WTSActive (login screen "owns" the console), so a naive
|
||||
// first-active-wins pick lands on a session with no actual rendering.
|
||||
// Preference order:
|
||||
// 1. Active session with a user logged in (RDP user in session ≥2)
|
||||
// 2. Active session without a user (console at login screen)
|
||||
// 3. Console session ID
|
||||
func getActiveSessionID() uint32 {
|
||||
var sessionInfo uintptr
|
||||
var count uint32
|
||||
|
||||
r, _, _ := procWTSEnumerateSessionsW.Call(
|
||||
0, // WTS_CURRENT_SERVER_HANDLE
|
||||
0, // reserved
|
||||
1, // version
|
||||
uintptr(unsafe.Pointer(&sessionInfo)),
|
||||
uintptr(unsafe.Pointer(&count)),
|
||||
)
|
||||
if r == 0 || count == 0 {
|
||||
return getConsoleSessionID()
|
||||
}
|
||||
defer func() { _, _, _ = procWTSFreeMemory.Call(sessionInfo) }()
|
||||
|
||||
type wtsSession struct {
|
||||
SessionID uint32
|
||||
Station *uint16
|
||||
State uint32
|
||||
}
|
||||
sessions := unsafe.Slice((*wtsSession)(unsafe.Pointer(sessionInfo)), count)
|
||||
|
||||
var withUser uint32
|
||||
var withUserFound bool
|
||||
var anyActive uint32
|
||||
var anyActiveFound bool
|
||||
for _, s := range sessions {
|
||||
if s.SessionID == 0 {
|
||||
continue
|
||||
}
|
||||
if s.State != wtsActive {
|
||||
continue
|
||||
}
|
||||
if !anyActiveFound {
|
||||
anyActive = s.SessionID
|
||||
anyActiveFound = true
|
||||
}
|
||||
if !withUserFound && wtsSessionHasUser(s.SessionID) {
|
||||
withUser = s.SessionID
|
||||
withUserFound = true
|
||||
}
|
||||
}
|
||||
if withUserFound {
|
||||
return withUser
|
||||
}
|
||||
if anyActiveFound {
|
||||
return anyActive
|
||||
}
|
||||
return getConsoleSessionID()
|
||||
}
|
||||
|
||||
// reapOrphanOnPort finds any process listening on 127.0.0.1:port and, if
|
||||
// it's a netbird vnc-agent left over from a previous service instance,
|
||||
// terminates it. Verified by image-name match so we never kill an
|
||||
// unrelated process that happens to use the same port.
|
||||
func reapOrphanOnPort(port uint16) {
|
||||
pid := tcpListenerPID(port)
|
||||
if pid == 0 || pid == uint32(windows.GetCurrentProcessId()) {
|
||||
return
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
log.Warnf("reap on port %d: open PID=%d: %v", port, pid, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(h) }()
|
||||
if !isOurAgentProcess(h) {
|
||||
log.Warnf("reap on port %d: PID=%d is not a netbird vnc-agent, leaving it alone", port, pid)
|
||||
return
|
||||
}
|
||||
if err := windows.TerminateProcess(h, 0); err != nil {
|
||||
log.Warnf("reap on port %d: terminate PID=%d: %v", port, pid, err)
|
||||
return
|
||||
}
|
||||
log.Infof("reaped orphan vnc-agent PID=%d holding port %d", pid, port)
|
||||
}
|
||||
|
||||
// isOurAgentProcess returns true if the given process handle points at a
|
||||
// netbird.exe binary at the same path as the current process. We compare
|
||||
// full paths (case-insensitive on Windows) so co-installed netbird binaries
|
||||
// from a different install dir or unrelated apps named netbird.exe don't
|
||||
// get killed.
|
||||
func isOurAgentProcess(h windows.Handle) bool {
|
||||
var size uint32 = windows.MAX_PATH
|
||||
buf := make([]uint16, size)
|
||||
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||
return false
|
||||
}
|
||||
target := strings.ToLower(windows.UTF16ToString(buf[:size]))
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return target == strings.ToLower(selfExe)
|
||||
}
|
||||
|
||||
// tcpListenerPID returns the PID of the process listening on 127.0.0.1:port,
|
||||
// or 0 if none. Uses GetExtendedTcpTable with TCP_TABLE_OWNER_PID_LISTENER.
|
||||
func tcpListenerPID(port uint16) uint32 {
|
||||
const tcpTableOwnerPidListener = 3
|
||||
const afInet = 2
|
||||
|
||||
// MIB_TCPROW_OWNER_PID layout: state(4) + localAddr(4) + localPort(4) +
|
||||
// remoteAddr(4) + remotePort(4) + owningPid(4) = 24 bytes.
|
||||
const rowSize = 24
|
||||
|
||||
var size uint32
|
||||
_, _, _ = procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0, afInet, tcpTableOwnerPidListener, 0)
|
||||
if size == 0 {
|
||||
return 0
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
r, _, _ := procGetExtendedTcpTable.Call(
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(unsafe.Pointer(&size)),
|
||||
0, afInet, tcpTableOwnerPidListener, 0,
|
||||
)
|
||||
if r != 0 {
|
||||
return 0
|
||||
}
|
||||
count := binary.LittleEndian.Uint32(buf[:4])
|
||||
for i := uint32(0); i < count; i++ {
|
||||
off := 4 + int(i)*rowSize
|
||||
if off+rowSize > len(buf) {
|
||||
break
|
||||
}
|
||||
// localPort is stored big-endian in the high 16 bits of a 32-bit field.
|
||||
localPort := uint16(buf[off+8])<<8 | uint16(buf[off+9])
|
||||
if localPort != port {
|
||||
continue
|
||||
}
|
||||
localAddr := binary.LittleEndian.Uint32(buf[off+4 : off+8])
|
||||
// 0x0100007f == 127.0.0.1 in network byte order on little-endian.
|
||||
// We accept 0.0.0.0 too in case the orphan bound to all interfaces.
|
||||
if localAddr != 0x0100007f && localAddr != 0 {
|
||||
continue
|
||||
}
|
||||
return binary.LittleEndian.Uint32(buf[off+20 : off+24])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// wtsSessionHasUser returns true if the session has a non-empty user name,
|
||||
// i.e. someone is logged in (vs. the login/Welcome screen). The console
|
||||
// session at the lock screen has WTSUserName == "".
|
||||
const wtsUserName = 5
|
||||
|
||||
func wtsSessionHasUser(sessionID uint32) bool {
|
||||
var buf uintptr
|
||||
var bytesReturned uint32
|
||||
r, _, _ := procWTSQuerySessionInformation.Call(
|
||||
0, // WTS_CURRENT_SERVER_HANDLE
|
||||
uintptr(sessionID),
|
||||
uintptr(wtsUserName),
|
||||
uintptr(unsafe.Pointer(&buf)),
|
||||
uintptr(unsafe.Pointer(&bytesReturned)),
|
||||
)
|
||||
if r == 0 || buf == 0 {
|
||||
return false
|
||||
}
|
||||
defer func() { _, _, _ = procWTSFreeMemory.Call(buf) }()
|
||||
// First UTF-16 code unit non-zero ⇒ non-empty username.
|
||||
return *(*uint16)(unsafe.Pointer(buf)) != 0
|
||||
}
|
||||
|
||||
// getSystemTokenForSession duplicates the current SYSTEM token and sets its
|
||||
// session ID so the spawned process runs in the target session. Using a SYSTEM
|
||||
// token gives access to both Default and Winlogon desktops plus UIPI bypass.
|
||||
func getSystemTokenForSession(sessionID uint32) (windows.Token, error) {
|
||||
var cur windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(),
|
||||
windows.MAXIMUM_ALLOWED, &cur); err != nil {
|
||||
return 0, fmt.Errorf("OpenProcessToken: %w", err)
|
||||
}
|
||||
defer cur.Close()
|
||||
|
||||
var dup windows.Token
|
||||
if err := windows.DuplicateTokenEx(cur, windows.MAXIMUM_ALLOWED, nil,
|
||||
securityImpersonation, tokenPrimary, &dup); err != nil {
|
||||
return 0, fmt.Errorf("DuplicateTokenEx: %w", err)
|
||||
}
|
||||
|
||||
sid := sessionID
|
||||
r, _, err := procSetTokenInformation.Call(
|
||||
uintptr(dup),
|
||||
uintptr(tokenSessionID),
|
||||
uintptr(unsafe.Pointer(&sid)),
|
||||
unsafe.Sizeof(sid),
|
||||
)
|
||||
if r == 0 {
|
||||
dup.Close()
|
||||
return 0, fmt.Errorf("SetTokenInformation(SessionId=%d): %w", sessionID, err)
|
||||
}
|
||||
return dup, nil
|
||||
}
|
||||
|
||||
// injectEnvVar appends a KEY=VALUE entry to a Unicode environment block.
|
||||
// The block is a sequence of null-terminated UTF-16 strings, terminated by
|
||||
// an extra null. Returns the new []uint16 backing slice; the caller must
|
||||
// hold the returned slice alive until CreateProcessAsUser completes.
|
||||
func injectEnvVar(envBlock uintptr, key, value string) []uint16 {
|
||||
entry := key + "=" + value
|
||||
|
||||
// Walk the existing block to find its total length.
|
||||
ptr := (*uint16)(unsafe.Pointer(envBlock))
|
||||
var totalChars int
|
||||
for {
|
||||
ch := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(totalChars)*2))
|
||||
if ch == 0 {
|
||||
// Check for double-null terminator.
|
||||
next := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(totalChars+1)*2))
|
||||
totalChars++
|
||||
if next == 0 {
|
||||
// End of block (don't count the final null yet, we'll rebuild).
|
||||
break
|
||||
}
|
||||
} else {
|
||||
totalChars++
|
||||
}
|
||||
}
|
||||
|
||||
entryUTF16, _ := windows.UTF16FromString(entry)
|
||||
// New block: existing entries + new entry (null-terminated) + final null.
|
||||
newLen := totalChars + len(entryUTF16) + 1
|
||||
newBlock := make([]uint16, newLen)
|
||||
// Copy existing entries (up to but not including the final null).
|
||||
for i := range totalChars {
|
||||
newBlock[i] = *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(i)*2))
|
||||
}
|
||||
copy(newBlock[totalChars:], entryUTF16)
|
||||
newBlock[newLen-1] = 0 // final null terminator
|
||||
|
||||
return newBlock
|
||||
}
|
||||
|
||||
func spawnAgentInSession(sessionID uint32, port uint16, authToken string, jobHandle windows.Handle) (windows.Handle, error) {
|
||||
token, err := getSystemTokenForSession(sessionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get SYSTEM token for session %d: %w", sessionID, err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
var envBlock uintptr
|
||||
r, _, e := procCreateEnvironmentBlock.Call(
|
||||
uintptr(unsafe.Pointer(&envBlock)),
|
||||
uintptr(token),
|
||||
0,
|
||||
)
|
||||
if r == 0 {
|
||||
// Without an environment block we cannot inject NB_VNC_AGENT_TOKEN;
|
||||
// the agent would start unauthenticated. Abort instead of launching.
|
||||
return 0, fmt.Errorf("CreateEnvironmentBlock: %w", e)
|
||||
}
|
||||
defer func() { _, _, _ = procDestroyEnvironmentBlock.Call(envBlock) }()
|
||||
|
||||
// Inject the auth token into the environment block so it doesn't appear
|
||||
// in the process command line (visible via tasklist/wmic). injectedBlock
|
||||
// must stay alive until CreateProcessAsUser returns.
|
||||
injectedBlock := injectEnvVar(envBlock, agentTokenEnvVar, authToken)
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get executable path: %w", err)
|
||||
}
|
||||
|
||||
cmdLine := fmt.Sprintf(`"%s" %s --port %d`, exePath, vncAgentSubcommand, port)
|
||||
cmdLineW, err := windows.UTF16PtrFromString(cmdLine)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("UTF16 cmdline: %w", err)
|
||||
}
|
||||
|
||||
// Create an inheritable pipe for the agent's stderr so we can relog
|
||||
// its output in the service process.
|
||||
var sa windows.SecurityAttributes
|
||||
sa.Length = uint32(unsafe.Sizeof(sa))
|
||||
sa.InheritHandle = 1
|
||||
|
||||
var stderrRead, stderrWrite windows.Handle
|
||||
if err := windows.CreatePipe(&stderrRead, &stderrWrite, &sa, 0); err != nil {
|
||||
return 0, fmt.Errorf("create stderr pipe: %w", err)
|
||||
}
|
||||
// The read end must NOT be inherited by the child.
|
||||
_ = windows.SetHandleInformation(stderrRead, windows.HANDLE_FLAG_INHERIT, 0)
|
||||
|
||||
desktop, _ := windows.UTF16PtrFromString(`WinSta0\Default`)
|
||||
si := windows.StartupInfo{
|
||||
Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})),
|
||||
Desktop: desktop,
|
||||
Flags: windows.STARTF_USESHOWWINDOW | windows.STARTF_USESTDHANDLES,
|
||||
ShowWindow: 0,
|
||||
StdErr: stderrWrite,
|
||||
StdOutput: stderrWrite,
|
||||
}
|
||||
var pi windows.ProcessInformation
|
||||
|
||||
var envPtr *uint16
|
||||
if len(injectedBlock) > 0 {
|
||||
envPtr = &injectedBlock[0]
|
||||
} else if envBlock != 0 {
|
||||
envPtr = (*uint16)(unsafe.Pointer(envBlock))
|
||||
}
|
||||
|
||||
// CREATE_SUSPENDED so we can assign the process to our Job Object
|
||||
// before it executes. Without this the agent could spawn its own child
|
||||
// processes and have them inherit the SCM service-job (not ours), or
|
||||
// briefly listen on the agent port before we tear it down on rollback.
|
||||
// CREATE_BREAKAWAY_FROM_JOB lets the child leave the SCM-managed
|
||||
// service job; harmless if that job allows breakaway, and is required
|
||||
// before AssignProcessToJobObject can succeed in the no-nested-jobs case.
|
||||
err = windows.CreateProcessAsUser(
|
||||
token, nil, cmdLineW,
|
||||
nil, nil, true, // inheritHandles=true for the pipe
|
||||
createUnicodeEnvironment|createNoWindow|createSuspended|createBreakawayFromJob,
|
||||
envPtr, nil, &si, &pi,
|
||||
)
|
||||
runtime.KeepAlive(injectedBlock)
|
||||
// Close the write end in the parent so reads will get EOF when the child exits.
|
||||
_ = windows.CloseHandle(stderrWrite)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(stderrRead)
|
||||
return 0, fmt.Errorf("CreateProcessAsUser: %w", err)
|
||||
}
|
||||
|
||||
if jobHandle != 0 {
|
||||
r, _, e := procAssignProcessToJobObject.Call(uintptr(jobHandle), uintptr(pi.Process))
|
||||
if r == 0 {
|
||||
log.Warnf("assign agent to job object: %v (orphan possible on service crash)", e)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := windows.ResumeThread(pi.Thread); err != nil {
|
||||
log.Warnf("resume agent main thread: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(pi.Thread)
|
||||
|
||||
// Relog agent output in the service with a [vnc-agent] prefix.
|
||||
go relogAgentOutput(stderrRead)
|
||||
|
||||
log.Infof("spawned agent PID=%d in session %d on port %d", pi.ProcessId, sessionID, port)
|
||||
return pi.Process, nil
|
||||
}
|
||||
|
||||
// sessionManager monitors the active console session and ensures a VNC agent
|
||||
// process is running in it. When the session changes (e.g., user switch, RDP
|
||||
// connect/disconnect), it kills the old agent and spawns a new one.
|
||||
type sessionManager struct {
|
||||
port uint16
|
||||
mu sync.Mutex
|
||||
agentProc windows.Handle
|
||||
everSpawned bool
|
||||
agentStartedAt time.Time
|
||||
spawnFailures int
|
||||
nextSpawnAt time.Time
|
||||
sessionID uint32
|
||||
authToken string
|
||||
done chan struct{}
|
||||
// jobHandle owns the agent processes via a Windows Job Object with
|
||||
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the service exits or crashes,
|
||||
// the OS closes the handle and terminates every assigned agent: no
|
||||
// orphaned listeners holding the agent port across restarts.
|
||||
jobHandle windows.Handle
|
||||
}
|
||||
|
||||
func newSessionManager(port uint16) *sessionManager {
|
||||
m := &sessionManager{port: port, sessionID: ^uint32(0), done: make(chan struct{})}
|
||||
if h, err := createKillOnCloseJob(); err != nil {
|
||||
log.Warnf("create job object for vnc-agent (orphan agents possible after crash): %v", err)
|
||||
} else {
|
||||
m.jobHandle = h
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// createKillOnCloseJob returns a Job Object configured so that closing its
|
||||
// handle (process exit or explicit Close) terminates every process assigned
|
||||
// to it. Used to keep orphaned vnc-agent processes from outliving the service.
|
||||
func createKillOnCloseJob() (windows.Handle, error) {
|
||||
r, _, e := procCreateJobObjectW.Call(0, 0)
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("CreateJobObject: %w", e)
|
||||
}
|
||||
job := windows.Handle(r)
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION on amd64 = 144 bytes.
|
||||
//
|
||||
// JOBOBJECT_BASIC_LIMIT_INFORMATION (64 bytes with alignment padding)
|
||||
// PerProcessUserTimeLimit LARGE_INTEGER off 0
|
||||
// PerJobUserTimeLimit LARGE_INTEGER off 8
|
||||
// LimitFlags DWORD off 16
|
||||
// [4 byte pad to align SIZE_T]
|
||||
// MinimumWorkingSetSize SIZE_T off 24
|
||||
// MaximumWorkingSetSize SIZE_T off 32
|
||||
// ActiveProcessLimit DWORD off 40
|
||||
// [4 byte pad to align ULONG_PTR]
|
||||
// Affinity ULONG_PTR off 48
|
||||
// PriorityClass DWORD off 56
|
||||
// SchedulingClass DWORD off 60
|
||||
// IO_COUNTERS (48) + 4 * SIZE_T (32) = 144 total.
|
||||
//
|
||||
// We only set LimitFlags; the rest stays zero.
|
||||
const sizeofExtended = 144
|
||||
const offsetLimitFlags = 16
|
||||
const jobObjectExtendedLimitInformation = 9
|
||||
const jobObjectLimitKillOnJobClose = 0x00002000
|
||||
|
||||
var info [sizeofExtended]byte
|
||||
binary.LittleEndian.PutUint32(info[offsetLimitFlags:offsetLimitFlags+4], jobObjectLimitKillOnJobClose)
|
||||
|
||||
r, _, e = procSetInformationJobObject.Call(
|
||||
uintptr(job),
|
||||
uintptr(jobObjectExtendedLimitInformation),
|
||||
uintptr(unsafe.Pointer(&info[0])),
|
||||
uintptr(sizeofExtended),
|
||||
)
|
||||
if r == 0 {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, fmt.Errorf("SetInformationJobObject(KILL_ON_JOB_CLOSE): %w", e)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// AuthToken returns the current agent authentication token.
|
||||
func (m *sessionManager) AuthToken() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.authToken
|
||||
}
|
||||
|
||||
// Stop signals the session manager to exit its polling loop and closes the
|
||||
// Job Object handle, which Windows uses as the trigger to terminate every
|
||||
// agent process this manager spawned.
|
||||
func (m *sessionManager) Stop() {
|
||||
select {
|
||||
case <-m.done:
|
||||
default:
|
||||
close(m.done)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.jobHandle != 0 {
|
||||
_ = windows.CloseHandle(m.jobHandle)
|
||||
m.jobHandle = 0
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *sessionManager) run() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if !m.tick() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-m.done:
|
||||
m.mu.Lock()
|
||||
m.killAgent()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick performs one session/agent-state update. Returns false if the manager
|
||||
// should permanently stop (e.g. missing SYSTEM privileges).
|
||||
func (m *sessionManager) tick() bool {
|
||||
sid := getActiveSessionID()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.handleSessionChange(sid)
|
||||
m.reapExitedAgent()
|
||||
return m.maybeSpawnAgent(sid)
|
||||
}
|
||||
|
||||
func (m *sessionManager) handleSessionChange(sid uint32) {
|
||||
if sid == m.sessionID {
|
||||
return
|
||||
}
|
||||
log.Infof("active session changed: %d -> %d", m.sessionID, sid)
|
||||
m.killAgent()
|
||||
m.sessionID = sid
|
||||
}
|
||||
|
||||
func (m *sessionManager) reapExitedAgent() {
|
||||
if m.agentProc == 0 {
|
||||
return
|
||||
}
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(m.agentProc, &code); err != nil {
|
||||
log.Debugf("GetExitCodeProcess: %v", err)
|
||||
return
|
||||
}
|
||||
if code == stillActive {
|
||||
return
|
||||
}
|
||||
m.scheduleNextSpawn(code, time.Since(m.agentStartedAt))
|
||||
if err := windows.CloseHandle(m.agentProc); err != nil {
|
||||
log.Debugf("close agent handle: %v", err)
|
||||
}
|
||||
m.agentProc = 0
|
||||
}
|
||||
|
||||
// scheduleNextSpawn applies an exponential backoff on fast crashes (<5s) and
|
||||
// resets immediately otherwise.
|
||||
func (m *sessionManager) scheduleNextSpawn(exitCode uint32, lifetime time.Duration) {
|
||||
if lifetime < 5*time.Second {
|
||||
m.spawnFailures++
|
||||
backoff := time.Duration(1<<min(m.spawnFailures, 5)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
m.nextSpawnAt = time.Now().Add(backoff)
|
||||
log.Warnf("agent exited (code=%d) after %v, retrying in %v (failures=%d)", exitCode, lifetime.Round(time.Millisecond), backoff, m.spawnFailures)
|
||||
return
|
||||
}
|
||||
m.spawnFailures = 0
|
||||
m.nextSpawnAt = time.Time{}
|
||||
log.Infof("agent exited (code=%d) after %v, respawning", exitCode, lifetime.Round(time.Second))
|
||||
}
|
||||
|
||||
// maybeSpawnAgent spawns a new agent if there's no current one and the backoff
|
||||
// window has elapsed. Returns false to permanently stop the manager when the
|
||||
// service lacks the privileges needed to spawn cross-session.
|
||||
func (m *sessionManager) maybeSpawnAgent(sid uint32) bool {
|
||||
if m.agentProc != 0 || sid == 0xFFFFFFFF || !time.Now().After(m.nextSpawnAt) {
|
||||
return true
|
||||
}
|
||||
// Reap any orphan still holding the agent port from a previous
|
||||
// service instance, only on our very first spawn. Once we own
|
||||
// an agent, we manage its lifecycle ourselves and never need to
|
||||
// kill an unknown listener; if a kill+respawn races on port
|
||||
// release, the spawn-failure backoff handles it without forcing
|
||||
// a synchronous wait or duplicate kill.
|
||||
if !m.everSpawned {
|
||||
reapOrphanOnPort(m.port)
|
||||
}
|
||||
token, err := generateAuthToken()
|
||||
if err != nil {
|
||||
log.Warnf("generate agent auth token: %v", err)
|
||||
return true
|
||||
}
|
||||
m.authToken = token
|
||||
h, err := spawnAgentInSession(sid, m.port, m.authToken, m.jobHandle)
|
||||
if err != nil {
|
||||
m.authToken = ""
|
||||
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) {
|
||||
// SE_TCB_NAME (token-impersonation across sessions) is only
|
||||
// granted to SYSTEM. Without it spawnAgent will fail every 2
|
||||
// seconds forever: log once and give up.
|
||||
log.Warnf("VNC service mode disabled: agent spawn requires SYSTEM privileges (got: %v)", err)
|
||||
return false
|
||||
}
|
||||
log.Warnf("spawn agent in session %d: %v", sid, err)
|
||||
return true
|
||||
}
|
||||
m.agentProc = h
|
||||
m.agentStartedAt = time.Now()
|
||||
m.everSpawned = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *sessionManager) killAgent() {
|
||||
if m.agentProc == 0 {
|
||||
return
|
||||
}
|
||||
_ = windows.TerminateProcess(m.agentProc, 0)
|
||||
_ = windows.CloseHandle(m.agentProc)
|
||||
m.agentProc = 0
|
||||
log.Info("killed old agent")
|
||||
}
|
||||
|
||||
// relogAgentOutput reads log lines from the agent's stderr pipe and
|
||||
// relogs them with the service's formatter. The *os.File owns the
|
||||
// underlying handle, so closing it suffices.
|
||||
func relogAgentOutput(pipe windows.Handle) {
|
||||
f := os.NewFile(uintptr(pipe), "vnc-agent-stderr")
|
||||
defer func() { _ = f.Close() }()
|
||||
relogAgentStream(f)
|
||||
}
|
||||
|
||||
// logCleanupCall invokes a Windows syscall used solely as a cleanup primitive
|
||||
// (CloseClipboard, ReleaseDC, etc.) and logs failures at trace level. The
|
||||
// indirection lets us satisfy errcheck without scattering ignored returns at
|
||||
// each call site, while still capturing diagnostic info when the OS reports
|
||||
// a failure.
|
||||
func logCleanupCall(name string, proc *windows.LazyProc) {
|
||||
r, _, err := proc.Call()
|
||||
if r == 0 && err != nil && err != windows.NTE_OP_OK {
|
||||
log.Tracef("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// logCleanupCallArgs is logCleanupCall with one argument; common pattern for
|
||||
// release-by-handle syscalls.
|
||||
func logCleanupCallArgs(name string, proc *windows.LazyProc, args ...uintptr) {
|
||||
r, _, err := proc.Call(args...)
|
||||
if r == 0 && err != nil && err != windows.NTE_OP_OK {
|
||||
log.Tracef("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
643
client/vnc/server/capture_darwin.go
Normal file
643
client/vnc/server/capture_darwin.go
Normal file
@@ -0,0 +1,643 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"image"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var darwinCaptureOnce sync.Once
|
||||
|
||||
var (
|
||||
cgMainDisplayID func() uint32
|
||||
cgDisplayPixelsWide func(uint32) uintptr
|
||||
cgDisplayPixelsHigh func(uint32) uintptr
|
||||
cgDisplayCreateImage func(uint32) uintptr
|
||||
cgImageGetWidth func(uintptr) uintptr
|
||||
cgImageGetHeight func(uintptr) uintptr
|
||||
cgImageGetBytesPerRow func(uintptr) uintptr
|
||||
cgImageGetBitsPerPixel func(uintptr) uintptr
|
||||
cgImageGetDataProvider func(uintptr) uintptr
|
||||
cgDataProviderCopyData func(uintptr) uintptr
|
||||
cgImageRelease func(uintptr)
|
||||
cfDataGetLength func(uintptr) int64
|
||||
cfDataGetBytePtr func(uintptr) uintptr
|
||||
cfRelease func(uintptr)
|
||||
cgRequestScreenCaptureAccess func() bool
|
||||
cgEventCreate func(uintptr) uintptr
|
||||
cgEventGetLocation func(uintptr) cgPoint
|
||||
darwinCaptureReady bool
|
||||
)
|
||||
|
||||
// cgPoint mirrors CoreGraphics CGPoint: two doubles, 16 bytes, returned
|
||||
// in registers on Darwin amd64/arm64. Used to receive cursor coordinates
|
||||
// from CGEventGetLocation via purego.
|
||||
type cgPoint struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func initDarwinCapture() {
|
||||
darwinCaptureOnce.Do(func() {
|
||||
cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreGraphics: %v", err)
|
||||
return
|
||||
}
|
||||
cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreFoundation: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cgMainDisplayID, cg, "CGMainDisplayID")
|
||||
purego.RegisterLibFunc(&cgDisplayPixelsWide, cg, "CGDisplayPixelsWide")
|
||||
purego.RegisterLibFunc(&cgDisplayPixelsHigh, cg, "CGDisplayPixelsHigh")
|
||||
purego.RegisterLibFunc(&cgDisplayCreateImage, cg, "CGDisplayCreateImage")
|
||||
purego.RegisterLibFunc(&cgImageGetWidth, cg, "CGImageGetWidth")
|
||||
purego.RegisterLibFunc(&cgImageGetHeight, cg, "CGImageGetHeight")
|
||||
purego.RegisterLibFunc(&cgImageGetBytesPerRow, cg, "CGImageGetBytesPerRow")
|
||||
purego.RegisterLibFunc(&cgImageGetBitsPerPixel, cg, "CGImageGetBitsPerPixel")
|
||||
purego.RegisterLibFunc(&cgImageGetDataProvider, cg, "CGImageGetDataProvider")
|
||||
purego.RegisterLibFunc(&cgDataProviderCopyData, cg, "CGDataProviderCopyData")
|
||||
purego.RegisterLibFunc(&cgImageRelease, cg, "CGImageRelease")
|
||||
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
|
||||
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
|
||||
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
|
||||
|
||||
// CGRequestScreenCaptureAccess (macOS 11+) prompts on first call and
|
||||
// is a cheap no-op once granted. The Preflight companion is unreliable
|
||||
// on Sequoia (returns false even when access is granted), so we drive
|
||||
// the permission flow from actual capture failures instead.
|
||||
if sym, err := purego.Dlsym(cg, "CGRequestScreenCaptureAccess"); err == nil {
|
||||
purego.RegisterFunc(&cgRequestScreenCaptureAccess, sym)
|
||||
}
|
||||
// CGEventCreate / CGEventGetLocation feed the cursor position used
|
||||
// by remote-cursor compositing. Optional; absence reports as a
|
||||
// position-source error and disables that feature on this host.
|
||||
if sym, err := purego.Dlsym(cg, "CGEventCreate"); err == nil {
|
||||
purego.RegisterFunc(&cgEventCreate, sym)
|
||||
}
|
||||
if sym, err := purego.Dlsym(cg, "CGEventGetLocation"); err == nil {
|
||||
purego.RegisterFunc(&cgEventGetLocation, sym)
|
||||
}
|
||||
|
||||
darwinCaptureReady = true
|
||||
})
|
||||
}
|
||||
|
||||
// CGCapturer captures the macOS main display using Core Graphics.
|
||||
type CGCapturer struct {
|
||||
displayID uint32
|
||||
w, h int
|
||||
// downscale is 1 for pixel-perfect, 2 for Retina 2:1 box-filter downscale.
|
||||
downscale int
|
||||
hashSeed maphash.Seed
|
||||
lastHash uint64
|
||||
hasHash bool
|
||||
// cursor lazily binds the private CGSCreateCurrentCursorImage symbol
|
||||
// so we can emit the Cursor pseudo-encoding without a per-frame cost
|
||||
// on builds that never query it.
|
||||
cursorOnce sync.Once
|
||||
cursor *cgCursor
|
||||
}
|
||||
|
||||
// PrimeScreenCapturePermission triggers the macOS Screen Recording
|
||||
// permission prompt without creating a full capturer. The platform wiring
|
||||
// calls this at VNC-server enable time so the user sees the prompt the
|
||||
// moment they turn the feature on. CGRequestScreenCaptureAccess is a
|
||||
// no-op when the grant already exists, so calling it on every enable is
|
||||
// cheap and safe.
|
||||
func PrimeScreenCapturePermission() {
|
||||
initDarwinCapture()
|
||||
if !darwinCaptureReady {
|
||||
return
|
||||
}
|
||||
if cgRequestScreenCaptureAccess != nil {
|
||||
cgRequestScreenCaptureAccess()
|
||||
}
|
||||
}
|
||||
|
||||
// notifyScreenRecordingMissing nudges the user once per agent process to
|
||||
// approve Screen Recording. The capturer init retries on backoff when the
|
||||
// grant is missing; without the sync.Once we would reopen System Settings
|
||||
// every tick and flood the daemon log with the same warning.
|
||||
var screenRecordingNotifyOnce sync.Once
|
||||
|
||||
func notifyScreenRecordingMissing() {
|
||||
screenRecordingNotifyOnce.Do(func() {
|
||||
if cgRequestScreenCaptureAccess != nil {
|
||||
cgRequestScreenCaptureAccess()
|
||||
}
|
||||
openPrivacyPane("Privacy_ScreenCapture")
|
||||
log.Warn("Screen Recording permission not granted. " +
|
||||
"Opened System Settings > Privacy & Security > Screen Recording; enable netbird and restart.")
|
||||
})
|
||||
}
|
||||
|
||||
// NewCGCapturer creates a screen capturer for the main display.
|
||||
func NewCGCapturer() (*CGCapturer, error) {
|
||||
initDarwinCapture()
|
||||
if !darwinCaptureReady {
|
||||
return nil, fmt.Errorf("CoreGraphics not available")
|
||||
}
|
||||
|
||||
displayID := cgMainDisplayID()
|
||||
c := &CGCapturer{displayID: displayID, downscale: 1, hashSeed: maphash.MakeSeed()}
|
||||
|
||||
img, err := c.Capture()
|
||||
if err != nil {
|
||||
notifyScreenRecordingMissing()
|
||||
return nil, fmt.Errorf("probe capture: %w", err)
|
||||
}
|
||||
nativeW := img.Rect.Dx()
|
||||
nativeH := img.Rect.Dy()
|
||||
c.hasHash = false
|
||||
if nativeW == 0 || nativeH == 0 {
|
||||
return nil, errors.New("display dimensions are zero")
|
||||
}
|
||||
|
||||
logicalW := int(cgDisplayPixelsWide(displayID))
|
||||
logicalH := int(cgDisplayPixelsHigh(displayID))
|
||||
|
||||
// Enable 2:1 downscale on Retina unless explicitly disabled. Cuts pixel
|
||||
// count 4x, shrinking convert, diff, and wire data proportionally.
|
||||
if !retinaDownscaleDisabled() && nativeW >= 2*logicalW && nativeH >= 2*logicalH && nativeW%2 == 0 && nativeH%2 == 0 {
|
||||
c.downscale = 2
|
||||
}
|
||||
c.w = nativeW / c.downscale
|
||||
c.h = nativeH / c.downscale
|
||||
|
||||
log.Infof("macOS capturer ready: %dx%d (native %dx%d, logical %dx%d, downscale=%d, display=%d)",
|
||||
c.w, c.h, nativeW, nativeH, logicalW, logicalH, c.downscale, displayID)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func retinaDownscaleDisabled() bool {
|
||||
v := os.Getenv(EnvVNCDisableDownscale)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
log.Warnf("parse %s: %v", EnvVNCDisableDownscale, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
// Width returns the screen width.
|
||||
func (c *CGCapturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the screen height.
|
||||
func (c *CGCapturer) Height() int { return c.h }
|
||||
|
||||
// Capture returns the current screen as an RGBA image.
|
||||
// CaptureInto writes a fresh frame directly into dst, skipping the
|
||||
// per-frame image.RGBA allocation that Capture() does. Returns
|
||||
// errFrameUnchanged when the screen hash matches the prior call.
|
||||
func (c *CGCapturer) CaptureInto(dst *image.RGBA) error {
|
||||
cgImage := cgDisplayCreateImage(c.displayID)
|
||||
if cgImage == 0 {
|
||||
return fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)")
|
||||
}
|
||||
defer cgImageRelease(cgImage)
|
||||
w := int(cgImageGetWidth(cgImage))
|
||||
h := int(cgImageGetHeight(cgImage))
|
||||
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
||||
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
||||
provider := cgImageGetDataProvider(cgImage)
|
||||
if provider == 0 {
|
||||
return fmt.Errorf("CGImageGetDataProvider returned nil")
|
||||
}
|
||||
cfData := cgDataProviderCopyData(provider)
|
||||
if cfData == 0 {
|
||||
return fmt.Errorf("CGDataProviderCopyData returned nil")
|
||||
}
|
||||
defer cfRelease(cfData)
|
||||
dataLen := int(cfDataGetLength(cfData))
|
||||
dataPtr := cfDataGetBytePtr(cfData)
|
||||
if dataPtr == 0 || dataLen == 0 {
|
||||
return fmt.Errorf("empty image data")
|
||||
}
|
||||
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
||||
hash := maphash.Bytes(c.hashSeed, src)
|
||||
if c.hasHash && hash == c.lastHash {
|
||||
return errFrameUnchanged
|
||||
}
|
||||
c.lastHash = hash
|
||||
c.hasHash = true
|
||||
|
||||
ds := c.downscale
|
||||
if ds < 1 {
|
||||
ds = 1
|
||||
}
|
||||
outW := w / ds
|
||||
outH := h / ds
|
||||
if dst.Rect.Dx() != outW || dst.Rect.Dy() != outH {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), outW, outH)
|
||||
}
|
||||
bytesPerPixel := bpp / 8
|
||||
if bytesPerPixel == 4 && ds == 1 {
|
||||
convertBGRAToRGBA(dst.Pix, dst.Stride, src, bytesPerRow, w, h)
|
||||
return nil
|
||||
}
|
||||
if bytesPerPixel == 4 && ds == 2 {
|
||||
convertBGRAToRGBADownscale2(dst.Pix, dst.Stride, src, bytesPerRow, outW, outH)
|
||||
return nil
|
||||
}
|
||||
for row := 0; row < outH; row++ {
|
||||
srcOff := row * ds * bytesPerRow
|
||||
dstOff := row * dst.Stride
|
||||
for col := 0; col < outW; col++ {
|
||||
si := srcOff + col*ds*bytesPerPixel
|
||||
di := dstOff + col*4
|
||||
dst.Pix[di+0] = src[si+2]
|
||||
dst.Pix[di+1] = src[si+1]
|
||||
dst.Pix[di+2] = src[si+0]
|
||||
dst.Pix[di+3] = 0xff
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CGCapturer) Capture() (*image.RGBA, error) {
|
||||
cgImage := cgDisplayCreateImage(c.displayID)
|
||||
if cgImage == 0 {
|
||||
return nil, fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)")
|
||||
}
|
||||
defer cgImageRelease(cgImage)
|
||||
|
||||
w := int(cgImageGetWidth(cgImage))
|
||||
h := int(cgImageGetHeight(cgImage))
|
||||
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
||||
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
||||
|
||||
provider := cgImageGetDataProvider(cgImage)
|
||||
if provider == 0 {
|
||||
return nil, fmt.Errorf("CGImageGetDataProvider returned nil")
|
||||
}
|
||||
|
||||
cfData := cgDataProviderCopyData(provider)
|
||||
if cfData == 0 {
|
||||
return nil, fmt.Errorf("CGDataProviderCopyData returned nil")
|
||||
}
|
||||
defer cfRelease(cfData)
|
||||
|
||||
dataLen := int(cfDataGetLength(cfData))
|
||||
dataPtr := cfDataGetBytePtr(cfData)
|
||||
if dataPtr == 0 || dataLen == 0 {
|
||||
return nil, fmt.Errorf("empty image data")
|
||||
}
|
||||
|
||||
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
||||
|
||||
hash := maphash.Bytes(c.hashSeed, src)
|
||||
if c.hasHash && hash == c.lastHash {
|
||||
return nil, errFrameUnchanged
|
||||
}
|
||||
c.lastHash = hash
|
||||
c.hasHash = true
|
||||
|
||||
ds := c.downscale
|
||||
if ds < 1 {
|
||||
ds = 1
|
||||
}
|
||||
outW := w / ds
|
||||
outH := h / ds
|
||||
img := image.NewRGBA(image.Rect(0, 0, outW, outH))
|
||||
|
||||
bytesPerPixel := bpp / 8
|
||||
switch {
|
||||
case bytesPerPixel == 4 && ds == 1:
|
||||
convertBGRAToRGBA(img.Pix, img.Stride, src, bytesPerRow, w, h)
|
||||
case bytesPerPixel == 4 && ds == 2:
|
||||
convertBGRAToRGBADownscale2(img.Pix, img.Stride, src, bytesPerRow, outW, outH)
|
||||
default:
|
||||
convertBGRAToRGBAGeneric(img.Pix, img.Stride, src, bytesPerRow, bgraDownscaleParams{outW: outW, outH: outH, bytesPerPixel: bytesPerPixel, ds: ds})
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
type bgraDownscaleParams struct {
|
||||
outW, outH, bytesPerPixel, ds int
|
||||
}
|
||||
|
||||
// convertBGRAToRGBAGeneric is the slow per-pixel fallback for non-4-bytes
|
||||
// or non-1/2 downscale formats. Always available regardless of the source
|
||||
// format quirks the fast paths optimize for.
|
||||
func convertBGRAToRGBAGeneric(dst []byte, dstStride int, src []byte, srcStride int, p bgraDownscaleParams) {
|
||||
for row := 0; row < p.outH; row++ {
|
||||
srcOff := row * p.ds * srcStride
|
||||
dstOff := row * dstStride
|
||||
for col := 0; col < p.outW; col++ {
|
||||
si := srcOff + col*p.ds*p.bytesPerPixel
|
||||
di := dstOff + col*4
|
||||
dst[di+0] = src[si+2]
|
||||
dst[di+1] = src[si+1]
|
||||
dst[di+2] = src[si+0]
|
||||
dst[di+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// convertBGRAToRGBADownscale2 averages every 2x2 BGRA block into one RGBA
|
||||
// output pixel, parallelised across GOMAXPROCS cores. outW and outH are the
|
||||
// destination dimensions (source is 2*outW by 2*outH).
|
||||
func convertBGRAToRGBADownscale2(dst []byte, dstStride int, src []byte, srcStride, outW, outH int) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > outH {
|
||||
workers = outH
|
||||
}
|
||||
if workers < 1 || outH < 32 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
convertRows := func(y0, y1 int) {
|
||||
for row := y0; row < y1; row++ {
|
||||
srcRow0 := 2 * row * srcStride
|
||||
srcRow1 := srcRow0 + srcStride
|
||||
dstOff := row * dstStride
|
||||
for col := 0; col < outW; col++ {
|
||||
s0 := srcRow0 + col*8
|
||||
s1 := srcRow1 + col*8
|
||||
b := (uint32(src[s0]) + uint32(src[s0+4]) + uint32(src[s1]) + uint32(src[s1+4])) >> 2
|
||||
g := (uint32(src[s0+1]) + uint32(src[s0+5]) + uint32(src[s1+1]) + uint32(src[s1+5])) >> 2
|
||||
r := (uint32(src[s0+2]) + uint32(src[s0+6]) + uint32(src[s1+2]) + uint32(src[s1+6])) >> 2
|
||||
di := dstOff + col*4
|
||||
dst[di+0] = byte(r)
|
||||
dst[di+1] = byte(g)
|
||||
dst[di+2] = byte(b)
|
||||
dst[di+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if workers == 1 {
|
||||
convertRows(0, outH)
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
chunk := (outH + workers - 1) / workers
|
||||
for i := 0; i < workers; i++ {
|
||||
y0 := i * chunk
|
||||
y1 := y0 + chunk
|
||||
if y1 > outH {
|
||||
y1 = outH
|
||||
}
|
||||
if y0 >= y1 {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(y0, y1 int) {
|
||||
defer wg.Done()
|
||||
convertRows(y0, y1)
|
||||
}(y0, y1)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// convertBGRAToRGBA swaps R/B channels using uint32 word operations, and
|
||||
// parallelises across GOMAXPROCS cores for large images.
|
||||
func convertBGRAToRGBA(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > h {
|
||||
workers = h
|
||||
}
|
||||
if workers < 1 || h < 64 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
convertRows := func(y0, y1 int) {
|
||||
rowBytes := w * 4
|
||||
for row := y0; row < y1; row++ {
|
||||
dstRow := dst[row*dstStride : row*dstStride+rowBytes]
|
||||
srcRow := src[row*srcStride : row*srcStride+rowBytes]
|
||||
dstU := unsafe.Slice((*uint32)(unsafe.Pointer(&dstRow[0])), w)
|
||||
srcU := unsafe.Slice((*uint32)(unsafe.Pointer(&srcRow[0])), w)
|
||||
for i, p := range srcU {
|
||||
dstU[i] = (p & 0xff00ff00) | ((p & 0x000000ff) << 16) | ((p & 0x00ff0000) >> 16) | 0xff000000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if workers == 1 {
|
||||
convertRows(0, h)
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
chunk := (h + workers - 1) / workers
|
||||
for i := 0; i < workers; i++ {
|
||||
y0 := i * chunk
|
||||
y1 := y0 + chunk
|
||||
if y1 > h {
|
||||
y1 = h
|
||||
}
|
||||
if y0 >= y1 {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(y0, y1 int) {
|
||||
defer wg.Done()
|
||||
convertRows(y0, y1)
|
||||
}(y0, y1)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// MacPoller wraps CGCapturer with a staleness-cached on-demand Capture:
|
||||
// sessions drive captures themselves from their encoder goroutine, so we
|
||||
// don't need a background ticker. The last result is cached for a short
|
||||
// window so concurrent sessions coalesce into one capture.
|
||||
//
|
||||
// The capturer is allocated lazily on first use and released when all
|
||||
// clients disconnect. Init is retried with backoff because the user may
|
||||
// grant Screen Recording permission while the server is already running.
|
||||
type MacPoller struct {
|
||||
mu sync.Mutex
|
||||
|
||||
capturer *CGCapturer
|
||||
w, h int
|
||||
|
||||
lastFrame *image.RGBA
|
||||
lastAt time.Time
|
||||
|
||||
clients atomic.Int32
|
||||
initFails int
|
||||
initBackoffUntil time.Time
|
||||
closed bool
|
||||
}
|
||||
|
||||
// macInitRetryBackoffFor returns the delay we wait between init attempts
|
||||
// after consecutive failures. Screen Recording permission is a one-shot
|
||||
// user grant, so after several failures we back off aggressively.
|
||||
func macInitRetryBackoffFor(fails int) time.Duration {
|
||||
switch {
|
||||
case fails > 15:
|
||||
return 30 * time.Second
|
||||
case fails > 5:
|
||||
return 10 * time.Second
|
||||
default:
|
||||
return 2 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// NewMacPoller creates a lazy on-demand capturer for the macOS display.
|
||||
func NewMacPoller() *MacPoller {
|
||||
return &MacPoller{}
|
||||
}
|
||||
|
||||
// Wake is a no-op retained for API compatibility. With on-demand capture
|
||||
// there is no background retry loop to kick: init happens on the next
|
||||
// Capture/ClientConnect call.
|
||||
func (p *MacPoller) Wake() {
|
||||
// intentional no-op
|
||||
}
|
||||
|
||||
// ClientConnect increments the active client count and eagerly initialises
|
||||
// the capturer so the first FBUpdateRequest doesn't pay the init cost.
|
||||
func (p *MacPoller) ClientConnect() {
|
||||
if p.clients.Add(1) == 1 {
|
||||
p.mu.Lock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect decrements the active client count. On the last
|
||||
// disconnect the capturer is released.
|
||||
func (p *MacPoller) ClientDisconnect() {
|
||||
if p.clients.Add(-1) == 0 {
|
||||
p.mu.Lock()
|
||||
p.capturer = nil
|
||||
p.lastFrame = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases all resources.
|
||||
func (p *MacPoller) Close() {
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
p.capturer = nil
|
||||
p.lastFrame = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Width returns the screen width. Triggers lazy init if needed.
|
||||
func (p *MacPoller) Width() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.w
|
||||
}
|
||||
|
||||
// Height returns the screen height. Triggers lazy init if needed.
|
||||
func (p *MacPoller) Height() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.h
|
||||
}
|
||||
|
||||
// CaptureInto fills dst directly via the underlying capturer, bypassing
|
||||
// the freshness cache.
|
||||
func (p *MacPoller) CaptureInto(dst *image.RGBA) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := p.capturer.CaptureInto(dst)
|
||||
if errors.Is(err, errFrameUnchanged) {
|
||||
// Caller (session) treats this as "no change"; the dst buffer
|
||||
// keeps its prior contents from the previous capture cycle so
|
||||
// the diff stays meaningful.
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
p.capturer = nil
|
||||
return fmt.Errorf("macos capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capture returns a fresh frame, serving from the short-lived cache if a
|
||||
// previous caller captured within freshWindow. Handles the
|
||||
// errFrameUnchanged return from CGCapturer by reusing the cached frame.
|
||||
func (p *MacPoller) Capture() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow {
|
||||
return p.lastFrame, nil
|
||||
}
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := p.capturer.Capture()
|
||||
if errors.Is(err, errFrameUnchanged) {
|
||||
if p.lastFrame != nil {
|
||||
p.lastAt = time.Now()
|
||||
return p.lastFrame, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
// Drop the capturer so the next call retries init; the display stream
|
||||
// can die if the session changes or permissions are revoked.
|
||||
p.capturer = nil
|
||||
return nil, fmt.Errorf("macos capture: %w", err)
|
||||
}
|
||||
p.lastFrame = img
|
||||
p.lastAt = time.Now()
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// ensureCapturerLocked initialises the underlying CGCapturer if needed.
|
||||
// Caller must hold p.mu.
|
||||
func (p *MacPoller) ensureCapturerLocked() error {
|
||||
if p.closed {
|
||||
return fmt.Errorf("poller closed")
|
||||
}
|
||||
if p.capturer != nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().Before(p.initBackoffUntil) {
|
||||
return fmt.Errorf("macOS capturer unavailable (retry scheduled)")
|
||||
}
|
||||
c, err := NewCGCapturer()
|
||||
if err != nil {
|
||||
p.initFails++
|
||||
p.initBackoffUntil = time.Now().Add(macInitRetryBackoffFor(p.initFails))
|
||||
if p.initFails == 1 || p.initFails%10 == 0 {
|
||||
log.Warnf("macOS capturer: %v (attempt %d)", err, p.initFails)
|
||||
} else {
|
||||
log.Debugf("macOS capturer: %v (attempt %d)", err, p.initFails)
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.initFails = 0
|
||||
p.capturer = c
|
||||
p.w, p.h = c.Width(), c.Height()
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ ScreenCapturer = (*MacPoller)(nil)
|
||||
99
client/vnc/server/capture_dxgi_windows.go
Normal file
99
client/vnc/server/capture_dxgi_windows.go
Normal file
@@ -0,0 +1,99 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/kirides/go-d3d/d3d11"
|
||||
"github.com/kirides/go-d3d/outputduplication"
|
||||
)
|
||||
|
||||
// dxgiCapturer captures the desktop using DXGI Desktop Duplication.
|
||||
// Provides GPU-accelerated capture with native dirty rect tracking.
|
||||
// Only works from the interactive user session, not Session 0.
|
||||
//
|
||||
// Uses a double-buffer: DXGI writes into img, then we copy to the current
|
||||
// output buffer and hand it out. Alternating between two output buffers
|
||||
// avoids allocating a new image.RGBA per frame (~8MB at 1080p, 30fps).
|
||||
type dxgiCapturer struct {
|
||||
dup *outputduplication.OutputDuplicator
|
||||
device *d3d11.ID3D11Device
|
||||
ctx *d3d11.ID3D11DeviceContext
|
||||
img *image.RGBA
|
||||
out [2]*image.RGBA
|
||||
outIdx int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newDXGICapturer() (*dxgiCapturer, error) {
|
||||
device, deviceCtx, err := d3d11.NewD3D11Device()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create D3D11 device: %w", err)
|
||||
}
|
||||
|
||||
dup, err := outputduplication.NewIDXGIOutputDuplication(device, deviceCtx, 0)
|
||||
if err != nil {
|
||||
device.Release()
|
||||
deviceCtx.Release()
|
||||
return nil, fmt.Errorf("create output duplication: %w", err)
|
||||
}
|
||||
|
||||
w, h := screenSize()
|
||||
if w == 0 || h == 0 {
|
||||
dup.Release()
|
||||
device.Release()
|
||||
deviceCtx.Release()
|
||||
return nil, fmt.Errorf("screen dimensions are zero")
|
||||
}
|
||||
|
||||
rect := image.Rect(0, 0, w, h)
|
||||
c := &dxgiCapturer{
|
||||
dup: dup,
|
||||
device: device,
|
||||
ctx: deviceCtx,
|
||||
img: image.NewRGBA(rect),
|
||||
out: [2]*image.RGBA{image.NewRGBA(rect), image.NewRGBA(rect)},
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
|
||||
// Grab the initial frame with a longer timeout to ensure we have
|
||||
// a valid image before returning.
|
||||
_ = dup.GetImage(c.img, 2000)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *dxgiCapturer) capture() (*image.RGBA, error) {
|
||||
err := c.dup.GetImage(c.img, 100)
|
||||
if err != nil && !errors.Is(err, outputduplication.ErrNoImageYet) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Copy into the next output buffer. The DesktopCapturer hands out the
|
||||
// returned pointer to VNC sessions that read pixels concurrently, so we
|
||||
// alternate between two pre-allocated buffers instead of allocating per frame.
|
||||
out := c.out[c.outIdx]
|
||||
c.outIdx ^= 1
|
||||
copy(out.Pix, c.img.Pix)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dxgiCapturer) close() {
|
||||
if c.dup != nil {
|
||||
c.dup.Release()
|
||||
c.dup = nil
|
||||
}
|
||||
if c.ctx != nil {
|
||||
c.ctx.Release()
|
||||
c.ctx = nil
|
||||
}
|
||||
if c.device != nil {
|
||||
c.device.Release()
|
||||
c.device = nil
|
||||
}
|
||||
}
|
||||
148
client/vnc/server/capture_fb_freebsd.go
Normal file
148
client/vnc/server/capture_fb_freebsd.go
Normal file
@@ -0,0 +1,148 @@
|
||||
//go:build freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// FreeBSD vt(4) framebuffer ioctl numbers from sys/fbio.h.
|
||||
//
|
||||
// #define FBIOGTYPE _IOR('F', 0, struct fbtype)
|
||||
//
|
||||
// _IOR(g, n, t) on FreeBSD: dir=2 (read) <<30 | (sizeof(t) & 0x1fff)<<16
|
||||
// | (g<<8) | n. sizeof(struct fbtype)=24 → 0x40184600.
|
||||
const fbioGType = 0x40184600
|
||||
|
||||
func defaultFBPath() string { return "/dev/ttyv0" }
|
||||
|
||||
// fbType mirrors FreeBSD's struct fbtype.
|
||||
type fbType struct {
|
||||
FbType int32
|
||||
FbHeight int32
|
||||
FbWidth int32
|
||||
FbDepth int32
|
||||
FbCMSize int32
|
||||
FbSize int32
|
||||
}
|
||||
|
||||
// FBCapturer reads pixels from FreeBSD's vt(4) framebuffer device. The
|
||||
// vt(4) console exposes the active framebuffer via ttyv0 with FBIOGTYPE
|
||||
// for geometry and mmap for backing memory. Pixel layout is assumed to
|
||||
// be 32bpp BGRA (the common case for KMS-backed vt); fbtype doesn't
|
||||
// expose channel offsets, so we don't try to handle exotic layouts here.
|
||||
type FBCapturer struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
fd int
|
||||
mmap []byte
|
||||
w, h int
|
||||
bpp int
|
||||
stride int
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewFBCapturer opens the given vt(4) device and queries its geometry.
|
||||
func NewFBCapturer(path string) (*FBCapturer, error) {
|
||||
if path == "" {
|
||||
path = defaultFBPath()
|
||||
}
|
||||
fd, err := unix.Open(path, unix.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
|
||||
var fbt fbType
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGType, uintptr(unsafe.Pointer(&fbt))); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("FBIOGTYPE: %v", e)
|
||||
}
|
||||
if fbt.FbDepth != 16 && fbt.FbDepth != 24 && fbt.FbDepth != 32 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("unsupported framebuffer depth: %d", fbt.FbDepth)
|
||||
}
|
||||
if fbt.FbWidth <= 0 || fbt.FbHeight <= 0 || fbt.FbSize <= 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("invalid framebuffer geometry: %dx%d size=%d", fbt.FbWidth, fbt.FbHeight, fbt.FbSize)
|
||||
}
|
||||
|
||||
mm, err := unix.Mmap(fd, 0, int(fbt.FbSize), unix.PROT_READ, unix.MAP_SHARED)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("mmap %s: %w (vt may not support mmap on this driver, e.g. virtio_gpu)", path, err)
|
||||
}
|
||||
|
||||
bpp := int(fbt.FbDepth)
|
||||
stride := int(fbt.FbWidth) * (bpp / 8)
|
||||
c := &FBCapturer{
|
||||
path: path,
|
||||
fd: fd, // valid fd >= 0; we use -1 as the closed sentinel
|
||||
mmap: mm,
|
||||
w: int(fbt.FbWidth),
|
||||
h: int(fbt.FbHeight),
|
||||
bpp: bpp,
|
||||
stride: stride,
|
||||
}
|
||||
log.Infof("framebuffer capturer ready: %s %dx%d bpp=%d (freebsd vt)", path, c.w, c.h, c.bpp)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Width returns the framebuffer width.
|
||||
func (c *FBCapturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the framebuffer height.
|
||||
func (c *FBCapturer) Height() int { return c.h }
|
||||
|
||||
// Capture allocates a fresh image and fills it with the current
|
||||
// framebuffer contents.
|
||||
func (c *FBCapturer) Capture() (*image.RGBA, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, c.w, c.h))
|
||||
if err := c.CaptureInto(img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// CaptureInto reads the framebuffer directly into dst.Pix. Assumes BGRA
|
||||
// for 32bpp; the FreeBSD fbtype struct doesn't expose channel offsets.
|
||||
func (c *FBCapturer) CaptureInto(dst *image.RGBA) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d fb=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h)
|
||||
}
|
||||
switch c.bpp {
|
||||
case 32:
|
||||
// vt(4) on KMS framebuffers is BGRA: byte 0=B, 1=G, 2=R.
|
||||
swizzleBGRAtoRGBA(dst.Pix, c.mmap[:c.h*c.stride])
|
||||
case 24:
|
||||
swizzleFB24(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
case 16:
|
||||
swizzleFB16RGB565(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the framebuffer mmap and file descriptor. Serialized with
|
||||
// CaptureInto via c.mu so an in-flight capture can't read freed memory.
|
||||
func (c *FBCapturer) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.mmap != nil {
|
||||
_ = unix.Munmap(c.mmap)
|
||||
c.mmap = nil
|
||||
}
|
||||
if c.fd >= 0 {
|
||||
_ = unix.Close(c.fd)
|
||||
c.fd = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
229
client/vnc/server/capture_fb_linux.go
Normal file
229
client/vnc/server/capture_fb_linux.go
Normal file
@@ -0,0 +1,229 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Linux framebuffer ioctls (linux/fb.h).
|
||||
const (
|
||||
fbioGetVScreenInfo = 0x4600
|
||||
fbioGetFScreenInfo = 0x4602
|
||||
)
|
||||
|
||||
func defaultFBPath() string { return "/dev/fb0" }
|
||||
|
||||
// fbVarScreenInfo mirrors the kernel's fb_var_screeninfo. Only the
|
||||
// fields we use are mapped; the rest are absorbed into _padN.
|
||||
type fbVarScreenInfo struct {
|
||||
Xres, Yres uint32
|
||||
XresVirtual, YresVirtual uint32
|
||||
XOffset, YOffset uint32
|
||||
BitsPerPixel uint32
|
||||
Grayscale uint32
|
||||
RedOffset, RedLen, RedMSBR uint32
|
||||
GreenOffset, GreenLen, GreenMSBR uint32
|
||||
BlueOffset, BlueLen, BlueMSBR uint32
|
||||
TranspOffset, TranspLen, TranspM uint32
|
||||
NonStd uint32
|
||||
Activate uint32
|
||||
Height, Width uint32
|
||||
AccelFlags uint32
|
||||
PixClock uint32
|
||||
LeftMargin, RightMargin uint32
|
||||
UpperMargin, LowerMargin uint32
|
||||
HsyncLen, VsyncLen uint32
|
||||
Sync uint32
|
||||
Vmode uint32
|
||||
Rotate uint32
|
||||
Colorspace uint32
|
||||
_pad [4]uint32
|
||||
}
|
||||
|
||||
// fbFixScreenInfo mirrors fb_fix_screeninfo. We only need LineLength.
|
||||
type fbFixScreenInfo struct {
|
||||
IDStr [16]byte
|
||||
SmemStart uint64
|
||||
SmemLen uint32
|
||||
Type uint32
|
||||
TypeAux uint32
|
||||
Visual uint32
|
||||
XPanStep uint16
|
||||
YPanStep uint16
|
||||
YWrapStep uint16
|
||||
_pad0 uint16
|
||||
LineLength uint32
|
||||
MmioStart uint64
|
||||
MmioLen uint32
|
||||
Accel uint32
|
||||
Capabilities uint16
|
||||
_reserved [2]uint16
|
||||
}
|
||||
|
||||
// FBCapturer reads pixels straight from the Linux framebuffer device.
|
||||
// Used as a fallback when X11 isn't available, e.g. on a headless box at
|
||||
// the kernel console or the display manager's pre-login screen on machines
|
||||
// without an Xorg server. The framebuffer must be mmap()-able under our
|
||||
// process privileges (typically the netbird service runs as root).
|
||||
type FBCapturer struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
fd int
|
||||
mmap []byte
|
||||
w, h int
|
||||
bpp int
|
||||
stride int
|
||||
rOff uint32
|
||||
gOff uint32
|
||||
bOff uint32
|
||||
rLen uint32
|
||||
gLen uint32
|
||||
bLen uint32
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewFBCapturer opens the given framebuffer device (/dev/fbN) and
|
||||
// queries its current geometry + pixel format.
|
||||
func NewFBCapturer(path string) (*FBCapturer, error) {
|
||||
if path == "" {
|
||||
path = "/dev/fb0"
|
||||
}
|
||||
fd, err := unix.Open(path, unix.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
|
||||
var vinfo fbVarScreenInfo
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGetVScreenInfo, uintptr(unsafe.Pointer(&vinfo))); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %v", e)
|
||||
}
|
||||
var finfo fbFixScreenInfo
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGetFScreenInfo, uintptr(unsafe.Pointer(&finfo))); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %v", e)
|
||||
}
|
||||
|
||||
bpp := int(vinfo.BitsPerPixel)
|
||||
if bpp != 16 && bpp != 24 && bpp != 32 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("unsupported framebuffer bpp: %d", bpp)
|
||||
}
|
||||
|
||||
size := int(finfo.LineLength) * int(vinfo.Yres)
|
||||
if size <= 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("invalid framebuffer dimensions: stride=%d h=%d", finfo.LineLength, vinfo.Yres)
|
||||
}
|
||||
|
||||
mm, err := unix.Mmap(fd, 0, size, unix.PROT_READ, unix.MAP_SHARED)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("mmap %s: %w", path, err)
|
||||
}
|
||||
|
||||
c := &FBCapturer{
|
||||
path: path,
|
||||
fd: fd,
|
||||
mmap: mm,
|
||||
w: int(vinfo.Xres),
|
||||
h: int(vinfo.Yres),
|
||||
bpp: bpp,
|
||||
stride: int(finfo.LineLength),
|
||||
rOff: vinfo.RedOffset,
|
||||
gOff: vinfo.GreenOffset,
|
||||
bOff: vinfo.BlueOffset,
|
||||
rLen: vinfo.RedLen,
|
||||
gLen: vinfo.GreenLen,
|
||||
bLen: vinfo.BlueLen,
|
||||
}
|
||||
log.Infof("framebuffer capturer ready: %s %dx%d bpp=%d r=%d/%d g=%d/%d b=%d/%d",
|
||||
path, c.w, c.h, c.bpp, c.rOff, c.rLen, c.gOff, c.gLen, c.bOff, c.bLen)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Width returns the framebuffer width in pixels.
|
||||
func (c *FBCapturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the framebuffer height in pixels.
|
||||
func (c *FBCapturer) Height() int { return c.h }
|
||||
|
||||
// Capture allocates a fresh image and fills it with the current
|
||||
// framebuffer contents.
|
||||
func (c *FBCapturer) Capture() (*image.RGBA, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, c.w, c.h))
|
||||
if err := c.CaptureInto(img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// CaptureInto reads the framebuffer directly into dst.Pix.
|
||||
func (c *FBCapturer) CaptureInto(dst *image.RGBA) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d fb=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h)
|
||||
}
|
||||
|
||||
switch c.bpp {
|
||||
case 32:
|
||||
swizzleFB32(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h, channelShifts{R: c.rOff, G: c.gOff, B: c.bOff})
|
||||
case 24:
|
||||
swizzleFB24(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
case 16:
|
||||
swizzleFB16RGB565(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the framebuffer mmap and file descriptor. Serialized with
|
||||
// CaptureInto via c.mu so an in-flight capture can't read freed memory.
|
||||
func (c *FBCapturer) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.mmap != nil {
|
||||
_ = unix.Munmap(c.mmap)
|
||||
c.mmap = nil
|
||||
}
|
||||
if c.fd >= 0 {
|
||||
_ = unix.Close(c.fd)
|
||||
c.fd = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// channelShifts groups the bit offsets for the R/G/B channels in a packed
|
||||
// uint32 framebuffer pixel. Bundling avoids drowning per-row callers in a
|
||||
// 9-parameter signature.
|
||||
type channelShifts struct {
|
||||
R, G, B uint32
|
||||
}
|
||||
|
||||
// swizzleFB32 handles 32-bit framebuffers with arbitrary R/G/B channel
|
||||
// offsets. Pulls one pixel per uint32, then masks each channel into the
|
||||
// destination RGBA byte order.
|
||||
func swizzleFB32(dst []byte, dstStride int, src []byte, srcStride, w, h int, shifts channelShifts) {
|
||||
for y := 0; y < h; y++ {
|
||||
srcRow := src[y*srcStride : y*srcStride+w*4]
|
||||
dstRow := dst[y*dstStride:]
|
||||
for x := 0; x < w; x++ {
|
||||
pix := binary.LittleEndian.Uint32(srcRow[x*4 : x*4+4])
|
||||
dstRow[x*4+0] = byte(pix >> shifts.R)
|
||||
dstRow[x*4+1] = byte(pix >> shifts.G)
|
||||
dstRow[x*4+2] = byte(pix >> shifts.B)
|
||||
dstRow[x*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
149
client/vnc/server/capture_fb_unix.go
Normal file
149
client/vnc/server/capture_fb_unix.go
Normal file
@@ -0,0 +1,149 @@
|
||||
//go:build unix && !darwin && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"image"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FBPoller wraps FBCapturer with the same lifecycle (ClientConnect /
|
||||
// ClientDisconnect, lazy init) as X11Poller, so it slots into the same
|
||||
// session plumbing without code changes upstream. The concrete
|
||||
// FBCapturer is platform-specific (capture_fb_linux.go / _freebsd.go);
|
||||
// this file owns the cross-platform glue.
|
||||
type FBPoller struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
capturer *FBCapturer
|
||||
w, h int
|
||||
clients int32
|
||||
}
|
||||
|
||||
// NewFBPoller returns a poller that opens path on first use. Empty path
|
||||
// defaults to /dev/fb0 on Linux and /dev/ttyv0 on FreeBSD.
|
||||
func NewFBPoller(path string) *FBPoller {
|
||||
if path == "" {
|
||||
path = defaultFBPath()
|
||||
}
|
||||
return &FBPoller{path: path}
|
||||
}
|
||||
|
||||
// ClientConnect eagerly initialises the capturer on first connect.
|
||||
func (p *FBPoller) ClientConnect() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.clients++
|
||||
if p.clients == 1 {
|
||||
_ = p.ensureCapturerLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect closes the capturer when the last client leaves.
|
||||
func (p *FBPoller) ClientDisconnect() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.clients--
|
||||
if p.clients <= 0 && p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the framebuffer width, doing lazy init if needed.
|
||||
func (p *FBPoller) Width() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.w
|
||||
}
|
||||
|
||||
// Height returns the framebuffer height, doing lazy init if needed.
|
||||
func (p *FBPoller) Height() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.h
|
||||
}
|
||||
|
||||
// Capture takes a fresh frame.
|
||||
func (p *FBPoller) Capture() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.capturer.Capture()
|
||||
}
|
||||
|
||||
// CaptureInto fills dst directly.
|
||||
func (p *FBPoller) CaptureInto(dst *image.RGBA) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.capturer.CaptureInto(dst)
|
||||
}
|
||||
|
||||
// Close releases all framebuffer resources.
|
||||
func (p *FBPoller) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FBPoller) ensureCapturerLocked() error {
|
||||
if p.capturer != nil {
|
||||
return nil
|
||||
}
|
||||
c, err := NewFBCapturer(p.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.capturer = c
|
||||
p.w, p.h = c.Width(), c.Height()
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ ScreenCapturer = (*FBPoller)(nil)
|
||||
var _ captureIntoer = (*FBPoller)(nil)
|
||||
|
||||
// swizzleFB24 handles 24-bit packed framebuffers (B,G,R triplets).
|
||||
// Shared between Linux and FreeBSD framebuffer paths.
|
||||
func swizzleFB24(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
||||
for y := 0; y < h; y++ {
|
||||
srcRow := src[y*srcStride : y*srcStride+w*3]
|
||||
dstRow := dst[y*dstStride:]
|
||||
for x := 0; x < w; x++ {
|
||||
b := srcRow[x*3+0]
|
||||
g := srcRow[x*3+1]
|
||||
r := srcRow[x*3+2]
|
||||
dstRow[x*4+0] = r
|
||||
dstRow[x*4+1] = g
|
||||
dstRow[x*4+2] = b
|
||||
dstRow[x*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swizzleFB16RGB565 handles 16bpp RGB 565 framebuffers.
|
||||
func swizzleFB16RGB565(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
||||
for y := 0; y < h; y++ {
|
||||
srcRow := src[y*srcStride : y*srcStride+w*2]
|
||||
dstRow := dst[y*dstStride:]
|
||||
for x := 0; x < w; x++ {
|
||||
pix := uint16(srcRow[x*2]) | uint16(srcRow[x*2+1])<<8
|
||||
r := byte((pix >> 11) & 0x1f)
|
||||
g := byte((pix >> 5) & 0x3f)
|
||||
b := byte(pix & 0x1f)
|
||||
dstRow[x*4+0] = (r << 3) | (r >> 2)
|
||||
dstRow[x*4+1] = (g << 2) | (g >> 4)
|
||||
dstRow[x*4+2] = (b << 3) | (b >> 2)
|
||||
dstRow[x*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
556
client/vnc/server/capture_windows.go
Normal file
556
client/vnc/server/capture_windows.go
Normal file
@@ -0,0 +1,556 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
|
||||
procGetDC = user32.NewProc("GetDC")
|
||||
procReleaseDC = user32.NewProc("ReleaseDC")
|
||||
procCreateCompatDC = gdi32.NewProc("CreateCompatibleDC")
|
||||
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
|
||||
procSelectObject = gdi32.NewProc("SelectObject")
|
||||
procDeleteObject = gdi32.NewProc("DeleteObject")
|
||||
procDeleteDC = gdi32.NewProc("DeleteDC")
|
||||
procBitBlt = gdi32.NewProc("BitBlt")
|
||||
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
|
||||
|
||||
// Desktop switching for service/Session 0 capture.
|
||||
procOpenInputDesktop = user32.NewProc("OpenInputDesktop")
|
||||
procSetThreadDesktop = user32.NewProc("SetThreadDesktop")
|
||||
procCloseDesktop = user32.NewProc("CloseDesktop")
|
||||
procOpenWindowStation = user32.NewProc("OpenWindowStationW")
|
||||
procSetProcessWindowStation = user32.NewProc("SetProcessWindowStation")
|
||||
procCloseWindowStation = user32.NewProc("CloseWindowStation")
|
||||
procGetUserObjectInformationW = user32.NewProc("GetUserObjectInformationW")
|
||||
)
|
||||
|
||||
const uoiName = 2
|
||||
|
||||
const (
|
||||
smCxScreen = 0
|
||||
smCyScreen = 1
|
||||
srccopy = 0x00CC0020
|
||||
captureBlt = 0x40000000
|
||||
dibRgbColors = 0
|
||||
)
|
||||
|
||||
type bitmapInfoHeader struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
}
|
||||
|
||||
type bitmapInfo struct {
|
||||
Header bitmapInfoHeader
|
||||
}
|
||||
|
||||
// setupInteractiveWindowStation associates the current process with WinSta0,
|
||||
// the interactive window station. This is required for a SYSTEM service in
|
||||
// Session 0 to call OpenInputDesktop for screen capture and input injection.
|
||||
func setupInteractiveWindowStation() error {
|
||||
name, err := windows.UTF16PtrFromString("WinSta0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("UTF16 WinSta0: %w", err)
|
||||
}
|
||||
hWinSta, _, err := procOpenWindowStation.Call(
|
||||
uintptr(unsafe.Pointer(name)),
|
||||
0,
|
||||
uintptr(windows.MAXIMUM_ALLOWED),
|
||||
)
|
||||
if hWinSta == 0 {
|
||||
return fmt.Errorf("OpenWindowStation(WinSta0): %w", err)
|
||||
}
|
||||
r, _, err := procSetProcessWindowStation.Call(hWinSta)
|
||||
if r == 0 {
|
||||
_, _, _ = procCloseWindowStation.Call(hWinSta)
|
||||
return fmt.Errorf("SetProcessWindowStation: %w", err)
|
||||
}
|
||||
log.Info("process window station set to WinSta0 (interactive)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func screenSize() (int, int) {
|
||||
w, _, _ := procGetSystemMetrics.Call(uintptr(smCxScreen))
|
||||
h, _, _ := procGetSystemMetrics.Call(uintptr(smCyScreen))
|
||||
return int(w), int(h)
|
||||
}
|
||||
|
||||
func getDesktopName(hDesk uintptr) string {
|
||||
var buf [256]uint16
|
||||
var needed uint32
|
||||
_, _, _ = procGetUserObjectInformationW.Call(hDesk, uoiName,
|
||||
uintptr(unsafe.Pointer(&buf[0])), 512,
|
||||
uintptr(unsafe.Pointer(&needed)))
|
||||
return windows.UTF16ToString(buf[:])
|
||||
}
|
||||
|
||||
// switchToInputDesktop opens the desktop currently receiving user input
|
||||
// and sets it as the calling OS thread's desktop. Must be called from a
|
||||
// goroutine locked to its OS thread via runtime.LockOSThread().
|
||||
func switchToInputDesktop() (bool, string) {
|
||||
hDesk, _, _ := procOpenInputDesktop.Call(0, 0, uintptr(windows.MAXIMUM_ALLOWED))
|
||||
if hDesk == 0 {
|
||||
return false, ""
|
||||
}
|
||||
name := getDesktopName(hDesk)
|
||||
ret, _, _ := procSetThreadDesktop.Call(hDesk)
|
||||
_, _, _ = procCloseDesktop.Call(hDesk)
|
||||
return ret != 0, name
|
||||
}
|
||||
|
||||
// gdiCapturer captures the desktop screen using GDI BitBlt.
|
||||
// GDI objects (DC, DIBSection) are allocated once and reused across frames.
|
||||
type gdiCapturer struct {
|
||||
mu sync.Mutex
|
||||
width int
|
||||
height int
|
||||
|
||||
// Pre-allocated GDI resources, reused across captures.
|
||||
memDC uintptr
|
||||
bmp uintptr
|
||||
bits uintptr
|
||||
}
|
||||
|
||||
func newGDICapturer() (*gdiCapturer, error) {
|
||||
w, h := screenSize()
|
||||
if w == 0 || h == 0 {
|
||||
return nil, fmt.Errorf("screen dimensions are zero")
|
||||
}
|
||||
c := &gdiCapturer{width: w, height: h}
|
||||
if err := c.allocGDI(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// allocGDI pre-allocates the compatible DC and DIB section for reuse.
|
||||
func (c *gdiCapturer) allocGDI() error {
|
||||
screenDC, _, _ := procGetDC.Call(0)
|
||||
if screenDC == 0 {
|
||||
return fmt.Errorf("GetDC returned 0")
|
||||
}
|
||||
defer func() { _, _, _ = procReleaseDC.Call(0, screenDC) }()
|
||||
|
||||
memDC, _, _ := procCreateCompatDC.Call(screenDC)
|
||||
if memDC == 0 {
|
||||
return fmt.Errorf("CreateCompatibleDC returned 0")
|
||||
}
|
||||
|
||||
bi := bitmapInfo{
|
||||
Header: bitmapInfoHeader{
|
||||
Size: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||
Width: int32(c.width),
|
||||
Height: -int32(c.height), // negative = top-down DIB
|
||||
Planes: 1,
|
||||
BitCount: 32,
|
||||
},
|
||||
}
|
||||
|
||||
var bits uintptr
|
||||
bmp, _, _ := procCreateDIBSection.Call(
|
||||
screenDC,
|
||||
uintptr(unsafe.Pointer(&bi)),
|
||||
dibRgbColors,
|
||||
uintptr(unsafe.Pointer(&bits)),
|
||||
0, 0,
|
||||
)
|
||||
if bmp == 0 || bits == 0 {
|
||||
_, _, _ = procDeleteDC.Call(memDC)
|
||||
return fmt.Errorf("CreateDIBSection returned 0")
|
||||
}
|
||||
|
||||
_, _, _ = procSelectObject.Call(memDC, bmp)
|
||||
|
||||
c.memDC = memDC
|
||||
c.bmp = bmp
|
||||
c.bits = bits
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *gdiCapturer) close() { c.freeGDI() }
|
||||
|
||||
// freeGDI releases pre-allocated GDI resources.
|
||||
func (c *gdiCapturer) freeGDI() {
|
||||
if c.bmp != 0 {
|
||||
_, _, _ = procDeleteObject.Call(c.bmp)
|
||||
c.bmp = 0
|
||||
}
|
||||
if c.memDC != 0 {
|
||||
_, _, _ = procDeleteDC.Call(c.memDC)
|
||||
c.memDC = 0
|
||||
}
|
||||
c.bits = 0
|
||||
}
|
||||
|
||||
func (c *gdiCapturer) capture() (*image.RGBA, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.memDC == 0 {
|
||||
return nil, fmt.Errorf("GDI resources not allocated")
|
||||
}
|
||||
|
||||
screenDC, _, _ := procGetDC.Call(0)
|
||||
if screenDC == 0 {
|
||||
return nil, fmt.Errorf("GetDC returned 0")
|
||||
}
|
||||
defer func() { _, _, _ = procReleaseDC.Call(0, screenDC) }()
|
||||
|
||||
// SRCCOPY|CAPTUREBLT: CAPTUREBLT forces inclusion of layered/topmost
|
||||
// windows in the capture and is required for GDI BitBlt to return live
|
||||
// pixels when the session is rendered through RDP / DWM-composited
|
||||
// surfaces. Without it BitBlt reads the backing-store DIB which is
|
||||
// often empty (all-black) on RDP and headless sessions.
|
||||
ret, _, _ := procBitBlt.Call(c.memDC, 0, 0, uintptr(c.width), uintptr(c.height),
|
||||
screenDC, 0, 0, srccopy|captureBlt)
|
||||
if ret == 0 {
|
||||
return nil, fmt.Errorf("BitBlt returned 0")
|
||||
}
|
||||
|
||||
n := c.width * c.height * 4
|
||||
raw := unsafe.Slice((*byte)(unsafe.Pointer(c.bits)), n)
|
||||
|
||||
// GDI gives BGRA, the RFB encoder expects RGBA (img.Pix layout).
|
||||
// Swap R and B in bulk using uint32 operations (one load + mask + shift
|
||||
// per pixel instead of three separate byte assignments).
|
||||
img := image.NewRGBA(image.Rect(0, 0, c.width, c.height))
|
||||
swizzleBGRAtoRGBA(img.Pix, raw)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// DesktopCapturer captures the interactive desktop, handling desktop transitions
|
||||
// (login screen, UAC prompts). A dedicated OS-locked goroutine continuously
|
||||
// captures frames on demand via a dedicated OS-locked goroutine (required
|
||||
// because DXGI's D3D11 device context is not thread-safe). Sessions drive
|
||||
// timing by calling Capture(); a short staleness cache coalesces concurrent
|
||||
// requests. Capture pauses automatically when no clients are connected.
|
||||
type DesktopCapturer struct {
|
||||
mu sync.Mutex
|
||||
w, h int
|
||||
|
||||
// lastFrame/lastAt implement a small staleness cache so multiple
|
||||
// near-simultaneous Capture calls share one DXGI round-trip.
|
||||
lastFrame *image.RGBA
|
||||
lastAt time.Time
|
||||
|
||||
// clients tracks the number of active VNC sessions. When zero, the
|
||||
// worker goroutine releases the underlying capturer.
|
||||
clients atomic.Int32
|
||||
|
||||
// reqCh carries capture requests from sessions to the OS-locked worker.
|
||||
reqCh chan captureReq
|
||||
// wake is signaled when a client connects and the worker should resume.
|
||||
wake chan struct{}
|
||||
// done is closed when Close is called, terminating the worker.
|
||||
done chan struct{}
|
||||
|
||||
// cursorState holds the latest cursor sprite sampled by the worker.
|
||||
// The worker calls GetCursorInfo every capture and decodes a new
|
||||
// sprite only when the HCURSOR changes.
|
||||
cursorState cursorState
|
||||
}
|
||||
|
||||
// captureReq is a single capture request awaiting a reply. Reply channel is
|
||||
// buffered to size 1 so the worker never blocks on a sender that's gone.
|
||||
type captureReq struct {
|
||||
reply chan captureReply
|
||||
}
|
||||
|
||||
type captureReply struct {
|
||||
img *image.RGBA
|
||||
err error
|
||||
}
|
||||
|
||||
// NewDesktopCapturer creates an on-demand capturer for the active desktop.
|
||||
func NewDesktopCapturer() *DesktopCapturer {
|
||||
c := &DesktopCapturer{
|
||||
wake: make(chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
reqCh: make(chan captureReq),
|
||||
}
|
||||
go c.worker()
|
||||
return c
|
||||
}
|
||||
|
||||
// ClientConnect increments the active client count, resuming capture if needed.
|
||||
func (c *DesktopCapturer) ClientConnect() {
|
||||
c.clients.Add(1)
|
||||
select {
|
||||
case c.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect decrements the active client count.
|
||||
func (c *DesktopCapturer) ClientDisconnect() {
|
||||
c.clients.Add(-1)
|
||||
}
|
||||
|
||||
// Close stops the capture loop and releases resources.
|
||||
func (c *DesktopCapturer) Close() {
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the current screen width, triggering a capture if the
|
||||
// worker hasn't initialised yet. validateCapturer depends on Width/Height
|
||||
// becoming non-zero promptly after ClientConnect so it doesn't reject
|
||||
// brand-new sessions.
|
||||
func (c *DesktopCapturer) Width() int {
|
||||
c.mu.Lock()
|
||||
w := c.w
|
||||
c.mu.Unlock()
|
||||
if w == 0 && c.clients.Load() > 0 {
|
||||
_, _ = c.Capture()
|
||||
c.mu.Lock()
|
||||
w = c.w
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Height returns the current screen height, triggering a capture if the
|
||||
// worker hasn't initialised yet (see Width). Returns 0 while no client is
|
||||
// connected so callers don't deadlock against a parked worker.
|
||||
func (c *DesktopCapturer) Height() int {
|
||||
c.mu.Lock()
|
||||
h := c.h
|
||||
c.mu.Unlock()
|
||||
if h == 0 && c.clients.Load() > 0 {
|
||||
_, _ = c.Capture()
|
||||
c.mu.Lock()
|
||||
h = c.h
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Capture returns a freshly captured frame, serving from a short staleness
|
||||
// cache when multiple sessions ask within freshWindow of each other. All
|
||||
// real DXGI/GDI work happens on the OS-locked worker goroutine.
|
||||
func (c *DesktopCapturer) Capture() (*image.RGBA, error) {
|
||||
c.mu.Lock()
|
||||
if c.lastFrame != nil && time.Since(c.lastAt) < freshWindow {
|
||||
img := c.lastFrame
|
||||
c.mu.Unlock()
|
||||
return img, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
reply := make(chan captureReply, 1)
|
||||
select {
|
||||
case c.reqCh <- captureReq{reply: reply}:
|
||||
case <-c.done:
|
||||
return nil, fmt.Errorf("capturer closed")
|
||||
}
|
||||
select {
|
||||
case r := <-reply:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.lastFrame = r.img
|
||||
c.lastAt = time.Now()
|
||||
c.mu.Unlock()
|
||||
return r.img, nil
|
||||
case <-c.done:
|
||||
return nil, fmt.Errorf("capturer closed")
|
||||
}
|
||||
}
|
||||
|
||||
// waitForClient blocks until a client connects or the capturer is closed.
|
||||
func (c *DesktopCapturer) waitForClient() bool {
|
||||
if c.clients.Load() > 0 {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-c.wake:
|
||||
return true
|
||||
case <-c.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// worker owns DXGI/GDI state on its OS-locked thread and services capture
|
||||
// requests from sessions. No background ticker: a capture happens only when
|
||||
// a session asks for one (throttled by Capture()'s staleness cache).
|
||||
func (c *DesktopCapturer) worker() {
|
||||
runtime.LockOSThread()
|
||||
|
||||
// When running as a Windows service (Session 0), we need to attach to the
|
||||
// interactive window station before OpenInputDesktop will succeed.
|
||||
if err := setupInteractiveWindowStation(); err != nil {
|
||||
log.Warnf("attach to interactive window station: %v", err)
|
||||
}
|
||||
|
||||
w := &captureWorker{c: c}
|
||||
defer w.closeCapturer()
|
||||
|
||||
for {
|
||||
if !c.waitForClient() {
|
||||
return
|
||||
}
|
||||
// Drop the capturer when all clients have disconnected so we don't
|
||||
// hold the DXGI duplication or GDI DC on an idle peer.
|
||||
if c.clients.Load() <= 0 {
|
||||
w.closeCapturer()
|
||||
continue
|
||||
}
|
||||
if !w.handleNextRequest() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameCapturer is the per-backend interface used by the worker. DXGI and
|
||||
// GDI implementations both satisfy it.
|
||||
type frameCapturer interface {
|
||||
capture() (*image.RGBA, error)
|
||||
close()
|
||||
}
|
||||
|
||||
// captureWorker owns the worker goroutine's mutable state. Extracted into a
|
||||
// struct so the request/desktop/init logic can live on small methods and the
|
||||
// outer worker() stays a thin loop.
|
||||
type captureWorker struct {
|
||||
c *DesktopCapturer
|
||||
cap frameCapturer
|
||||
desktopFails int
|
||||
lastDesktop string
|
||||
nextInitRetry time.Time
|
||||
cursor cursorSampler
|
||||
}
|
||||
|
||||
// handleNextRequest waits for either shutdown or a capture request and runs
|
||||
// the request through prepCapturer/capture. Returns false when the worker
|
||||
// should exit.
|
||||
func (w *captureWorker) handleNextRequest() bool {
|
||||
select {
|
||||
case <-w.c.done:
|
||||
return false
|
||||
case req := <-w.c.reqCh:
|
||||
w.serveRequest(req)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (w *captureWorker) serveRequest(req captureReq) {
|
||||
fc, err := w.prepCapturer()
|
||||
if err != nil {
|
||||
req.reply <- captureReply{err: err}
|
||||
return
|
||||
}
|
||||
img, err := fc.capture()
|
||||
if err != nil {
|
||||
log.Debugf("capture: %v", err)
|
||||
w.closeCapturer()
|
||||
w.nextInitRetry = time.Now().Add(100 * time.Millisecond)
|
||||
req.reply <- captureReply{err: err}
|
||||
return
|
||||
}
|
||||
if snap, err := w.cursor.sample(); err != nil {
|
||||
w.c.cursorState.store(&cursorSnapshot{err: err})
|
||||
} else {
|
||||
w.c.cursorState.store(snap)
|
||||
}
|
||||
req.reply <- captureReply{img: img}
|
||||
}
|
||||
|
||||
// prepCapturer switches to the input desktop, handles desktop-change
|
||||
// teardown, and creates the underlying capturer on demand. Backoff state is
|
||||
// tracked across calls via w.nextInitRetry.
|
||||
func (w *captureWorker) prepCapturer() (frameCapturer, error) {
|
||||
if err := w.refreshDesktop(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.cap != nil {
|
||||
return w.cap, nil
|
||||
}
|
||||
if time.Now().Before(w.nextInitRetry) {
|
||||
return nil, fmt.Errorf("capturer init backing off")
|
||||
}
|
||||
fc, err := w.createCapturer()
|
||||
if err != nil {
|
||||
w.nextInitRetry = time.Now().Add(500 * time.Millisecond)
|
||||
return nil, err
|
||||
}
|
||||
w.cap = fc
|
||||
sw, sh := screenSize()
|
||||
w.c.mu.Lock()
|
||||
w.c.w, w.c.h = sw, sh
|
||||
w.c.mu.Unlock()
|
||||
log.Infof("screen capturer ready: %dx%d", sw, sh)
|
||||
return w.cap, nil
|
||||
}
|
||||
|
||||
// refreshDesktop tracks the active input desktop. When it changes (lock
|
||||
// screen, fast-user-switch) the existing capturer is dropped so the next
|
||||
// call rebuilds one against the new desktop.
|
||||
func (w *captureWorker) refreshDesktop() error {
|
||||
ok, desk := switchToInputDesktop()
|
||||
if !ok {
|
||||
w.desktopFails++
|
||||
if w.desktopFails == 1 || w.desktopFails%100 == 0 {
|
||||
log.Warnf("switchToInputDesktop failed (count=%d), no interactive desktop session?", w.desktopFails)
|
||||
}
|
||||
return fmt.Errorf("no interactive desktop")
|
||||
}
|
||||
if w.desktopFails > 0 {
|
||||
log.Infof("switchToInputDesktop recovered after %d failures, desktop=%q", w.desktopFails, desk)
|
||||
w.desktopFails = 0
|
||||
}
|
||||
if desk != w.lastDesktop {
|
||||
log.Infof("desktop changed: %q -> %q", w.lastDesktop, desk)
|
||||
w.lastDesktop = desk
|
||||
w.closeCapturer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *captureWorker) createCapturer() (frameCapturer, error) {
|
||||
dc, err := newDXGICapturer()
|
||||
if err == nil {
|
||||
log.Info("using DXGI Desktop Duplication for capture")
|
||||
return dc, nil
|
||||
}
|
||||
log.Warnf("DXGI Desktop Duplication unavailable, falling back to slower GDI BitBlt: %v", err)
|
||||
gc, err := newGDICapturer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info("using GDI BitBlt for capture")
|
||||
return gc, nil
|
||||
}
|
||||
|
||||
func (w *captureWorker) closeCapturer() {
|
||||
if w.cap != nil {
|
||||
w.cap.close()
|
||||
w.cap = nil
|
||||
}
|
||||
}
|
||||
533
client/vnc/server/capture_x11.go
Normal file
533
client/vnc/server/capture_x11.go
Normal file
@@ -0,0 +1,533 @@
|
||||
//go:build unix && !darwin && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/jezek/xgb"
|
||||
"github.com/jezek/xgb/xproto"
|
||||
)
|
||||
|
||||
const (
|
||||
// x11SocketDir is the well-known directory where X servers create
|
||||
// their abstract UNIX-domain sockets, named "X<display>". Used both
|
||||
// for auto-detecting an existing display and for placing/probing
|
||||
// sockets of virtual sessions we spawn.
|
||||
x11SocketDir = "/tmp/.X11-unix"
|
||||
|
||||
// envDisplay is the X11 display selector environment variable.
|
||||
envDisplay = "DISPLAY"
|
||||
// envXAuthority points X clients at the cookie file used to
|
||||
// authenticate against the running X server.
|
||||
envXAuthority = "XAUTHORITY"
|
||||
)
|
||||
|
||||
// X11Capturer captures the screen from an X11 display using the MIT-SHM extension.
|
||||
type X11Capturer struct {
|
||||
mu sync.Mutex
|
||||
conn *xgb.Conn
|
||||
screen *xproto.ScreenInfo
|
||||
w, h int
|
||||
shmID int
|
||||
shmAddr []byte
|
||||
shmSeg uint32
|
||||
useSHM bool
|
||||
// bufs double-buffers output images so the X11Poller's capture loop can
|
||||
// overwrite one while the session is still encoding the other. Before
|
||||
// this, a single reused buffer would race with the reader. Allocation
|
||||
// happens on first use and on geometry change.
|
||||
bufs [2]*image.RGBA
|
||||
cur int
|
||||
// cursor is the XFixes binding used to report the current sprite.
|
||||
// Allocated lazily on the first Cursor call. cursorInitErr latches
|
||||
// a permanent init failure so we stop retrying every frame.
|
||||
cursor *xfixesCursor
|
||||
cursorInitErr error
|
||||
}
|
||||
|
||||
// detectX11Display finds the active X11 display and sets DISPLAY/XAUTHORITY
|
||||
// environment variables if needed. This is required when running as a system
|
||||
// service where these vars aren't set.
|
||||
func detectX11Display() {
|
||||
if os.Getenv(envDisplay) != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Try /proc first (Linux), then ps fallback (FreeBSD and others).
|
||||
if detectX11FromProc() {
|
||||
return
|
||||
}
|
||||
if detectX11FromSockets() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// detectX11FromProc scans /proc/*/cmdline for Xorg (Linux).
|
||||
func detectX11FromProc() bool {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
cmdline, err := os.ReadFile("/proc/" + e.Name() + "/cmdline")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if display, auth := parseXorgArgs(splitCmdline(cmdline)); display != "" {
|
||||
setDisplayEnv(display, auth)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectX11FromSockets checks /tmp/.X11-unix/ for X sockets and uses ps
|
||||
// to find the auth file. Works on FreeBSD and other systems without /proc.
|
||||
func detectX11FromSockets() bool {
|
||||
entries, err := os.ReadDir(x11SocketDir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Pick the lowest numeric display rather than the lexically first
|
||||
// entry, so X10 doesn't win over X2.
|
||||
minDisplay := -1
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if len(name) < 2 || name[0] != 'X' {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(name[1:])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if minDisplay < 0 || n < minDisplay {
|
||||
minDisplay = n
|
||||
}
|
||||
}
|
||||
if minDisplay < 0 {
|
||||
return false
|
||||
}
|
||||
display := ":" + strconv.Itoa(minDisplay)
|
||||
os.Setenv(envDisplay, display)
|
||||
auth := findXorgAuthFromPS()
|
||||
if auth != "" {
|
||||
os.Setenv(envXAuthority, auth)
|
||||
log.Infof("auto-detected DISPLAY=%s (from socket) XAUTHORITY=%s (from ps)", display, auth)
|
||||
} else {
|
||||
log.Infof("auto-detected DISPLAY=%s (from socket)", display)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// findXorgAuthFromPS runs ps to find Xorg and extract its -auth argument.
|
||||
func findXorgAuthFromPS() string {
|
||||
out, err := exec.Command("ps", "auxww").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if !strings.Contains(line, "Xorg") && !strings.Contains(line, "/X ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "-auth" && i+1 < len(fields) {
|
||||
return fields[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseXorgArgs(args []string) (display, auth string) {
|
||||
if len(args) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
base := args[0]
|
||||
if !(base == "Xorg" || base == "X" || len(base) > 0 && base[len(base)-1] == 'X' ||
|
||||
strings.Contains(base, "/Xorg") || strings.Contains(base, "/X")) {
|
||||
return "", ""
|
||||
}
|
||||
for i, arg := range args[1:] {
|
||||
if len(arg) > 0 && arg[0] == ':' {
|
||||
display = arg
|
||||
}
|
||||
if arg == "-auth" && i+2 < len(args) {
|
||||
auth = args[i+2]
|
||||
}
|
||||
}
|
||||
return display, auth
|
||||
}
|
||||
|
||||
func setDisplayEnv(display, auth string) {
|
||||
os.Setenv(envDisplay, display)
|
||||
if auth != "" {
|
||||
os.Setenv(envXAuthority, auth)
|
||||
log.Infof("auto-detected DISPLAY=%s XAUTHORITY=%s", display, auth)
|
||||
return
|
||||
}
|
||||
log.Infof("auto-detected DISPLAY=%s", display)
|
||||
}
|
||||
|
||||
func splitCmdline(data []byte) []string {
|
||||
var args []string
|
||||
for _, b := range splitNull(data) {
|
||||
if len(b) > 0 {
|
||||
args = append(args, string(b))
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func splitNull(data []byte) [][]byte {
|
||||
var parts [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == 0 {
|
||||
parts = append(parts, data[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
parts = append(parts, data[start:])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// NewX11Capturer connects to the X11 display and sets up shared memory capture.
|
||||
func NewX11Capturer(display string) (*X11Capturer, error) {
|
||||
if display == "" {
|
||||
detectX11Display()
|
||||
display = os.Getenv(envDisplay)
|
||||
}
|
||||
if display == "" {
|
||||
return nil, fmt.Errorf("DISPLAY not set and no Xorg process found")
|
||||
}
|
||||
|
||||
conn, err := xgb.NewConnDisplay(display)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to X11 display %s: %w", display, err)
|
||||
}
|
||||
|
||||
setup := xproto.Setup(conn)
|
||||
if len(setup.Roots) == 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("no X11 screens")
|
||||
}
|
||||
screen := setup.Roots[0]
|
||||
|
||||
c := &X11Capturer{
|
||||
conn: conn,
|
||||
screen: &screen,
|
||||
w: int(screen.WidthInPixels),
|
||||
h: int(screen.HeightInPixels),
|
||||
}
|
||||
|
||||
if err := c.initSHM(); err != nil {
|
||||
log.Debugf("X11 SHM not available, using slow GetImage: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("X11 capturer ready: %dx%d (display=%s, shm=%v)", c.w, c.h, display, c.useSHM)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// initSHM is implemented in capture_x11_shm_linux.go (requires SysV SHM).
|
||||
// On platforms without SysV SHM (FreeBSD), a stub returns an error and
|
||||
// the capturer falls back to GetImage.
|
||||
|
||||
// Width returns the screen width.
|
||||
func (c *X11Capturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the screen height.
|
||||
func (c *X11Capturer) Height() int { return c.h }
|
||||
|
||||
// Capture returns the current screen as an RGBA image.
|
||||
func (c *X11Capturer) Capture() (*image.RGBA, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.useSHM {
|
||||
return c.captureSHM()
|
||||
}
|
||||
return c.captureGetImage()
|
||||
}
|
||||
|
||||
// CaptureInto fills the caller's destination buffer in one pass. The
|
||||
// source path (SHM or fallback GetImage) writes directly into dst.Pix
|
||||
// instead of going through the X11Capturer's internal double-buffer,
|
||||
// saving one full-frame memcpy per capture.
|
||||
func (c *X11Capturer) CaptureInto(dst *image.RGBA) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h)
|
||||
}
|
||||
if c.useSHM {
|
||||
return c.captureSHMInto(dst)
|
||||
}
|
||||
return c.captureGetImageInto(dst)
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureGetImageInto(dst *image.RGBA) error {
|
||||
cookie := xproto.GetImage(c.conn, xproto.ImageFormatZPixmap,
|
||||
xproto.Drawable(c.screen.Root),
|
||||
0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF)
|
||||
reply, err := cookie.Reply()
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetImage: %w", err)
|
||||
}
|
||||
n := c.w * c.h * 4
|
||||
if len(reply.Data) < n {
|
||||
return fmt.Errorf("GetImage returned %d bytes, expected %d", len(reply.Data), n)
|
||||
}
|
||||
swizzleBGRAtoRGBA(dst.Pix, reply.Data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureSHM is implemented in capture_x11_shm_linux.go.
|
||||
|
||||
func (c *X11Capturer) captureGetImage() (*image.RGBA, error) {
|
||||
cookie := xproto.GetImage(c.conn, xproto.ImageFormatZPixmap,
|
||||
xproto.Drawable(c.screen.Root),
|
||||
0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF)
|
||||
|
||||
reply, err := cookie.Reply()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetImage: %w", err)
|
||||
}
|
||||
|
||||
data := reply.Data
|
||||
n := c.w * c.h * 4
|
||||
if len(data) < n {
|
||||
return nil, fmt.Errorf("GetImage returned %d bytes, expected %d", len(data), n)
|
||||
}
|
||||
|
||||
img := c.nextBuffer()
|
||||
swizzleBGRAtoRGBA(img.Pix, data)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// nextBuffer returns the *image.RGBA the next capture should fill, advancing
|
||||
// the double-buffer index. Reallocates on geometry change.
|
||||
func (c *X11Capturer) nextBuffer() *image.RGBA {
|
||||
c.cur ^= 1
|
||||
b := c.bufs[c.cur]
|
||||
if b == nil || b.Rect.Dx() != c.w || b.Rect.Dy() != c.h {
|
||||
b = image.NewRGBA(image.Rect(0, 0, c.w, c.h))
|
||||
c.bufs[c.cur] = b
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Close releases X11 resources.
|
||||
func (c *X11Capturer) Close() {
|
||||
c.closeSHM()
|
||||
c.conn.Close()
|
||||
}
|
||||
|
||||
// closeSHM is implemented in capture_x11_shm_linux.go.
|
||||
|
||||
// X11Poller wraps X11Capturer with a staleness-cached on-demand Capture:
|
||||
// sessions drive captures themselves through the encoder goroutine, so we
|
||||
// don't need a background ticker. The last result is cached for a short
|
||||
// window so concurrent sessions coalesce into one capture.
|
||||
//
|
||||
// The capturer is allocated lazily on first use and released when all
|
||||
// clients disconnect, so an idle peer holds no X connection or SHM segment.
|
||||
type X11Poller struct {
|
||||
mu sync.Mutex
|
||||
|
||||
capturer *X11Capturer
|
||||
w, h int
|
||||
// closed at Close so callers can stop waiting on retry backoff.
|
||||
done chan struct{}
|
||||
|
||||
// lastFrame/lastAt implement a small cache: multiple near-simultaneous
|
||||
// Capture calls (multi-client, or input-coalesced) return the same
|
||||
// frame instead of hammering the X server.
|
||||
lastFrame *image.RGBA
|
||||
lastAt time.Time
|
||||
|
||||
// initBackoffUntil throttles capturer re-init when the X server is
|
||||
// unavailable or flapping.
|
||||
initBackoffUntil time.Time
|
||||
|
||||
clients atomic.Int32
|
||||
display string
|
||||
}
|
||||
|
||||
// initRetryBackoff gates capturer re-init attempts after a failure so we
|
||||
// don't spin on X server errors.
|
||||
const initRetryBackoff = 2 * time.Second
|
||||
|
||||
// NewX11Poller creates a lazy on-demand capturer for the given X display.
|
||||
func NewX11Poller(display string) *X11Poller {
|
||||
return &X11Poller{
|
||||
display: display,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientConnect increments the active client count. The first client triggers
|
||||
// eager capturer initialisation so that the first FBUpdateRequest doesn't
|
||||
// pay the X11 connect + SHM attach latency.
|
||||
func (p *X11Poller) ClientConnect() {
|
||||
if p.clients.Add(1) == 1 {
|
||||
p.mu.Lock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect decrements the active client count. On the last
|
||||
// disconnect we close the underlying capturer so idle peers cost nothing.
|
||||
func (p *X11Poller) ClientDisconnect() {
|
||||
if p.clients.Add(-1) == 0 {
|
||||
p.mu.Lock()
|
||||
if p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
p.lastFrame = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases all resources. Subsequent Capture calls will fail.
|
||||
func (p *X11Poller) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
select {
|
||||
case <-p.done:
|
||||
default:
|
||||
close(p.done)
|
||||
}
|
||||
if p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the screen width. Triggers lazy init if needed.
|
||||
func (p *X11Poller) Width() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.w
|
||||
}
|
||||
|
||||
// Height returns the screen height. Triggers lazy init if needed.
|
||||
func (p *X11Poller) Height() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.h
|
||||
}
|
||||
|
||||
// Cursor satisfies cursorSource by forwarding to the lazily-initialised
|
||||
// X11Capturer. Asking for the cursor on an idle poller triggers the same
|
||||
// lazy X11 connection setup as a capture would.
|
||||
func (p *X11Poller) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
return p.capturer.Cursor()
|
||||
}
|
||||
|
||||
// CursorPos satisfies cursorPositionSource by forwarding to the X11Capturer.
|
||||
func (p *X11Poller) CursorPos() (int, int, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.capturer.CursorPos()
|
||||
}
|
||||
|
||||
// Capture returns a fresh frame, serving from the short-lived cache if a
|
||||
// previous caller captured within freshWindow.
|
||||
func (p *X11Poller) Capture() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow {
|
||||
return p.lastFrame, nil
|
||||
}
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := p.capturer.Capture()
|
||||
if err != nil {
|
||||
// Drop the capturer so the next call re-inits; the X connection may
|
||||
// have died (e.g. Xorg restart).
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
p.initBackoffUntil = time.Now().Add(initRetryBackoff)
|
||||
return nil, fmt.Errorf("x11 capture: %w", err)
|
||||
}
|
||||
p.lastFrame = img
|
||||
p.lastAt = time.Now()
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// CaptureInto fills dst directly via the underlying capturer, bypassing
|
||||
// the freshness cache. The session's prevFrame/curFrame swap means each
|
||||
// session needs its own buffer anyway, so caching wouldn't help.
|
||||
func (p *X11Poller) CaptureInto(dst *image.RGBA) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.capturer.CaptureInto(dst); err != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
p.initBackoffUntil = time.Now().Add(initRetryBackoff)
|
||||
return fmt.Errorf("x11 capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureCapturerLocked initialises the underlying X11Capturer if not
|
||||
// already open. Caller must hold p.mu.
|
||||
func (p *X11Poller) ensureCapturerLocked() error {
|
||||
if p.capturer != nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
return fmt.Errorf("x11 capturer closed")
|
||||
default:
|
||||
}
|
||||
if time.Now().Before(p.initBackoffUntil) {
|
||||
return fmt.Errorf("x11 capturer unavailable (retry scheduled)")
|
||||
}
|
||||
c, err := NewX11Capturer(p.display)
|
||||
if err != nil {
|
||||
p.initBackoffUntil = time.Now().Add(initRetryBackoff)
|
||||
log.Debugf("X11 capturer: %v", err)
|
||||
return err
|
||||
}
|
||||
p.capturer = c
|
||||
p.w, p.h = c.Width(), c.Height()
|
||||
return nil
|
||||
}
|
||||
96
client/vnc/server/capture_x11_shm_linux.go
Normal file
96
client/vnc/server/capture_x11_shm_linux.go
Normal file
@@ -0,0 +1,96 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/jezek/xgb/shm"
|
||||
"github.com/jezek/xgb/xproto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (c *X11Capturer) initSHM() error {
|
||||
if err := shm.Init(c.conn); err != nil {
|
||||
return fmt.Errorf("init SHM extension: %w", err)
|
||||
}
|
||||
|
||||
size := c.w * c.h * 4
|
||||
id, err := unix.SysvShmGet(unix.IPC_PRIVATE, size, unix.IPC_CREAT|0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shmget: %w", err)
|
||||
}
|
||||
|
||||
addr, err := unix.SysvShmAttach(id, 0, 0)
|
||||
if err != nil {
|
||||
if _, ctlErr := unix.SysvShmCtl(id, unix.IPC_RMID, nil); ctlErr != nil {
|
||||
log.Debugf("shmctl IPC_RMID on attach failure: %v", ctlErr)
|
||||
}
|
||||
return fmt.Errorf("shmat: %w", err)
|
||||
}
|
||||
|
||||
if _, err := unix.SysvShmCtl(id, unix.IPC_RMID, nil); err != nil {
|
||||
log.Debugf("shmctl IPC_RMID: %v", err)
|
||||
}
|
||||
|
||||
seg, err := shm.NewSegId(c.conn)
|
||||
if err != nil {
|
||||
if detachErr := unix.SysvShmDetach(addr); detachErr != nil {
|
||||
log.Debugf("shmdt on new-seg failure: %v", detachErr)
|
||||
}
|
||||
return fmt.Errorf("new SHM seg: %w", err)
|
||||
}
|
||||
|
||||
if err := shm.AttachChecked(c.conn, seg, uint32(id), false).Check(); err != nil {
|
||||
if detachErr := unix.SysvShmDetach(addr); detachErr != nil {
|
||||
log.Debugf("shmdt on attach-checked failure: %v", detachErr)
|
||||
}
|
||||
return fmt.Errorf("SHM attach to X: %w", err)
|
||||
}
|
||||
|
||||
c.shmID = id
|
||||
c.shmAddr = addr
|
||||
c.shmSeg = uint32(seg)
|
||||
c.useSHM = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureSHM() (*image.RGBA, error) {
|
||||
if err := c.fillSHM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := c.nextBuffer()
|
||||
swizzleBGRAtoRGBA(img.Pix, c.shmAddr[:c.w*c.h*4])
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// captureSHMInto runs a single SHM GetImage and swizzles directly into the
|
||||
// caller-provided destination, skipping the internal double-buffer.
|
||||
func (c *X11Capturer) captureSHMInto(dst *image.RGBA) error {
|
||||
if err := c.fillSHM(); err != nil {
|
||||
return err
|
||||
}
|
||||
swizzleBGRAtoRGBA(dst.Pix, c.shmAddr[:c.w*c.h*4])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *X11Capturer) fillSHM() error {
|
||||
cookie := shm.GetImage(c.conn, xproto.Drawable(c.screen.Root),
|
||||
0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF,
|
||||
xproto.ImageFormatZPixmap, shm.Seg(c.shmSeg), 0)
|
||||
if _, err := cookie.Reply(); err != nil {
|
||||
return fmt.Errorf("SHM GetImage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *X11Capturer) closeSHM() {
|
||||
if c.useSHM {
|
||||
shm.Detach(c.conn, shm.Seg(c.shmSeg))
|
||||
if err := unix.SysvShmDetach(c.shmAddr); err != nil {
|
||||
log.Debugf("shmdt on close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
client/vnc/server/capture_x11_shm_stub.go
Normal file
24
client/vnc/server/capture_x11_shm_stub.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//go:build freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
)
|
||||
|
||||
func (c *X11Capturer) initSHM() error {
|
||||
return fmt.Errorf("SysV SHM not available on this platform")
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureSHM() (*image.RGBA, error) {
|
||||
return nil, fmt.Errorf("SHM capture not available on this platform")
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureSHMInto(_ *image.RGBA) error {
|
||||
return fmt.Errorf("SHM capture not available on this platform")
|
||||
}
|
||||
|
||||
func (c *X11Capturer) closeSHM() {
|
||||
// no SHM to close on this platform
|
||||
}
|
||||
77
client/vnc/server/coalesce_test.go
Normal file
77
client/vnc/server/coalesce_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoalesceRects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in [][4]int
|
||||
want [][4]int
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
in: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single",
|
||||
in: [][4]int{{0, 0, 64, 64}},
|
||||
want: [][4]int{{0, 0, 64, 64}},
|
||||
},
|
||||
{
|
||||
name: "horizontal_run",
|
||||
in: [][4]int{{0, 0, 64, 64}, {64, 0, 64, 64}, {128, 0, 64, 64}},
|
||||
want: [][4]int{{0, 0, 192, 64}},
|
||||
},
|
||||
{
|
||||
name: "vertical_run",
|
||||
in: [][4]int{{0, 0, 64, 64}, {0, 64, 64, 64}, {0, 128, 64, 64}},
|
||||
want: [][4]int{{0, 0, 64, 192}},
|
||||
},
|
||||
{
|
||||
name: "block_2x2",
|
||||
in: [][4]int{
|
||||
{0, 0, 64, 64}, {64, 0, 64, 64},
|
||||
{0, 64, 64, 64}, {64, 64, 64, 64},
|
||||
},
|
||||
want: [][4]int{{0, 0, 128, 128}},
|
||||
},
|
||||
{
|
||||
name: "no_merge_gap",
|
||||
in: [][4]int{{0, 0, 64, 64}, {192, 0, 64, 64}},
|
||||
want: [][4]int{{0, 0, 64, 64}, {192, 0, 64, 64}},
|
||||
},
|
||||
{
|
||||
name: "two_disjoint_columns",
|
||||
in: [][4]int{
|
||||
{0, 0, 64, 64}, {192, 0, 64, 64},
|
||||
{0, 64, 64, 64}, {192, 64, 64, 64},
|
||||
},
|
||||
want: [][4]int{{0, 0, 64, 128}, {192, 0, 64, 128}},
|
||||
},
|
||||
{
|
||||
name: "misaligned_widths_no_vertical_merge",
|
||||
in: [][4]int{
|
||||
{0, 0, 128, 64},
|
||||
{0, 64, 64, 64},
|
||||
},
|
||||
want: [][4]int{{0, 0, 128, 64}, {0, 64, 64, 64}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := coalesceRects(tc.in)
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
191
client/vnc/server/copyrect.go
Normal file
191
client/vnc/server/copyrect.go
Normal file
@@ -0,0 +1,191 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"hash/maphash"
|
||||
"image"
|
||||
)
|
||||
|
||||
// copyRectDetector finds tiles in the current frame that match the content
|
||||
// of some tile-aligned region of the previous frame, so we can emit them as
|
||||
// CopyRect rectangles (16 wire bytes) instead of re-encoding the pixels.
|
||||
//
|
||||
// The detector keeps two structures:
|
||||
// - tileHash, a flat slice of one hash per tile-aligned position, used as
|
||||
// the source of truth for the previous frame's tile content.
|
||||
// - prevTiles, a hash → position lookup used during findTileMatch.
|
||||
//
|
||||
// updateDirty rehashes only the tiles that changed this frame, so the
|
||||
// steady-state cost is proportional to the dirty set, not the framebuffer.
|
||||
// A full rebuild from scratch is only done on the first frame or when the
|
||||
// detector has not yet been initialized for the current resolution.
|
||||
//
|
||||
// Limitations:
|
||||
// - Only tile-aligned source positions are considered. Sub-tile-aligned
|
||||
// moves (e.g. window dragged by 7 pixels) are not detected. This still
|
||||
// covers the common case of vertical/horizontal scrolling, which always
|
||||
// produces tile-aligned matches at the tile granularity.
|
||||
// - 64-bit maphash collisions are assumed not to happen. The probability
|
||||
// for any single frame's hash universe is ~2^-32 * tileCount² which is
|
||||
// vanishingly small at typical resolutions; if we ever observe one we
|
||||
// can fall back to a full memcmp verification.
|
||||
type copyRectDetector struct {
|
||||
seed maphash.Seed
|
||||
tileSize int
|
||||
w, h int
|
||||
cols, rows int
|
||||
// tileHash[ty*cols + tx] is the current hash of the tile at (tx, ty)
|
||||
// in the previous frame. Lookup uses this to detect stale prevTiles
|
||||
// entries: incremental updates may leave hash→pos entries pointing
|
||||
// at a tile whose content has since changed.
|
||||
tileHash []uint64
|
||||
// prevTiles maps a tile hash to a (x, y) origin in the previous frame.
|
||||
prevTiles map[uint64][2]int
|
||||
// hash is reused across hash computations to keep the per-tile lookup
|
||||
// path allocation-free.
|
||||
hash maphash.Hash
|
||||
}
|
||||
|
||||
func newCopyRectDetector(tileSize int) *copyRectDetector {
|
||||
d := ©RectDetector{
|
||||
seed: maphash.MakeSeed(),
|
||||
tileSize: tileSize,
|
||||
prevTiles: make(map[uint64][2]int),
|
||||
}
|
||||
d.hash.SetSeed(d.seed)
|
||||
return d
|
||||
}
|
||||
|
||||
// resize ensures the per-tile tables match the given framebuffer size.
|
||||
// Called from rebuild before each full hash sweep.
|
||||
func (d *copyRectDetector) resize(w, h int) {
|
||||
if d.w == w && d.h == h && d.tileHash != nil {
|
||||
return
|
||||
}
|
||||
d.w, d.h = w, h
|
||||
d.cols = w / d.tileSize
|
||||
d.rows = h / d.tileSize
|
||||
d.tileHash = make([]uint64, d.cols*d.rows)
|
||||
}
|
||||
|
||||
// hashTile computes the 64-bit maphash of one tile-aligned tile of frame.
|
||||
func (d *copyRectDetector) hashTile(frame *image.RGBA, tx, ty int) uint64 {
|
||||
d.hash.Reset()
|
||||
ts := d.tileSize
|
||||
stride := frame.Stride
|
||||
rowBytes := ts * 4
|
||||
base := ty*stride + tx*4
|
||||
for row := 0; row < ts; row++ {
|
||||
off := base + row*stride
|
||||
_, _ = d.hash.Write(frame.Pix[off : off+rowBytes])
|
||||
}
|
||||
return d.hash.Sum64()
|
||||
}
|
||||
|
||||
// rebuild discards everything and rehashes the whole frame. O(w*h). Use
|
||||
// for the first frame or after the detector has been resized. Steady-state
|
||||
// updates should go through updateDirty instead.
|
||||
func (d *copyRectDetector) rebuild(frame *image.RGBA, w, h int) {
|
||||
d.resize(w, h)
|
||||
if d.prevTiles == nil {
|
||||
d.prevTiles = make(map[uint64][2]int)
|
||||
} else {
|
||||
clear(d.prevTiles)
|
||||
}
|
||||
ts := d.tileSize
|
||||
for ty := 0; ty+ts <= h; ty += ts {
|
||||
for tx := 0; tx+ts <= w; tx += ts {
|
||||
sum := d.hashTile(frame, tx, ty)
|
||||
d.tileHash[(ty/ts)*d.cols+(tx/ts)] = sum
|
||||
if _, exists := d.prevTiles[sum]; !exists {
|
||||
d.prevTiles[sum] = [2]int{tx, ty}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateDirty rehashes only the tiles named in dirty (each entry is
|
||||
// [x, y, w, h] with w and h equal to tileSize). O(len(dirty)) work, which
|
||||
// in the common case is a tiny fraction of the whole framebuffer.
|
||||
//
|
||||
// The prevTiles map is replaced on collision rather than first-wins so a
|
||||
// newly-hashed tile claims the slot. Old, stale entries pointing at tiles
|
||||
// that no longer carry that hash are filtered at lookup time via tileHash.
|
||||
func (d *copyRectDetector) updateDirty(frame *image.RGBA, w, h int, dirty [][4]int) {
|
||||
if d.w != w || d.h != h || d.tileHash == nil {
|
||||
d.rebuild(frame, w, h)
|
||||
return
|
||||
}
|
||||
ts := d.tileSize
|
||||
for _, r := range dirty {
|
||||
if r[2] != ts || r[3] != ts {
|
||||
continue
|
||||
}
|
||||
tx, ty := r[0], r[1]
|
||||
if tx+ts > w || ty+ts > h {
|
||||
continue
|
||||
}
|
||||
sum := d.hashTile(frame, tx, ty)
|
||||
d.tileHash[(ty/ts)*d.cols+(tx/ts)] = sum
|
||||
// Latest-wins on collision: ensures the most recent owner of this
|
||||
// hash is the one we'll return on lookup. The previous owner's
|
||||
// entry, if any, gets shadowed; if its content has changed it's
|
||||
// stale anyway and findTileMatch's verification will skip it.
|
||||
d.prevTiles[sum] = [2]int{tx, ty}
|
||||
}
|
||||
}
|
||||
|
||||
// findTileMatch hashes the current-frame tile at (dstX, dstY) and looks up
|
||||
// its hash in the previous-frame map. Returns (srcX, srcY, true) when a
|
||||
// matching tile-aligned tile exists at a different position whose stored
|
||||
// hash still equals the requested hash (so the result is not stale).
|
||||
func (d *copyRectDetector) findTileMatch(cur *image.RGBA, dstX, dstY int) (int, int, bool) {
|
||||
if len(d.prevTiles) == 0 || d.tileHash == nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
ts := d.tileSize
|
||||
if dstX+ts > cur.Rect.Dx() || dstY+ts > cur.Rect.Dy() {
|
||||
return 0, 0, false
|
||||
}
|
||||
sum := d.hashTile(cur, dstX, dstY)
|
||||
pos, ok := d.prevTiles[sum]
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
if pos[0] == dstX && pos[1] == dstY {
|
||||
return 0, 0, false
|
||||
}
|
||||
// Reject stale entries: the position the map points at must still
|
||||
// carry the same hash according to our per-tile array.
|
||||
if d.tileHash[(pos[1]/ts)*d.cols+(pos[0]/ts)] != sum {
|
||||
return 0, 0, false
|
||||
}
|
||||
return pos[0], pos[1], true
|
||||
}
|
||||
|
||||
// extractCopyRectTiles examines the diff-produced (per-tile) dirty list and
|
||||
// pulls out any tiles whose current-frame content matches a prev-frame tile
|
||||
// at a different position. Returns the CopyRect candidates and the residual
|
||||
// dirty tiles that still need pixel encoding.
|
||||
type copyRectMove struct {
|
||||
srcX, srcY int
|
||||
dstX, dstY int
|
||||
}
|
||||
|
||||
func (d *copyRectDetector) extractCopyRectTiles(cur *image.RGBA, dirtyTiles [][4]int) (moves []copyRectMove, remaining [][4]int) {
|
||||
ts := d.tileSize
|
||||
remaining = dirtyTiles[:0:cap(dirtyTiles)]
|
||||
for _, r := range dirtyTiles {
|
||||
if r[2] == ts && r[3] == ts {
|
||||
if sx, sy, ok := d.findTileMatch(cur, r[0], r[1]); ok {
|
||||
moves = append(moves, copyRectMove{
|
||||
srcX: sx, srcY: sy, dstX: r[0], dstY: r[1],
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
remaining = append(remaining, r)
|
||||
}
|
||||
return moves, remaining
|
||||
}
|
||||
162
client/vnc/server/copyrect_test.go
Normal file
162
client/vnc/server/copyrect_test.go
Normal file
@@ -0,0 +1,162 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fillTile paints a tileSize×tileSize block of img at (x,y) with the colour
|
||||
// derived from (r,g,b) so the test can construct distinct-content tiles.
|
||||
func fillTile(img *image.RGBA, x, y, ts int, r, g, b byte) {
|
||||
for row := 0; row < ts; row++ {
|
||||
off := (y+row)*img.Stride + x*4
|
||||
for col := 0; col < ts; col++ {
|
||||
img.Pix[off+col*4+0] = r
|
||||
img.Pix[off+col*4+1] = g
|
||||
img.Pix[off+col*4+2] = b
|
||||
img.Pix[off+col*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// copyTile copies a tileSize×tileSize block from src(sx,sy) to dst(dx,dy).
|
||||
func copyTile(dst, src *image.RGBA, sx, sy, dx, dy, ts int) {
|
||||
for row := 0; row < ts; row++ {
|
||||
srcOff := (sy+row)*src.Stride + sx*4
|
||||
dstOff := (dy+row)*dst.Stride + dx*4
|
||||
copy(dst.Pix[dstOff:dstOff+ts*4], src.Pix[srcOff:srcOff+ts*4])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyRectDetector_DetectsVerticalScroll(t *testing.T) {
|
||||
const w, h = 256, 192 // 4×3 tiles at 64px
|
||||
const ts = 64
|
||||
|
||||
prev := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
cur := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
// prev: 12 tiles each with a unique colour.
|
||||
for ty := 0; ty < 3; ty++ {
|
||||
for tx := 0; tx < 4; tx++ {
|
||||
fillTile(prev, tx*ts, ty*ts, ts, byte(tx*40), byte(ty*60), 0x80)
|
||||
}
|
||||
}
|
||||
// cur: simulate a single-tile-row scroll upward, every tile copied from
|
||||
// the row below in prev, top row is new content.
|
||||
for ty := 0; ty < 2; ty++ {
|
||||
for tx := 0; tx < 4; tx++ {
|
||||
copyTile(cur, prev, tx*ts, (ty+1)*ts, tx*ts, ty*ts, ts)
|
||||
}
|
||||
}
|
||||
// Bottom row of cur: new colour, not a match.
|
||||
for tx := 0; tx < 4; tx++ {
|
||||
fillTile(cur, tx*ts, 2*ts, ts, 0xff, 0xff, 0xff)
|
||||
}
|
||||
|
||||
d := newCopyRectDetector(ts)
|
||||
d.rebuild(prev, w, h)
|
||||
|
||||
tiles := diffTiles(prev, cur, w, h, ts)
|
||||
moves, remaining := d.extractCopyRectTiles(cur, tiles)
|
||||
|
||||
// Expect 8 CopyRect moves (top two rows) and 4 residual tiles (bottom row).
|
||||
if len(moves) != 8 {
|
||||
t.Fatalf("moves: want 8, got %d", len(moves))
|
||||
}
|
||||
if len(remaining) != 4 {
|
||||
t.Fatalf("remaining: want 4, got %d", len(remaining))
|
||||
}
|
||||
// Spot-check one move: cur (0, 0) should map to prev (0, 64).
|
||||
var found bool
|
||||
for _, m := range moves {
|
||||
if m.dstX == 0 && m.dstY == 0 {
|
||||
if m.srcX != 0 || m.srcY != ts {
|
||||
t.Fatalf("move at (0,0): src=(%d,%d), want (0,%d)", m.srcX, m.srcY, ts)
|
||||
}
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("no move for dst (0,0)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyRectDetector_RejectsSelfMatch(t *testing.T) {
|
||||
const w, h = 128, 128
|
||||
const ts = 64
|
||||
|
||||
prev := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
cur := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
|
||||
// prev: 4 tiles, all unique
|
||||
fillTile(prev, 0, 0, ts, 0x10, 0x20, 0x30)
|
||||
fillTile(prev, ts, 0, ts, 0x40, 0x50, 0x60)
|
||||
fillTile(prev, 0, ts, ts, 0x70, 0x80, 0x90)
|
||||
fillTile(prev, ts, ts, ts, 0xa0, 0xb0, 0xc0)
|
||||
|
||||
// cur: tile (0,0) unchanged, others changed but content same as prev's (0,0).
|
||||
fillTile(cur, 0, 0, ts, 0x10, 0x20, 0x30) // self-match
|
||||
fillTile(cur, ts, 0, ts, 0xff, 0xff, 0xff)
|
||||
fillTile(cur, 0, ts, ts, 0xff, 0xff, 0xff)
|
||||
fillTile(cur, ts, ts, ts, 0xff, 0xff, 0xff)
|
||||
|
||||
d := newCopyRectDetector(ts)
|
||||
d.rebuild(prev, w, h)
|
||||
|
||||
// Tile (0,0) is not in the dirty list (it's unchanged) so it should not
|
||||
// produce a move even though its hash matches prev (0,0).
|
||||
tiles := diffTiles(prev, cur, w, h, ts)
|
||||
moves, _ := d.extractCopyRectTiles(cur, tiles)
|
||||
for _, m := range moves {
|
||||
if m.dstX == 0 && m.dstY == 0 {
|
||||
t.Fatalf("unexpected move at (0,0)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyRectDetector_PassThroughWhenNoMatch(t *testing.T) {
|
||||
const w, h = 64, 64
|
||||
const ts = 64
|
||||
|
||||
prev := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
cur := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
fillTile(prev, 0, 0, ts, 0x11, 0x22, 0x33)
|
||||
fillTile(cur, 0, 0, ts, 0xaa, 0xbb, 0xcc) // wholly different
|
||||
|
||||
d := newCopyRectDetector(ts)
|
||||
d.rebuild(prev, w, h)
|
||||
tiles := diffTiles(prev, cur, w, h, ts)
|
||||
moves, remaining := d.extractCopyRectTiles(cur, tiles)
|
||||
|
||||
if len(moves) != 0 {
|
||||
t.Fatalf("expected 0 moves, got %d", len(moves))
|
||||
}
|
||||
if len(remaining) != 1 {
|
||||
t.Fatalf("expected 1 residual tile, got %d", len(remaining))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeCopyRectBody_Layout(t *testing.T) {
|
||||
got := encodeCopyRectBody(100, 200, 300, 400, 64, 48)
|
||||
if len(got) != 16 {
|
||||
t.Fatalf("CopyRect body length: want 16, got %d", len(got))
|
||||
}
|
||||
// Dest position
|
||||
if got[0] != 0x01 || got[1] != 0x2c || got[2] != 0x01 || got[3] != 0x90 {
|
||||
t.Fatalf("bad dest bytes: % x", got[0:4])
|
||||
}
|
||||
// Width, height
|
||||
if got[4] != 0 || got[5] != 64 || got[6] != 0 || got[7] != 48 {
|
||||
t.Fatalf("bad size bytes: % x", got[4:8])
|
||||
}
|
||||
// Encoding = 1
|
||||
if got[11] != 0x01 {
|
||||
t.Fatalf("bad encoding byte: 0x%02x", got[11])
|
||||
}
|
||||
// Source position
|
||||
if got[12] != 0 || got[13] != 100 || got[14] != 0 || got[15] != 200 {
|
||||
t.Fatalf("bad src bytes: % x", got[12:16])
|
||||
}
|
||||
}
|
||||
194
client/vnc/server/cursor_darwin.go
Normal file
194
client/vnc/server/cursor_darwin.go
Normal file
@@ -0,0 +1,194 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"image"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var (
|
||||
darwinCursorOnce sync.Once
|
||||
cgsCreateCursor func() uintptr
|
||||
darwinCursorErr error
|
||||
)
|
||||
|
||||
// initDarwinCursor binds a private symbol that returns the current
|
||||
// system cursor image. The classic CGSCreateCurrentCursorImage moved
|
||||
// from CoreGraphics to SkyLight around macOS 13 and is gone entirely
|
||||
// in Sequoia; we probe both frameworks for any of the historical
|
||||
// names so this keeps working on whichever release the binding still
|
||||
// exists. Without a hit the remote-cursor compositing path becomes a
|
||||
// no-op and we log the candidates we tried.
|
||||
func initDarwinCursor() {
|
||||
darwinCursorOnce.Do(func() {
|
||||
libs := []string{
|
||||
"/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight",
|
||||
"/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics",
|
||||
}
|
||||
names := []string{
|
||||
"CGSCreateCurrentCursorImage",
|
||||
"CGSCopyCurrentCursorImage",
|
||||
"CGSCurrentCursorImage",
|
||||
"CGSHardwareCursorActiveImage",
|
||||
}
|
||||
var tried []string
|
||||
for _, path := range libs {
|
||||
h, err := purego.Dlopen(path, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
tried = append(tried, fmt.Sprintf("dlopen %s: %v", path, err))
|
||||
continue
|
||||
}
|
||||
for _, name := range names {
|
||||
sym, err := purego.Dlsym(h, name)
|
||||
if err != nil {
|
||||
tried = append(tried, fmt.Sprintf("%s!%s missing", path, name))
|
||||
continue
|
||||
}
|
||||
purego.RegisterFunc(&cgsCreateCursor, sym)
|
||||
log.Infof("macOS cursor: bound %s from %s", name, path)
|
||||
return
|
||||
}
|
||||
}
|
||||
darwinCursorErr = fmt.Errorf("no cursor image symbol available; tried: %v", tried)
|
||||
})
|
||||
}
|
||||
|
||||
// cgCursor holds the cached macOS cursor sprite and bumps a serial when
|
||||
// the bytes change. Hotspot is left at (0, 0): the public Cocoa hot-spot
|
||||
// query lives on NSCursor which is process-local and not reachable from
|
||||
// our purego-based bindings; the visual cost is a small misalignment for
|
||||
// non-arrow cursors (I-beam, crosshair, etc.).
|
||||
type cgCursor struct {
|
||||
mu sync.Mutex
|
||||
hashSeed maphash.Seed
|
||||
lastSum uint64
|
||||
cached *image.RGBA
|
||||
serial uint64
|
||||
}
|
||||
|
||||
func newCGCursor() *cgCursor {
|
||||
initDarwinCursor()
|
||||
return &cgCursor{hashSeed: maphash.MakeSeed()}
|
||||
}
|
||||
|
||||
// Cursor returns the current cursor sprite as RGBA. Errors that come from
|
||||
// missing private symbols are sticky; transient empty-image responses are
|
||||
// reported as such so the encoder skips this cycle.
|
||||
func (c *cgCursor) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if darwinCursorErr != nil {
|
||||
return nil, 0, 0, 0, darwinCursorErr
|
||||
}
|
||||
if cgsCreateCursor == nil {
|
||||
return nil, 0, 0, 0, fmt.Errorf("CGSCreateCurrentCursorImage unavailable")
|
||||
}
|
||||
cgImage := cgsCreateCursor()
|
||||
if cgImage == 0 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("no cursor image available")
|
||||
}
|
||||
defer cgImageRelease(cgImage)
|
||||
|
||||
w := int(cgImageGetWidth(cgImage))
|
||||
h := int(cgImageGetHeight(cgImage))
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent")
|
||||
}
|
||||
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
||||
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
||||
if bpp != 32 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("unsupported cursor bpp: %d", bpp)
|
||||
}
|
||||
provider := cgImageGetDataProvider(cgImage)
|
||||
if provider == 0 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("cursor data provider missing")
|
||||
}
|
||||
cfData := cgDataProviderCopyData(provider)
|
||||
if cfData == 0 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("cursor data copy failed")
|
||||
}
|
||||
defer cfRelease(cfData)
|
||||
dataLen := int(cfDataGetLength(cfData))
|
||||
dataPtr := cfDataGetBytePtr(cfData)
|
||||
if dataPtr == 0 || dataLen == 0 {
|
||||
return nil, 0, 0, 0, fmt.Errorf("cursor data empty")
|
||||
}
|
||||
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
||||
|
||||
sum := maphash.Bytes(c.hashSeed, src)
|
||||
if c.cached != nil && sum == c.lastSum {
|
||||
return c.cached, 0, 0, c.serial, nil
|
||||
}
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := 0; y < h; y++ {
|
||||
srcOff := y * bytesPerRow
|
||||
dstOff := y * w * 4
|
||||
for x := 0; x < w; x++ {
|
||||
si := srcOff + x*4
|
||||
di := dstOff + x*4
|
||||
img.Pix[di+0] = src[si+2]
|
||||
img.Pix[di+1] = src[si+1]
|
||||
img.Pix[di+2] = src[si+0]
|
||||
img.Pix[di+3] = src[si+3]
|
||||
}
|
||||
}
|
||||
|
||||
c.lastSum = sum
|
||||
c.cached = img
|
||||
c.serial++
|
||||
return img, 0, 0, c.serial, nil
|
||||
}
|
||||
|
||||
// Cursor on CGCapturer satisfies cursorSource. The cgCursor wrapper is
|
||||
// allocated lazily so a build that never asks for the cursor pays no cost.
|
||||
func (c *CGCapturer) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
c.cursorOnce.Do(func() {
|
||||
c.cursor = newCGCursor()
|
||||
})
|
||||
return c.cursor.Cursor()
|
||||
}
|
||||
|
||||
// CursorPos returns the current global mouse location via CGEventCreate /
|
||||
// CGEventGetLocation. Coordinates are screen pixels in the main display.
|
||||
func (c *CGCapturer) CursorPos() (int, int, error) {
|
||||
if cgEventCreate == nil || cgEventGetLocation == nil {
|
||||
return 0, 0, fmt.Errorf("CGEvent location APIs unavailable")
|
||||
}
|
||||
ev := cgEventCreate(0)
|
||||
if ev == 0 {
|
||||
return 0, 0, fmt.Errorf("CGEventCreate returned nil")
|
||||
}
|
||||
defer cfRelease(ev)
|
||||
pt := cgEventGetLocation(ev)
|
||||
return int(pt.X), int(pt.Y), nil
|
||||
}
|
||||
|
||||
// Cursor on MacPoller forwards to the lazy CGCapturer. ensureCapturerLocked
|
||||
// returns an error when Screen Recording permission has not been granted;
|
||||
// in that case there is no usable cursor source either.
|
||||
func (p *MacPoller) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, 0, 0, 0, err
|
||||
}
|
||||
return p.capturer.Cursor()
|
||||
}
|
||||
|
||||
// CursorPos forwards to the lazy CGCapturer.
|
||||
func (p *MacPoller) CursorPos() (int, int, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return p.capturer.CursorPos()
|
||||
}
|
||||
407
client/vnc/server/cursor_windows.go
Normal file
407
client/vnc/server/cursor_windows.go
Normal file
@@ -0,0 +1,407 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
procGetCursorInfo = user32.NewProc("GetCursorInfo")
|
||||
procGetIconInfo = user32.NewProc("GetIconInfo")
|
||||
procGetObjectW = gdi32.NewProc("GetObjectW")
|
||||
procGetDIBits = gdi32.NewProc("GetDIBits")
|
||||
)
|
||||
|
||||
const (
|
||||
cursorShowing = 0x00000001
|
||||
diRgbColors = 0
|
||||
biRgb = 0
|
||||
dibSectionBytes = 40 // sizeof(BITMAPINFOHEADER)
|
||||
)
|
||||
|
||||
// hiddenHandle is a sentinel stored in cursorSampler.lastHandle while
|
||||
// Windows reports the cursor as hidden. It is not a valid HCURSOR value;
|
||||
// real handles never collide with this constant.
|
||||
const hiddenHandle = windows.Handle(^uintptr(0))
|
||||
|
||||
// transparentCursorImage returns a 1x1 fully transparent sprite. The
|
||||
// client renders this as "no cursor"; emitting it explicitly lets us
|
||||
// recover when an app un-hides the cursor a moment later.
|
||||
func transparentCursorImage() *image.RGBA {
|
||||
return image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
}
|
||||
|
||||
type winPoint struct {
|
||||
X, Y int32
|
||||
}
|
||||
|
||||
type winCursorInfo struct {
|
||||
Size uint32
|
||||
Flags uint32
|
||||
Cursor windows.Handle
|
||||
PtPos winPoint
|
||||
}
|
||||
|
||||
type winIconInfo struct {
|
||||
FIcon int32
|
||||
XHotspot uint32
|
||||
YHotspot uint32
|
||||
HbmMask windows.Handle
|
||||
HbmColor windows.Handle
|
||||
}
|
||||
|
||||
type winBitmap struct {
|
||||
BmType int32
|
||||
BmWidth int32
|
||||
BmHeight int32
|
||||
BmWidthBytes int32
|
||||
BmPlanes uint16
|
||||
BmBitsPixel uint16
|
||||
BmBits uintptr
|
||||
}
|
||||
|
||||
type winBitmapInfoHeader struct {
|
||||
BiSize uint32
|
||||
BiWidth int32
|
||||
BiHeight int32
|
||||
BiPlanes uint16
|
||||
BiBitCount uint16
|
||||
BiCompression uint32
|
||||
BiSizeImage uint32
|
||||
BiXPelsPerMeter int32
|
||||
BiYPelsPerMeter int32
|
||||
BiClrUsed uint32
|
||||
BiClrImportant uint32
|
||||
}
|
||||
|
||||
// cursorSnapshot is the captured cursor state shared between the worker
|
||||
// (which polls the OS) and the session encoder (which reads it).
|
||||
type cursorSnapshot struct {
|
||||
img *image.RGBA
|
||||
hotX int
|
||||
hotY int
|
||||
posX int
|
||||
posY int
|
||||
hasPos bool
|
||||
serial uint64
|
||||
err error
|
||||
}
|
||||
|
||||
// cursorSampler captures the foreground process's cursor sprite via Win32
|
||||
// APIs. It must be called from a goroutine attached to the same window
|
||||
// station and desktop as the user session (the capture worker does this
|
||||
// via switchToInputDesktop). lastHandle dedupes per-shape work so we only
|
||||
// touch GDI when Windows hands us a new cursor.
|
||||
type cursorSampler struct {
|
||||
lastHandle windows.Handle
|
||||
serial uint64
|
||||
snapshot *cursorSnapshot
|
||||
}
|
||||
|
||||
// sample queries the current cursor and decodes a new sprite when Windows
|
||||
// reports a different HCURSOR than last time. Returns the current snapshot
|
||||
// regardless of whether anything changed; callers diff by serial.
|
||||
func (s *cursorSampler) sample() (*cursorSnapshot, error) {
|
||||
var ci winCursorInfo
|
||||
ci.Size = uint32(unsafe.Sizeof(ci))
|
||||
r, _, err := procGetCursorInfo.Call(uintptr(unsafe.Pointer(&ci)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("GetCursorInfo: %w", err)
|
||||
}
|
||||
if ci.Flags&cursorShowing == 0 || ci.Cursor == 0 {
|
||||
// Cursor temporarily hidden by an app (text fields toggle it on
|
||||
// focus). Emit a 1x1 transparent sprite so the client renders no
|
||||
// cursor and stay armed for the next handle change rather than
|
||||
// treating this as a hard failure that would latch us off for
|
||||
// the session.
|
||||
if s.lastHandle == hiddenHandle {
|
||||
s.snapshot.posX = int(ci.PtPos.X)
|
||||
s.snapshot.posY = int(ci.PtPos.Y)
|
||||
s.snapshot.hasPos = true
|
||||
return s.snapshot, nil
|
||||
}
|
||||
s.lastHandle = hiddenHandle
|
||||
s.serial++
|
||||
s.snapshot = &cursorSnapshot{
|
||||
img: transparentCursorImage(),
|
||||
posX: int(ci.PtPos.X),
|
||||
posY: int(ci.PtPos.Y),
|
||||
hasPos: true,
|
||||
serial: s.serial,
|
||||
}
|
||||
return s.snapshot, nil
|
||||
}
|
||||
if ci.Cursor == s.lastHandle && s.snapshot != nil {
|
||||
s.snapshot.posX = int(ci.PtPos.X)
|
||||
s.snapshot.posY = int(ci.PtPos.Y)
|
||||
s.snapshot.hasPos = true
|
||||
return s.snapshot, nil
|
||||
}
|
||||
img, hotX, hotY, err := decodeCursor(ci.Cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.lastHandle = ci.Cursor
|
||||
s.serial++
|
||||
s.snapshot = &cursorSnapshot{
|
||||
img: img,
|
||||
hotX: hotX,
|
||||
hotY: hotY,
|
||||
posX: int(ci.PtPos.X),
|
||||
posY: int(ci.PtPos.Y),
|
||||
hasPos: true,
|
||||
serial: s.serial,
|
||||
}
|
||||
return s.snapshot, nil
|
||||
}
|
||||
|
||||
// decodeCursor extracts the sprite at hCur as RGBA along with the hotspot.
|
||||
// Color cursors are read from the colour bitmap with the AND mask combined
|
||||
// in for alpha. Monochrome cursors collapse the two halves of the mask
|
||||
// bitmap into a single visible sprite where the AND bit drives alpha.
|
||||
func decodeCursor(hCur windows.Handle) (*image.RGBA, int, int, error) {
|
||||
var info winIconInfo
|
||||
r, _, err := procGetIconInfo.Call(uintptr(hCur), uintptr(unsafe.Pointer(&info)))
|
||||
if r == 0 {
|
||||
return nil, 0, 0, fmt.Errorf("GetIconInfo: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if info.HbmMask != 0 {
|
||||
_, _, _ = procDeleteObject.Call(uintptr(info.HbmMask))
|
||||
}
|
||||
if info.HbmColor != 0 {
|
||||
_, _, _ = procDeleteObject.Call(uintptr(info.HbmColor))
|
||||
}
|
||||
}()
|
||||
hotX, hotY := int(info.XHotspot), int(info.YHotspot)
|
||||
if info.HbmColor != 0 {
|
||||
img, err := decodeColorCursor(info.HbmColor, info.HbmMask)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return img, hotX, hotY, nil
|
||||
}
|
||||
img, err := decodeMonoCursor(info.HbmMask)
|
||||
if err != nil {
|
||||
return nil, 0, 0, err
|
||||
}
|
||||
return img, hotX, hotY, nil
|
||||
}
|
||||
|
||||
// readBitmap returns the BITMAP descriptor for hbm.
|
||||
func readBitmap(hbm windows.Handle) (winBitmap, error) {
|
||||
var bm winBitmap
|
||||
r, _, err := procGetObjectW.Call(uintptr(hbm), unsafe.Sizeof(bm), uintptr(unsafe.Pointer(&bm)))
|
||||
if r == 0 {
|
||||
return winBitmap{}, fmt.Errorf("GetObject: %w", err)
|
||||
}
|
||||
return bm, nil
|
||||
}
|
||||
|
||||
// dibCopy reads hbm as 32bpp top-down BGRA into a freshly allocated slice
|
||||
// matching w*h*4 bytes. The bitmap may be selected into the screen DC so
|
||||
// we use a memory DC to keep the call cheap.
|
||||
func dibCopy(hbm windows.Handle, w, h int32) ([]byte, error) {
|
||||
hdcScreen, _, _ := procGetDC.Call(0)
|
||||
if hdcScreen == 0 {
|
||||
return nil, fmt.Errorf("GetDC: failed")
|
||||
}
|
||||
defer func() { _, _, _ = procReleaseDC.Call(0, hdcScreen) }()
|
||||
hdcMem, _, _ := procCreateCompatDC.Call(hdcScreen)
|
||||
if hdcMem == 0 {
|
||||
return nil, fmt.Errorf("CreateCompatibleDC: failed")
|
||||
}
|
||||
defer func() { _, _, _ = procDeleteDC.Call(hdcMem) }()
|
||||
|
||||
var bih winBitmapInfoHeader
|
||||
bih.BiSize = dibSectionBytes
|
||||
bih.BiWidth = w
|
||||
bih.BiHeight = -h // top-down
|
||||
bih.BiPlanes = 1
|
||||
bih.BiBitCount = 32
|
||||
bih.BiCompression = biRgb
|
||||
|
||||
buf := make([]byte, int(w)*int(h)*4)
|
||||
r, _, err := procGetDIBits.Call(
|
||||
hdcMem,
|
||||
uintptr(hbm),
|
||||
0,
|
||||
uintptr(h),
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(unsafe.Pointer(&bih)),
|
||||
diRgbColors,
|
||||
)
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("GetDIBits: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// decodeColorCursor reads a 32bpp colour cursor and folds the AND mask into
|
||||
// the alpha channel when the colour bitmap leaves it zero.
|
||||
func decodeColorCursor(hbmColor, hbmMask windows.Handle) (*image.RGBA, error) {
|
||||
bm, err := readBitmap(hbmColor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, h := bm.BmWidth, bm.BmHeight
|
||||
color, err := dibCopy(hbmColor, w, h)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var mask []byte
|
||||
if hbmMask != 0 {
|
||||
mask, _ = dibCopy(hbmMask, w, h)
|
||||
}
|
||||
hasAlpha := colorHasAlpha(color)
|
||||
img := image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
|
||||
for y := int32(0); y < h; y++ {
|
||||
for x := int32(0); x < w; x++ {
|
||||
si := (y*w + x) * 4
|
||||
b := color[si]
|
||||
g := color[si+1]
|
||||
r := color[si+2]
|
||||
a := pixelAlpha(color[si+3], si, mask, hasAlpha)
|
||||
// Premultiply so the shared compositor can use the same
|
||||
// formula on every platform (X11 XFixes and macOS CG return
|
||||
// premultiplied bytes natively).
|
||||
if a != 255 && a != 0 {
|
||||
r = byte(uint32(r) * uint32(a) / 255)
|
||||
g = byte(uint32(g) * uint32(a) / 255)
|
||||
b = byte(uint32(b) * uint32(a) / 255)
|
||||
} else if a == 0 {
|
||||
r, g, b = 0, 0, 0
|
||||
}
|
||||
img.Pix[si+0] = r
|
||||
img.Pix[si+1] = g
|
||||
img.Pix[si+2] = b
|
||||
img.Pix[si+3] = a
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// colorHasAlpha reports whether any pixel of a 32bpp BGRA buffer has a
|
||||
// non-zero alpha. Cursors authored without alpha leave the channel at 0
|
||||
// and rely on hbmMask for transparency.
|
||||
func colorHasAlpha(color []byte) bool {
|
||||
for i := 0; i < len(color); i += 4 {
|
||||
if color[i+3] != 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pixelAlpha returns the effective alpha for a colour-cursor pixel. When
|
||||
// the source bitmap already has alpha we trust it; otherwise the AND mask
|
||||
// decides (1 = transparent, 0 = opaque). The 32bpp DIB stores each AND
|
||||
// bit as a 4-byte entry; the first byte carries the effective value.
|
||||
func pixelAlpha(colorA byte, si int32, mask []byte, hasAlpha bool) byte {
|
||||
if hasAlpha {
|
||||
return colorA
|
||||
}
|
||||
if mask != nil && mask[si] != 0 {
|
||||
return 0
|
||||
}
|
||||
return 255
|
||||
}
|
||||
|
||||
// decodeMonoCursor handles legacy 1bpp cursors where hbmMask is twice as
|
||||
// tall as the visible sprite: rows [0..h) are the AND mask and rows [h..2h)
|
||||
// are the XOR mask. We render the visible half into RGBA, treating
|
||||
// AND-mask=1 as transparent and the XOR bit as a black/white pixel.
|
||||
func decodeMonoCursor(hbmMask windows.Handle) (*image.RGBA, error) {
|
||||
bm, err := readBitmap(hbmMask)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, fullH := bm.BmWidth, bm.BmHeight
|
||||
if fullH%2 != 0 {
|
||||
return nil, fmt.Errorf("unexpected mono cursor shape: %dx%d", w, fullH)
|
||||
}
|
||||
h := fullH / 2
|
||||
data, err := dibCopy(hbmMask, w, fullH)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, int(w), int(h)))
|
||||
for y := int32(0); y < h; y++ {
|
||||
for x := int32(0); x < w; x++ {
|
||||
and := data[(y*w+x)*4]
|
||||
xor := data[((y+h)*w+x)*4]
|
||||
di := (y*w + x) * 4
|
||||
if and != 0 {
|
||||
img.Pix[di+3] = 0
|
||||
continue
|
||||
}
|
||||
c := byte(0)
|
||||
if xor != 0 {
|
||||
c = 255
|
||||
}
|
||||
img.Pix[di+0] = c
|
||||
img.Pix[di+1] = c
|
||||
img.Pix[di+2] = c
|
||||
img.Pix[di+3] = 255
|
||||
}
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// cursorState is the latest snapshot shared between the worker and
|
||||
// session readers.
|
||||
type cursorState struct {
|
||||
mu sync.Mutex
|
||||
snapshot *cursorSnapshot
|
||||
}
|
||||
|
||||
func (s *cursorState) store(snap *cursorSnapshot) {
|
||||
s.mu.Lock()
|
||||
s.snapshot = snap
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *cursorState) load() *cursorSnapshot {
|
||||
s.mu.Lock()
|
||||
snap := s.snapshot
|
||||
s.mu.Unlock()
|
||||
return snap
|
||||
}
|
||||
|
||||
// Cursor satisfies cursorSource by returning the latest snapshot the
|
||||
// capture worker decoded. The "no sample yet" and "cursor hidden" cases
|
||||
// return img=nil with no error so callers skip emission this cycle
|
||||
// without latching the source off for the rest of the session.
|
||||
func (c *DesktopCapturer) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
snap := c.cursorState.load()
|
||||
if snap == nil {
|
||||
return nil, 0, 0, 0, nil
|
||||
}
|
||||
if snap.err != nil {
|
||||
return nil, 0, 0, 0, snap.err
|
||||
}
|
||||
return snap.img, snap.hotX, snap.hotY, snap.serial, nil
|
||||
}
|
||||
|
||||
// CursorPos returns the cursor screen position observed by the worker on
|
||||
// its last sample. Errors out if the worker hasn't yet captured a frame
|
||||
// or the most recent sample failed.
|
||||
func (c *DesktopCapturer) CursorPos() (int, int, error) {
|
||||
snap := c.cursorState.load()
|
||||
if snap == nil {
|
||||
return 0, 0, fmt.Errorf("cursor position not sampled yet")
|
||||
}
|
||||
if snap.err != nil {
|
||||
return 0, 0, snap.err
|
||||
}
|
||||
if !snap.hasPos {
|
||||
return 0, 0, fmt.Errorf("cursor position unavailable")
|
||||
}
|
||||
return snap.posX, snap.posY, nil
|
||||
}
|
||||
127
client/vnc/server/cursor_x11.go
Normal file
127
client/vnc/server/cursor_x11.go
Normal file
@@ -0,0 +1,127 @@
|
||||
//go:build unix && !darwin && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
|
||||
"github.com/jezek/xgb"
|
||||
"github.com/jezek/xgb/xfixes"
|
||||
)
|
||||
|
||||
// xfixesCursor reports the current X cursor sprite via the XFixes extension.
|
||||
// CursorSerial changes whenever the server picks a different cursor, so
|
||||
// callers can cache by serial without comparing pixels.
|
||||
type xfixesCursor struct {
|
||||
mu sync.Mutex
|
||||
conn *xgb.Conn
|
||||
// lastPosX/lastPosY hold the cursor screen position observed on the
|
||||
// most recent successful GetCursorImage. cursorPositionSource readers
|
||||
// share this value so we do not pay a second X round-trip per frame.
|
||||
lastPosX, lastPosY int
|
||||
hasPos bool
|
||||
// lastImg, lastHotX, lastHotY, lastSerial cache the most recent good
|
||||
// GetCursorImage result so transient failures (cursor hidden, server
|
||||
// briefly unresponsive) reuse the previous sprite instead of going
|
||||
// dark. Without this the encoder's compositing path drops to no-op as
|
||||
// soon as the cursor becomes momentarily unavailable.
|
||||
lastImg *image.RGBA
|
||||
lastHotX int
|
||||
lastHotY int
|
||||
lastSerial uint64
|
||||
}
|
||||
|
||||
// newXFixesCursor initialises the XFixes extension on conn. Returns an
|
||||
// error if the extension is unavailable; callers can fall back to no
|
||||
// cursor emission instead of asking on every frame.
|
||||
func newXFixesCursor(conn *xgb.Conn) (*xfixesCursor, error) {
|
||||
if err := xfixes.Init(conn); err != nil {
|
||||
return nil, fmt.Errorf("xfixes init: %w", err)
|
||||
}
|
||||
if _, err := xfixes.QueryVersion(conn, 4, 0).Reply(); err != nil {
|
||||
return nil, fmt.Errorf("xfixes query version: %w", err)
|
||||
}
|
||||
return &xfixesCursor{conn: conn}, nil
|
||||
}
|
||||
|
||||
// Cursor returns the current cursor sprite as RGBA along with its hotspot
|
||||
// and serial. Callers should treat an unchanged serial as "no update". On
|
||||
// a transient GetCursorImage failure the last cached sprite is returned
|
||||
// so compositing keeps painting the cursor instead of disappearing.
|
||||
func (c *xfixesCursor) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
reply, err := xfixes.GetCursorImage(c.conn).Reply()
|
||||
if err != nil {
|
||||
if c.lastImg != nil {
|
||||
return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil
|
||||
}
|
||||
return nil, 0, 0, 0, fmt.Errorf("xfixes GetCursorImage: %w", err)
|
||||
}
|
||||
c.lastPosX, c.lastPosY, c.hasPos = int(reply.X), int(reply.Y), true
|
||||
w, h := int(reply.Width), int(reply.Height)
|
||||
if w <= 0 || h <= 0 {
|
||||
if c.lastImg != nil {
|
||||
return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil
|
||||
}
|
||||
return nil, 0, 0, 0, fmt.Errorf("cursor has zero extent")
|
||||
}
|
||||
if len(reply.CursorImage) < w*h {
|
||||
if c.lastImg != nil {
|
||||
return c.lastImg, c.lastHotX, c.lastHotY, c.lastSerial, nil
|
||||
}
|
||||
return nil, 0, 0, 0, fmt.Errorf("cursor pixel buffer truncated: %d < %d", len(reply.CursorImage), w*h)
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
// XFixes packs each pixel as a uint32 in ARGB order with premultiplied
|
||||
// alpha. Unpack into the standard RGBA byte layout.
|
||||
for i, p := range reply.CursorImage[:w*h] {
|
||||
o := i * 4
|
||||
img.Pix[o+0] = byte(p >> 16)
|
||||
img.Pix[o+1] = byte(p >> 8)
|
||||
img.Pix[o+2] = byte(p)
|
||||
img.Pix[o+3] = byte(p >> 24)
|
||||
}
|
||||
c.lastImg = img
|
||||
c.lastHotX = int(reply.Xhot)
|
||||
c.lastHotY = int(reply.Yhot)
|
||||
c.lastSerial = uint64(reply.CursorSerial)
|
||||
return img, c.lastHotX, c.lastHotY, c.lastSerial, nil
|
||||
}
|
||||
|
||||
// Cursor on X11Capturer satisfies cursorSource. The XFixes binding is
|
||||
// created lazily on the same X connection used for screen capture; the
|
||||
// first init failure is latched so we stop asking on every frame.
|
||||
func (x *X11Capturer) Cursor() (*image.RGBA, int, int, uint64, error) {
|
||||
x.mu.Lock()
|
||||
if x.cursor == nil && x.cursorInitErr == nil {
|
||||
x.cursor, x.cursorInitErr = newXFixesCursor(x.conn)
|
||||
}
|
||||
cur := x.cursor
|
||||
initErr := x.cursorInitErr
|
||||
x.mu.Unlock()
|
||||
if initErr != nil {
|
||||
return nil, 0, 0, 0, initErr
|
||||
}
|
||||
return cur.Cursor()
|
||||
}
|
||||
|
||||
// CursorPos on X11Capturer returns the screen position from the most
|
||||
// recent successful Cursor() call. Sessions call Cursor() once per encode
|
||||
// cycle, so this stays current without a second X round-trip.
|
||||
func (x *X11Capturer) CursorPos() (int, int, error) {
|
||||
x.mu.Lock()
|
||||
cur := x.cursor
|
||||
x.mu.Unlock()
|
||||
if cur == nil {
|
||||
return 0, 0, fmt.Errorf("cursor source not initialised")
|
||||
}
|
||||
cur.mu.Lock()
|
||||
defer cur.mu.Unlock()
|
||||
if !cur.hasPos {
|
||||
return 0, 0, fmt.Errorf("cursor position not sampled yet")
|
||||
}
|
||||
return cur.lastPosX, cur.lastPosY, nil
|
||||
}
|
||||
159
client/vnc/server/extclipboard.go
Normal file
159
client/vnc/server/extclipboard.go
Normal file
@@ -0,0 +1,159 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// ExtendedClipboard is an RFB community extension (pseudo-encoding
|
||||
// 0xC0A1E5CE) that replaces legacy CutText with a Caps/Notify/Request/
|
||||
// Provide/Peek handshake. Wins versus legacy CutText:
|
||||
// - UTF-8 text format (legacy is Latin-1).
|
||||
// - Pull-based: a Notify announces "I have new content", the peer fetches
|
||||
// via Request only when it actually needs the data. Saves bandwidth on
|
||||
// high-latency transports versus pushing every change.
|
||||
// - zlib-compressed payloads.
|
||||
// - Caps negotiation so each side knows the other's per-format max size.
|
||||
//
|
||||
// The extension reuses message opcodes 3 (ServerCutText) and 6 (ClientCutText)
|
||||
// and signals "extended" by encoding the length field as a negative int32;
|
||||
// the absolute value is the payload size in bytes. The first 4 bytes of
|
||||
// payload are a flags word: top byte is the action, low 16 bits are the
|
||||
// format mask.
|
||||
const pseudoEncExtendedClipboard = -1063131698 // 0xC0A1E5CE as int32
|
||||
|
||||
const (
|
||||
extClipActionCaps uint32 = 0x01000000
|
||||
extClipActionRequest uint32 = 0x02000000
|
||||
extClipActionPeek uint32 = 0x04000000
|
||||
extClipActionNotify uint32 = 0x08000000
|
||||
extClipActionProvide uint32 = 0x10000000
|
||||
extClipActionMask uint32 = 0x1F000000
|
||||
|
||||
extClipFormatText uint32 = 0x00000001
|
||||
extClipFormatRTF uint32 = 0x00000002
|
||||
extClipFormatHTML uint32 = 0x00000004
|
||||
extClipFormatDIB uint32 = 0x00000008
|
||||
extClipFormatFiles uint32 = 0x00000010
|
||||
extClipFormatMask uint32 = 0x0000FFFF
|
||||
|
||||
// extClipMaxText caps our accepted text payload. Mirrors the legacy
|
||||
// maxCutTextBytes (1 MiB); advertised in Caps and enforced on Provide.
|
||||
extClipMaxText = maxCutTextBytes
|
||||
|
||||
// extClipMaxPayload bounds the raw on-wire payload we will read for an
|
||||
// extended CutText message. Includes flags header, length prefixes, NUL,
|
||||
// and zlib framing overhead on top of the text body.
|
||||
extClipMaxPayload = extClipMaxText + 1024
|
||||
)
|
||||
|
||||
// buildExtClipCaps emits the Caps payload. The flags word advertises every
|
||||
// action we support in the high byte (Caps + Request + Peek + Notify +
|
||||
// Provide) and every format we accept in the low 16 bits. Clients use
|
||||
// these action bits to decide whether to auto-Request on Notify; without
|
||||
// Request in our Caps a conforming client silently drops our Notify
|
||||
// messages. After the flags word we emit one uint32 max size per format
|
||||
// bit set, in ascending bit order.
|
||||
func buildExtClipCaps() []byte {
|
||||
flags := extClipActionCaps | extClipActionRequest | extClipActionPeek |
|
||||
extClipActionNotify | extClipActionProvide | extClipFormatText
|
||||
payload := make([]byte, 4+4)
|
||||
binary.BigEndian.PutUint32(payload[0:4], flags)
|
||||
binary.BigEndian.PutUint32(payload[4:8], uint32(extClipMaxText))
|
||||
return payload
|
||||
}
|
||||
|
||||
// buildExtClipNotify emits a Notify announcing that we have new clipboard
|
||||
// content available in the given format mask. No data is shipped; the peer
|
||||
// pulls via Request when it actually needs to paste.
|
||||
func buildExtClipNotify(formats uint32) []byte {
|
||||
payload := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(payload, extClipActionNotify|formats)
|
||||
return payload
|
||||
}
|
||||
|
||||
// buildExtClipRequest emits a Request asking the peer to send Provide for
|
||||
// the given format mask. Sent in response to an inbound Notify.
|
||||
func buildExtClipRequest(formats uint32) []byte {
|
||||
payload := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(payload, extClipActionRequest|formats)
|
||||
return payload
|
||||
}
|
||||
|
||||
// buildExtClipProvideText emits a Provide carrying UTF-8 text. The inner
|
||||
// stream (4-byte length including the trailing NUL, then UTF-8 bytes, then
|
||||
// NUL) is zlib-compressed; each Provide uses an independent zlib context
|
||||
// per the extension spec. Rejects oversized input so a caller bug can't
|
||||
// produce a payload larger than the size advertised in our Caps.
|
||||
func buildExtClipProvideText(text string) ([]byte, error) {
|
||||
if len(text)+1 > extClipMaxText {
|
||||
return nil, fmt.Errorf("clipboard text exceeds extClipMaxText (%d > %d)", len(text)+1, extClipMaxText)
|
||||
}
|
||||
body := make([]byte, 0, 4+len(text)+1)
|
||||
var lenBuf [4]byte
|
||||
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(text)+1))
|
||||
body = append(body, lenBuf[:]...)
|
||||
body = append(body, text...)
|
||||
body = append(body, 0)
|
||||
|
||||
var compressed bytes.Buffer
|
||||
zw := zlib.NewWriter(&compressed)
|
||||
if _, err := zw.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("zlib write: %w", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("zlib close: %w", err)
|
||||
}
|
||||
|
||||
payload := make([]byte, 4+compressed.Len())
|
||||
binary.BigEndian.PutUint32(payload[0:4], extClipActionProvide|extClipFormatText)
|
||||
copy(payload[4:], compressed.Bytes())
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
// parseExtClipProvideText decompresses a Provide payload (the bytes after
|
||||
// the 4-byte flags header) and returns the UTF-8 text record if the text
|
||||
// format bit is set. Records for other formats are skipped. The trailing
|
||||
// NUL byte the spec appends to text records is stripped.
|
||||
func parseExtClipProvideText(flags uint32, payload []byte) (string, error) {
|
||||
zr, err := zlib.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("zlib reader: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
limited := io.LimitReader(zr, int64(extClipMaxText)+16)
|
||||
var text string
|
||||
for bit := uint32(1); bit <= extClipFormatFiles; bit <<= 1 {
|
||||
if flags&bit == 0 {
|
||||
continue
|
||||
}
|
||||
var sizeBuf [4]byte
|
||||
if _, err := io.ReadFull(limited, sizeBuf[:]); err != nil {
|
||||
if bit == extClipFormatText && err == io.EOF {
|
||||
return "", nil
|
||||
}
|
||||
return "", fmt.Errorf("read record size: %w", err)
|
||||
}
|
||||
size := binary.BigEndian.Uint32(sizeBuf[:])
|
||||
if size > uint32(extClipMaxText) {
|
||||
return "", fmt.Errorf("record too large: %d", size)
|
||||
}
|
||||
rec := make([]byte, size)
|
||||
if _, err := io.ReadFull(limited, rec); err != nil {
|
||||
return "", fmt.Errorf("read record: %w", err)
|
||||
}
|
||||
if bit == extClipFormatText {
|
||||
if len(rec) > 0 && rec[len(rec)-1] == 0 {
|
||||
rec = rec[:len(rec)-1]
|
||||
}
|
||||
text = string(rec)
|
||||
}
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
102
client/vnc/server/extclipboard_test.go
Normal file
102
client/vnc/server/extclipboard_test.go
Normal file
@@ -0,0 +1,102 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBuildExtClipCaps(t *testing.T) {
|
||||
payload := buildExtClipCaps()
|
||||
require.Len(t, payload, 8, "Caps with one format should be 4 bytes flags + 4 bytes size")
|
||||
|
||||
flags := binary.BigEndian.Uint32(payload[0:4])
|
||||
// Clients check individual action bits in our Caps to decide whether to
|
||||
// auto-Request on Notify, so all supported actions must be advertised.
|
||||
assert.NotZero(t, flags&extClipActionCaps, "Caps action bit must be set")
|
||||
assert.NotZero(t, flags&extClipActionRequest, "Request action bit must be set")
|
||||
assert.NotZero(t, flags&extClipActionPeek, "Peek action bit must be set")
|
||||
assert.NotZero(t, flags&extClipActionNotify, "Notify action bit must be set")
|
||||
assert.NotZero(t, flags&extClipActionProvide, "Provide action bit must be set")
|
||||
assert.Equal(t, extClipFormatText, flags&extClipFormatMask, "should advertise text format")
|
||||
|
||||
maxSize := binary.BigEndian.Uint32(payload[4:8])
|
||||
assert.Equal(t, uint32(extClipMaxText), maxSize, "should advertise extClipMaxText")
|
||||
}
|
||||
|
||||
func TestBuildExtClipNotify(t *testing.T) {
|
||||
payload := buildExtClipNotify(extClipFormatText)
|
||||
require.Len(t, payload, 4)
|
||||
flags := binary.BigEndian.Uint32(payload)
|
||||
assert.Equal(t, extClipActionNotify, flags&extClipActionMask)
|
||||
assert.Equal(t, extClipFormatText, flags&extClipFormatMask)
|
||||
}
|
||||
|
||||
func TestBuildExtClipRequest(t *testing.T) {
|
||||
payload := buildExtClipRequest(extClipFormatText)
|
||||
require.Len(t, payload, 4)
|
||||
flags := binary.BigEndian.Uint32(payload)
|
||||
assert.Equal(t, extClipActionRequest, flags&extClipActionMask)
|
||||
assert.Equal(t, extClipFormatText, flags&extClipFormatMask)
|
||||
}
|
||||
|
||||
func TestExtClipProvideRoundTripASCII(t *testing.T) {
|
||||
const original = "hello world"
|
||||
payload, err := buildExtClipProvideText(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
flags := binary.BigEndian.Uint32(payload[0:4])
|
||||
require.Equal(t, extClipActionProvide, flags&extClipActionMask)
|
||||
require.Equal(t, extClipFormatText, flags&extClipFormatMask)
|
||||
|
||||
text, err := parseExtClipProvideText(flags, payload[4:])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, text)
|
||||
}
|
||||
|
||||
func TestExtClipProvideRoundTripUTF8(t *testing.T) {
|
||||
original := "héllo 🦀 世界"
|
||||
payload, err := buildExtClipProvideText(original)
|
||||
require.NoError(t, err)
|
||||
|
||||
flags := binary.BigEndian.Uint32(payload[0:4])
|
||||
text, err := parseExtClipProvideText(flags, payload[4:])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, text, "UTF-8 should round-trip without mangling")
|
||||
}
|
||||
|
||||
func TestExtClipProvideRoundTripEmpty(t *testing.T) {
|
||||
payload, err := buildExtClipProvideText("")
|
||||
require.NoError(t, err)
|
||||
|
||||
flags := binary.BigEndian.Uint32(payload[0:4])
|
||||
text, err := parseExtClipProvideText(flags, payload[4:])
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, text)
|
||||
}
|
||||
|
||||
func TestExtClipProvideRoundTripLarge(t *testing.T) {
|
||||
original := strings.Repeat("abcd", 200000) // 800 KiB, below cap
|
||||
payload, err := buildExtClipProvideText(original)
|
||||
require.NoError(t, err)
|
||||
assert.Less(t, len(payload), len(original)/2,
|
||||
"highly repetitive text should compress significantly")
|
||||
|
||||
flags := binary.BigEndian.Uint32(payload[0:4])
|
||||
text, err := parseExtClipProvideText(flags, payload[4:])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, original, text)
|
||||
}
|
||||
|
||||
func TestParseExtClipProvideTextRejectsOversized(t *testing.T) {
|
||||
var fakePayload [4]byte
|
||||
// 4 bytes of zlib-compressed garbage won't decode; we want to ensure we
|
||||
// don't panic, not that we accept it.
|
||||
_, err := parseExtClipProvideText(extClipActionProvide|extClipFormatText, fakePayload[:])
|
||||
assert.Error(t, err)
|
||||
}
|
||||
865
client/vnc/server/input_darwin.go
Normal file
865
client/vnc/server/input_darwin.go
Normal file
@@ -0,0 +1,865 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Core Graphics event constants.
|
||||
const (
|
||||
kCGEventSourceStateCombinedSessionState int32 = 0
|
||||
|
||||
kCGEventLeftMouseDown int32 = 1
|
||||
kCGEventLeftMouseUp int32 = 2
|
||||
kCGEventRightMouseDown int32 = 3
|
||||
kCGEventRightMouseUp int32 = 4
|
||||
kCGEventMouseMoved int32 = 5
|
||||
kCGEventLeftMouseDragged int32 = 6
|
||||
kCGEventRightMouseDragged int32 = 7
|
||||
kCGEventKeyDown int32 = 10
|
||||
kCGEventKeyUp int32 = 11
|
||||
kCGEventFlagsChanged int32 = 12
|
||||
kCGEventOtherMouseDown int32 = 25
|
||||
kCGEventOtherMouseUp int32 = 26
|
||||
|
||||
kCGMouseButtonLeft int32 = 0
|
||||
kCGMouseButtonRight int32 = 1
|
||||
kCGMouseButtonCenter int32 = 2
|
||||
|
||||
kCGHIDEventTap int32 = 0
|
||||
|
||||
// kCGEventFlagMaskSecondaryFn is the CGEventFlags bit Apple sets when
|
||||
// a key was activated via the Fn modifier on internal keyboards. The
|
||||
// navigation cluster (ForwardDelete, Home, End, PageUp, PageDown,
|
||||
// Help/Insert, arrows) lives in the Fn-shifted region of an Apple
|
||||
// keyboard, so synthesising those keycodes without this bit leaves the
|
||||
// system in a confused "Fn implied" state where the next plain
|
||||
// letter is treated as a menu accelerator.
|
||||
kCGEventFlagMaskSecondaryFn uint64 = 0x00800000
|
||||
|
||||
// kCGMouseEventClickState (event field 1) tells macOS how many
|
||||
// consecutive clicks of this button have happened. Without it, a
|
||||
// double click looks like two independent single clicks and apps
|
||||
// never see the dblclick (window-bar maximize, text word-select, ...).
|
||||
kCGMouseEventClickState int32 = 1
|
||||
|
||||
// doubleClickWindow is the upper bound on the gap between two
|
||||
// down events that still counts as a multi-click. macOS reads the
|
||||
// user's setting from CGEventSourceGetDoubleClickInterval; 500ms is
|
||||
// the default and works as a safe injection-side ceiling.
|
||||
doubleClickWindow = 500 * time.Millisecond
|
||||
|
||||
// IOKit power management constants.
|
||||
kIOPMUserActiveLocal int32 = 0
|
||||
kIOPMAssertionLevelOn uint32 = 255
|
||||
kCFStringEncodingUTF8 uint32 = 0x08000100
|
||||
)
|
||||
|
||||
var darwinInputOnce sync.Once
|
||||
|
||||
var (
|
||||
cgEventSourceCreate func(int32) uintptr
|
||||
cgEventCreateKeyboardEvent func(uintptr, uint16, bool) uintptr
|
||||
// CGEventCreateMouseEvent takes CGPoint as two separate float64 args.
|
||||
// purego can't handle array/struct types but individual float64s work.
|
||||
cgEventCreateMouseEvent func(uintptr, int32, float64, float64, int32) uintptr
|
||||
cgEventPost func(int32, uintptr)
|
||||
cgEventSetIntegerValueField func(uintptr, int32, int64)
|
||||
cgEventSetFlags func(uintptr, uint64)
|
||||
cgEventSetType func(uintptr, int32)
|
||||
cgEventCreateForInput func(uintptr) uintptr
|
||||
|
||||
// CGEventCreateScrollWheelEvent is variadic, call via SyscallN.
|
||||
cgEventCreateScrollWheelEventAddr uintptr
|
||||
|
||||
axIsProcessTrusted func() bool
|
||||
// axIsProcessTrustedWithOptions takes a CFDictionary; when the dict's
|
||||
// kAXTrustedCheckOptionPrompt key is true, macOS shows the native
|
||||
// Accessibility prompt with an "Open System Settings" button the
|
||||
// first time the process asks. The bare AXIsProcessTrusted variant is
|
||||
// a silent check that never prompts.
|
||||
axIsProcessTrustedWithOptions func(uintptr) bool
|
||||
// cfDictionaryCreate builds the options dictionary above.
|
||||
cfDictionaryCreate func(uintptr, *uintptr, *uintptr, int64, uintptr, uintptr) uintptr
|
||||
// cfBooleanTrue is the global CF boolean we cache from a Dlsym lookup.
|
||||
cfBooleanTrue uintptr
|
||||
// axTrustedCheckOptionPromptCFStr is the option key for the dict.
|
||||
axTrustedCheckOptionPromptCFStr uintptr
|
||||
// kCFTypeDictionaryKey/Value CallBacks: standard CF retain/release
|
||||
// callback tables. Required so the dict properly manages refcounts on
|
||||
// the CFString key and CFBoolean value.
|
||||
kCFTypeDictionaryKeyCallBacksAddr uintptr
|
||||
kCFTypeDictionaryValueCallBacksAddr uintptr
|
||||
|
||||
// IOKit power-management bindings used to wake the display and inhibit
|
||||
// idle sleep while a VNC client is driving input.
|
||||
iopmAssertionDeclareUserActivity func(uintptr, int32, *uint32) int32
|
||||
iopmAssertionCreateWithName func(uintptr, uint32, uintptr, *uint32) int32
|
||||
iopmAssertionRelease func(uint32) int32
|
||||
cfStringCreateWithCString func(uintptr, string, uint32) uintptr
|
||||
|
||||
// Cached CFStrings for assertion name and idle-sleep type.
|
||||
pmAssertionNameCFStr uintptr
|
||||
pmPreventIdleDisplayCFStr uintptr
|
||||
|
||||
// Assertion IDs. userActivityID is reused across input events so repeated
|
||||
// calls refresh the same assertion rather than create new ones.
|
||||
pmMu sync.Mutex
|
||||
userActivityID uint32
|
||||
preventSleepID uint32
|
||||
preventSleepHeld bool
|
||||
// preventSleepRef tracks the refcount of held assertions across
|
||||
// concurrent injectors and sessions.
|
||||
preventSleepRef int
|
||||
|
||||
darwinInputReady bool
|
||||
darwinEventSource uintptr
|
||||
)
|
||||
|
||||
func initDarwinInput() {
|
||||
darwinInputOnce.Do(func() {
|
||||
cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreGraphics for input: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cgEventSourceCreate, cg, "CGEventSourceCreate")
|
||||
purego.RegisterLibFunc(&cgEventCreateKeyboardEvent, cg, "CGEventCreateKeyboardEvent")
|
||||
purego.RegisterLibFunc(&cgEventCreateMouseEvent, cg, "CGEventCreateMouseEvent")
|
||||
purego.RegisterLibFunc(&cgEventPost, cg, "CGEventPost")
|
||||
purego.RegisterLibFunc(&cgEventSetIntegerValueField, cg, "CGEventSetIntegerValueField")
|
||||
purego.RegisterLibFunc(&cgEventSetFlags, cg, "CGEventSetFlags")
|
||||
purego.RegisterLibFunc(&cgEventSetType, cg, "CGEventSetType")
|
||||
purego.RegisterLibFunc(&cgEventCreateForInput, cg, "CGEventCreate")
|
||||
|
||||
sym, err := purego.Dlsym(cg, "CGEventCreateScrollWheelEvent")
|
||||
if err == nil {
|
||||
cgEventCreateScrollWheelEventAddr = sym
|
||||
}
|
||||
|
||||
if ax, err := purego.Dlopen("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices", purego.RTLD_NOW|purego.RTLD_GLOBAL); err == nil {
|
||||
if sym, err := purego.Dlsym(ax, "AXIsProcessTrusted"); err == nil {
|
||||
purego.RegisterFunc(&axIsProcessTrusted, sym)
|
||||
}
|
||||
if sym, err := purego.Dlsym(ax, "AXIsProcessTrustedWithOptions"); err == nil {
|
||||
purego.RegisterFunc(&axIsProcessTrustedWithOptions, sym)
|
||||
}
|
||||
}
|
||||
|
||||
// initPowerAssertions registers cfStringCreateWithCString, which
|
||||
// initCFDictionarySymbols then uses to build the AX prompt key.
|
||||
initPowerAssertions()
|
||||
initCFDictionarySymbols()
|
||||
|
||||
darwinInputReady = true
|
||||
})
|
||||
}
|
||||
|
||||
// initCFDictionarySymbols loads the CF symbols needed to build the
|
||||
// options dictionary for AXIsProcessTrustedWithOptions. Best-effort:
|
||||
// failure here just leaves axIsProcessTrustedWithOptions unusable and we
|
||||
// fall back to the silent check.
|
||||
func initCFDictionarySymbols() {
|
||||
cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreFoundation for AX prompt dict: %v", err)
|
||||
return
|
||||
}
|
||||
if sym, err := purego.Dlsym(cf, "CFDictionaryCreate"); err == nil {
|
||||
purego.RegisterFunc(&cfDictionaryCreate, sym)
|
||||
}
|
||||
if sym, err := purego.Dlsym(cf, "kCFTypeDictionaryKeyCallBacks"); err == nil {
|
||||
kCFTypeDictionaryKeyCallBacksAddr = sym
|
||||
}
|
||||
if sym, err := purego.Dlsym(cf, "kCFTypeDictionaryValueCallBacks"); err == nil {
|
||||
kCFTypeDictionaryValueCallBacksAddr = sym
|
||||
}
|
||||
if sym, err := purego.Dlsym(cf, "kCFBooleanTrue"); err == nil {
|
||||
// kCFBooleanTrue is a pointer-to-pointer (CFBooleanRef stored at the
|
||||
// symbol address). Dereference once to get the actual CFBoolean.
|
||||
cfBooleanTrue = *(*uintptr)(unsafe.Pointer(sym))
|
||||
}
|
||||
if cfStringCreateWithCString != nil {
|
||||
axTrustedCheckOptionPromptCFStr = cfStringCreateWithCString(0, "AXTrustedCheckOptionPrompt", kCFStringEncodingUTF8)
|
||||
}
|
||||
}
|
||||
|
||||
func initPowerAssertions() {
|
||||
iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load IOKit: %v", err)
|
||||
return
|
||||
}
|
||||
cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreFoundation for power assertions: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cfStringCreateWithCString, cf, "CFStringCreateWithCString")
|
||||
purego.RegisterLibFunc(&iopmAssertionDeclareUserActivity, iokit, "IOPMAssertionDeclareUserActivity")
|
||||
purego.RegisterLibFunc(&iopmAssertionCreateWithName, iokit, "IOPMAssertionCreateWithName")
|
||||
purego.RegisterLibFunc(&iopmAssertionRelease, iokit, "IOPMAssertionRelease")
|
||||
|
||||
pmAssertionNameCFStr = cfStringCreateWithCString(0, "NetBird VNC input", kCFStringEncodingUTF8)
|
||||
pmPreventIdleDisplayCFStr = cfStringCreateWithCString(0, "PreventUserIdleDisplaySleep", kCFStringEncodingUTF8)
|
||||
}
|
||||
|
||||
// wakeDisplay declares user activity so macOS treats the synthesized input as
|
||||
// real HID activity, waking the display if it is asleep. Called on every key
|
||||
// and pointer event; the kernel coalesces repeated calls cheaply.
|
||||
func wakeDisplay() {
|
||||
if iopmAssertionDeclareUserActivity == nil || pmAssertionNameCFStr == 0 {
|
||||
return
|
||||
}
|
||||
pmMu.Lock()
|
||||
defer pmMu.Unlock()
|
||||
id := userActivityID
|
||||
r := iopmAssertionDeclareUserActivity(pmAssertionNameCFStr, kIOPMUserActiveLocal, &id)
|
||||
if r != 0 {
|
||||
log.Tracef("IOPMAssertionDeclareUserActivity returned %d", r)
|
||||
return
|
||||
}
|
||||
userActivityID = id
|
||||
}
|
||||
|
||||
// holdPreventIdleSleep creates an assertion that keeps the display from going
|
||||
// idle-to-sleep while a VNC session is active. Reference-counted so multiple
|
||||
// concurrent sessions don't yank the assertion when one of them releases.
|
||||
func holdPreventIdleSleep() {
|
||||
if iopmAssertionCreateWithName == nil || pmPreventIdleDisplayCFStr == 0 || pmAssertionNameCFStr == 0 {
|
||||
return
|
||||
}
|
||||
pmMu.Lock()
|
||||
defer pmMu.Unlock()
|
||||
preventSleepRef++
|
||||
if preventSleepRef > 1 {
|
||||
return
|
||||
}
|
||||
var id uint32
|
||||
r := iopmAssertionCreateWithName(pmPreventIdleDisplayCFStr, kIOPMAssertionLevelOn, pmAssertionNameCFStr, &id)
|
||||
if r != 0 {
|
||||
log.Debugf("IOPMAssertionCreateWithName returned %d", r)
|
||||
// Reset the refcount on failure so a later successful hold can take it.
|
||||
preventSleepRef = 0
|
||||
return
|
||||
}
|
||||
preventSleepID = id
|
||||
preventSleepHeld = true
|
||||
}
|
||||
|
||||
// releasePreventIdleSleep decrements the assertion refcount and only drops
|
||||
// the actual IOKit assertion on the final release.
|
||||
func releasePreventIdleSleep() {
|
||||
if iopmAssertionRelease == nil {
|
||||
return
|
||||
}
|
||||
pmMu.Lock()
|
||||
defer pmMu.Unlock()
|
||||
if !preventSleepHeld || preventSleepRef == 0 {
|
||||
return
|
||||
}
|
||||
preventSleepRef--
|
||||
if preventSleepRef > 0 {
|
||||
return
|
||||
}
|
||||
if r := iopmAssertionRelease(preventSleepID); r != 0 {
|
||||
log.Debugf("IOPMAssertionRelease returned %d", r)
|
||||
}
|
||||
preventSleepHeld = false
|
||||
preventSleepID = 0
|
||||
}
|
||||
|
||||
func ensureEventSource() uintptr {
|
||||
if darwinEventSource != 0 {
|
||||
return darwinEventSource
|
||||
}
|
||||
darwinEventSource = cgEventSourceCreate(kCGEventSourceStateCombinedSessionState)
|
||||
return darwinEventSource
|
||||
}
|
||||
|
||||
// MacInputInjector injects keyboard and mouse events via Core Graphics.
|
||||
type MacInputInjector struct {
|
||||
lastButtons uint16
|
||||
pbcopyPath string
|
||||
pbpastePath string
|
||||
// clickCount[i] / clickAt[i] track the multi-click sequence for
|
||||
// button i (0=left, 1=right, 2=middle). macOS apps reconstruct
|
||||
// double/triple click semantics from the kCGMouseEventClickState
|
||||
// field on each posted event, not from event timing.
|
||||
clickCount [5]int64
|
||||
clickAt [5]time.Time
|
||||
}
|
||||
|
||||
// NewMacInputInjector creates a macOS input injector.
|
||||
func NewMacInputInjector() (*MacInputInjector, error) {
|
||||
initDarwinInput()
|
||||
if !darwinInputReady {
|
||||
return nil, fmt.Errorf("CoreGraphics not available for input injection")
|
||||
}
|
||||
checkMacPermissions()
|
||||
|
||||
m := &MacInputInjector{}
|
||||
if path, err := exec.LookPath("pbcopy"); err == nil {
|
||||
m.pbcopyPath = path
|
||||
}
|
||||
if path, err := exec.LookPath("pbpaste"); err == nil {
|
||||
m.pbpastePath = path
|
||||
}
|
||||
if m.pbcopyPath == "" || m.pbpastePath == "" {
|
||||
log.Debugf("clipboard tools not found (pbcopy=%q, pbpaste=%q)", m.pbcopyPath, m.pbpastePath)
|
||||
}
|
||||
|
||||
holdPreventIdleSleep()
|
||||
|
||||
log.Info("macOS input injector ready")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// checkMacPermissions probes Accessibility access. Prefers the prompting
|
||||
// variant of AXIsProcessTrusted: when the process is not yet trusted,
|
||||
// macOS shows its native "would like to control your computer" dialog
|
||||
// with an "Open System Settings" button. The silent variant is the
|
||||
// fallback when the prompting symbol or its CF dictionary plumbing
|
||||
// couldn't be loaded.
|
||||
func checkMacPermissions() {
|
||||
if !axProcessIsTrusted() {
|
||||
log.Warn("Accessibility permission not granted. Input injection will not work. " +
|
||||
"Approve the prompt or grant in System Settings > Privacy & Security > Accessibility.")
|
||||
openPrivacyPane("Privacy_Accessibility")
|
||||
}
|
||||
}
|
||||
|
||||
// axProcessIsTrusted asks macOS whether netbird has Accessibility access,
|
||||
// and triggers the native prompt the first time when not trusted. Returns
|
||||
// the current trust status either way.
|
||||
func axProcessIsTrusted() bool {
|
||||
if axIsProcessTrustedWithOptions != nil &&
|
||||
cfDictionaryCreate != nil &&
|
||||
axTrustedCheckOptionPromptCFStr != 0 &&
|
||||
cfBooleanTrue != 0 &&
|
||||
kCFTypeDictionaryKeyCallBacksAddr != 0 &&
|
||||
kCFTypeDictionaryValueCallBacksAddr != 0 {
|
||||
keys := [1]uintptr{axTrustedCheckOptionPromptCFStr}
|
||||
values := [1]uintptr{cfBooleanTrue}
|
||||
dict := cfDictionaryCreate(0, &keys[0], &values[0], 1,
|
||||
kCFTypeDictionaryKeyCallBacksAddr,
|
||||
kCFTypeDictionaryValueCallBacksAddr)
|
||||
if dict != 0 {
|
||||
return axIsProcessTrustedWithOptions(dict)
|
||||
}
|
||||
}
|
||||
if axIsProcessTrusted != nil {
|
||||
return axIsProcessTrusted()
|
||||
}
|
||||
// Symbol load failed entirely. Assume trusted so we don't spam the
|
||||
// log every cycle; capture/inject calls will report concrete errors
|
||||
// if access really is missing.
|
||||
return true
|
||||
}
|
||||
|
||||
// openPrivacyPane opens the relevant pane of System Settings so the user
|
||||
// can toggle the permission without navigating manually. The
|
||||
// x-apple.systempreferences URL scheme works on every macOS release from
|
||||
// 10.10 onward; the per-pane anchor (Privacy_Accessibility, Privacy_ScreenCapture)
|
||||
// is what System Settings/Preferences uses to land on the right row.
|
||||
func openPrivacyPane(pane string) {
|
||||
url := "x-apple.systempreferences:com.apple.preference.security?" + pane
|
||||
if err := exec.Command("open", url).Start(); err != nil {
|
||||
log.Debugf("open privacy pane %s: %v", pane, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectKey simulates a key press or release.
|
||||
func (m *MacInputInjector) InjectKey(keysym uint32, down bool) {
|
||||
wakeDisplay()
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
keycode := keysymToMacKeycode(keysym)
|
||||
if keycode == 0xFFFF {
|
||||
return
|
||||
}
|
||||
m.postMacKey(src, keycode, down)
|
||||
}
|
||||
|
||||
// InjectKeyScancode injects using the QEMU scancode, mapped via the
|
||||
// qemuToMacVK table to Apple's virtual-keycode space. Apple uses an
|
||||
// entirely different scheme from PC AT scancodes, so the table is the
|
||||
// authoritative bridge. On miss we fall back to the keysym path.
|
||||
func (m *MacInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) {
|
||||
wakeDisplay()
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
vk, ok := qemuToMacVK[scancode]
|
||||
if !ok {
|
||||
// Fall back to the keysym path so unmapped keys still work.
|
||||
m.InjectKey(keysym, down)
|
||||
return
|
||||
}
|
||||
m.postMacKey(src, vk, down)
|
||||
}
|
||||
|
||||
// postMacKey emits a single key down/up event via Core Graphics. For
|
||||
// keycodes that live in the Fn-shifted region of an Apple keyboard we
|
||||
// also emit explicit flagsChanged events around the keypress: posting
|
||||
// the Fn flag on the key event alone leaves macOS's modifier state
|
||||
// machine without a matching transition, which manifests as "Fn stays
|
||||
// active" for the next key (e.g. the next letter activates a menu
|
||||
// accelerator).
|
||||
func (m *MacInputInjector) postMacKey(src uintptr, keycode uint16, down bool) {
|
||||
fnShifted := isFnShiftedKeycode(keycode)
|
||||
if fnShifted && down {
|
||||
postFnFlagsChanged(src, true)
|
||||
}
|
||||
event := cgEventCreateKeyboardEvent(src, keycode, down)
|
||||
if event == 0 {
|
||||
if fnShifted && !down {
|
||||
postFnFlagsChanged(src, false)
|
||||
}
|
||||
return
|
||||
}
|
||||
if fnShifted && cgEventSetFlags != nil {
|
||||
cgEventSetFlags(event, kCGEventFlagMaskSecondaryFn)
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, event)
|
||||
cfRelease(event)
|
||||
if fnShifted && !down {
|
||||
postFnFlagsChanged(src, false)
|
||||
}
|
||||
}
|
||||
|
||||
// postFnFlagsChanged emits a synthetic Fn modifier transition so the
|
||||
// system updates its global modifier state to match the key events we
|
||||
// post for the navigation cluster. Without this, posting a Fn-flagged
|
||||
// key event leaves macOS thinking Fn is still held after the key is
|
||||
// released.
|
||||
func postFnFlagsChanged(src uintptr, fnOn bool) {
|
||||
if cgEventCreateForInput == nil || cgEventSetType == nil || cgEventSetFlags == nil {
|
||||
return
|
||||
}
|
||||
event := cgEventCreateForInput(src)
|
||||
if event == 0 {
|
||||
return
|
||||
}
|
||||
cgEventSetType(event, kCGEventFlagsChanged)
|
||||
var flags uint64
|
||||
if fnOn {
|
||||
flags = kCGEventFlagMaskSecondaryFn
|
||||
}
|
||||
cgEventSetFlags(event, flags)
|
||||
cgEventPost(kCGHIDEventTap, event)
|
||||
cfRelease(event)
|
||||
}
|
||||
|
||||
// fnShiftedKeycodes are the Apple navigation/edit keys that hardware produces
|
||||
// with the Fn modifier held.
|
||||
var fnShiftedKeycodes = map[uint16]struct{}{
|
||||
0x72: {}, // Help / Insert
|
||||
0x73: {}, // Home
|
||||
0x74: {}, // PageUp
|
||||
0x75: {}, // ForwardDelete
|
||||
0x77: {}, // End
|
||||
0x79: {}, // PageDown
|
||||
0x7B: {}, // Left
|
||||
0x7C: {}, // Right
|
||||
0x7D: {}, // Down
|
||||
0x7E: {}, // Up
|
||||
}
|
||||
|
||||
// isFnShiftedKeycode reports whether keycode is one of the Apple
|
||||
// navigation/edit keys that hardware produces with the Fn modifier held.
|
||||
func isFnShiftedKeycode(keycode uint16) bool {
|
||||
_, ok := fnShiftedKeycodes[keycode]
|
||||
return ok
|
||||
}
|
||||
|
||||
// InjectPointer simulates mouse movement and button events.
|
||||
func (m *MacInputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) {
|
||||
wakeDisplay()
|
||||
if serverW == 0 || serverH == 0 {
|
||||
return
|
||||
}
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
x, y := scalePxToLogical(px, py, serverW, serverH)
|
||||
m.dispatchPointer(src, buttonMask, x, y)
|
||||
m.lastButtons = buttonMask
|
||||
}
|
||||
|
||||
// scalePxToLogical converts framebuffer coordinates (physical pixels) into
|
||||
// the logical points CGEventCreateMouseEvent expects. Falls back to a 1:1
|
||||
// mapping if the display API is unavailable.
|
||||
func scalePxToLogical(px, py, serverW, serverH int) (float64, float64) {
|
||||
x, y := float64(px), float64(py)
|
||||
if cgDisplayPixelsWide == nil || cgMainDisplayID == nil {
|
||||
return x, y
|
||||
}
|
||||
displayID := cgMainDisplayID()
|
||||
logicalW := int(cgDisplayPixelsWide(displayID))
|
||||
logicalH := int(cgDisplayPixelsHigh(displayID))
|
||||
if logicalW <= 0 || logicalH <= 0 {
|
||||
return x, y
|
||||
}
|
||||
return float64(px) * float64(logicalW) / float64(serverW),
|
||||
float64(py) * float64(logicalH) / float64(serverH)
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint16, x, y float64) {
|
||||
leftDown := buttonMask&0x01 != 0
|
||||
rightDown := buttonMask&0x04 != 0
|
||||
middleDown := buttonMask&0x02 != 0
|
||||
m.postMoveOrDrag(src, leftDown, rightDown, x, y)
|
||||
m.postButtonTransitions(src, buttonMask, x, y)
|
||||
m.postScrollWheel(src, buttonMask)
|
||||
_ = middleDown
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postMoveOrDrag(src uintptr, leftDown, rightDown bool, x, y float64) {
|
||||
switch {
|
||||
case leftDown:
|
||||
m.postMouse(src, kCGEventLeftMouseDragged, x, y, kCGMouseButtonLeft)
|
||||
case rightDown:
|
||||
m.postMouse(src, kCGEventRightMouseDragged, x, y, kCGMouseButtonRight)
|
||||
default:
|
||||
m.postMouse(src, kCGEventMouseMoved, x, y, kCGMouseButtonLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// postButtonTransitions emits the up/down events for each button whose
|
||||
// state changed against m.lastButtons, computing the click count so
|
||||
// macOS recognises double / triple clicks.
|
||||
func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint16, x, y float64) {
|
||||
emit := func(curBit, prevBit uint16, down, up int32, button int32, idx int) {
|
||||
cur := buttonMask&curBit != 0
|
||||
prev := m.lastButtons&prevBit != 0
|
||||
if cur && !prev {
|
||||
now := time.Now()
|
||||
if !m.clickAt[idx].IsZero() && now.Sub(m.clickAt[idx]) <= doubleClickWindow {
|
||||
m.clickCount[idx]++
|
||||
} else {
|
||||
m.clickCount[idx] = 1
|
||||
}
|
||||
m.clickAt[idx] = now
|
||||
m.postMouseClick(src, down, x, y, button, m.clickCount[idx])
|
||||
} else if !cur && prev {
|
||||
count := m.clickCount[idx]
|
||||
if count == 0 {
|
||||
count = 1
|
||||
}
|
||||
m.postMouseClick(src, up, x, y, button, count)
|
||||
}
|
||||
}
|
||||
emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft, 0)
|
||||
emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight, 1)
|
||||
emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter, 2)
|
||||
// CG mouse-button numbers 3 (back) and 4 (forward) are emitted as
|
||||
// "other" events; macOS apps that swallow Browser nav (Finder, web
|
||||
// views) react to these directly.
|
||||
emit(1<<7, 1<<7, kCGEventOtherMouseDown, kCGEventOtherMouseUp, 3, 3)
|
||||
emit(1<<8, 1<<8, kCGEventOtherMouseDown, kCGEventOtherMouseUp, 4, 4)
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint16) {
|
||||
if buttonMask&0x08 != 0 {
|
||||
m.postScroll(src, scrollPixelsPerWheelTick)
|
||||
}
|
||||
if buttonMask&0x10 != 0 {
|
||||
m.postScroll(src, -scrollPixelsPerWheelTick)
|
||||
}
|
||||
}
|
||||
|
||||
// scrollPixelsPerWheelTick is the pixel delta we post for one VNC wheel
|
||||
// button event. Browser-based RFB clients typically emit one press+release
|
||||
// per ~10 px of host wheel/trackpad motion, so a real gesture arrives as
|
||||
// many small events; ~20 px per event keeps the resulting macOS scroll
|
||||
// fluid without overshooting on a single notch.
|
||||
const scrollPixelsPerWheelTick int32 = 22
|
||||
|
||||
func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) {
|
||||
if cgEventCreateMouseEvent == nil {
|
||||
return
|
||||
}
|
||||
event := cgEventCreateMouseEvent(src, eventType, x, y, button)
|
||||
if event == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, event)
|
||||
cfRelease(event)
|
||||
}
|
||||
|
||||
// postMouseClick stamps the click count on the event before posting it.
|
||||
// Without this stamp macOS treats every press as a fresh single click.
|
||||
func (m *MacInputInjector) postMouseClick(src uintptr, eventType int32, x, y float64, button int32, clickCount int64) {
|
||||
if cgEventCreateMouseEvent == nil {
|
||||
return
|
||||
}
|
||||
event := cgEventCreateMouseEvent(src, eventType, x, y, button)
|
||||
if event == 0 {
|
||||
return
|
||||
}
|
||||
if cgEventSetIntegerValueField != nil && clickCount > 1 {
|
||||
cgEventSetIntegerValueField(event, kCGMouseEventClickState, clickCount)
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, event)
|
||||
cfRelease(event)
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postScroll(src uintptr, deltaY int32) {
|
||||
if cgEventCreateScrollWheelEventAddr == 0 {
|
||||
return
|
||||
}
|
||||
// CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta).
|
||||
// Pixel units (0) feel smoother given the small per-event deltas typical
|
||||
// of RFB wheel events than line units (1) where each event jumps a
|
||||
// whole line. Variadic C function, pass via SyscallN.
|
||||
r1, _, _ := purego.SyscallN(cgEventCreateScrollWheelEventAddr,
|
||||
src, 0, 1, uintptr(uint32(deltaY)))
|
||||
if r1 == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, r1)
|
||||
cfRelease(r1)
|
||||
}
|
||||
|
||||
// SetClipboard sets the macOS clipboard using pbcopy.
|
||||
func (m *MacInputInjector) SetClipboard(text string) {
|
||||
if m.pbcopyPath == "" {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(m.pbcopyPath)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Tracef("set clipboard via pbcopy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TypeText synthesizes the given text as keystrokes via Core Graphics.
|
||||
// Lets a client push host clipboard content to the focused remote app
|
||||
// even when the app doesn't honor pbpaste-style clipboard sync (e.g.
|
||||
// login screens, locked-down apps). ASCII printable runes only; others
|
||||
// are skipped.
|
||||
func (m *MacInputInjector) TypeText(text string) {
|
||||
wakeDisplay()
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
const maxChars = 4096
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= maxChars {
|
||||
break
|
||||
}
|
||||
count++
|
||||
typeRune(src, r)
|
||||
}
|
||||
}
|
||||
|
||||
// typeRune emits the press/release events for a single ASCII rune, framing
|
||||
// the keystroke with Shift-down/up when required by the keysym.
|
||||
func typeRune(src uintptr, r rune) {
|
||||
const shiftKey = uint16(0x38) // kVK_Shift
|
||||
keysym, shift, ok := keysymForASCIIRune(r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keycode := keysymToMacKeycode(keysym)
|
||||
if keycode == 0xFFFF {
|
||||
return
|
||||
}
|
||||
if shift {
|
||||
postKey(src, shiftKey, true)
|
||||
}
|
||||
postKey(src, keycode, true)
|
||||
postKey(src, keycode, false)
|
||||
if shift {
|
||||
postKey(src, shiftKey, false)
|
||||
}
|
||||
}
|
||||
|
||||
func postKey(src uintptr, keycode uint16, down bool) {
|
||||
e := cgEventCreateKeyboardEvent(src, keycode, down)
|
||||
if e == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, e)
|
||||
cfRelease(e)
|
||||
}
|
||||
|
||||
// GetClipboard reads the macOS clipboard using pbpaste.
|
||||
func (m *MacInputInjector) GetClipboard() string {
|
||||
if m.pbpastePath == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(m.pbpastePath).Output()
|
||||
if err != nil {
|
||||
// pbpaste exits 1 when the pasteboard has no string flavour.
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Close releases the idle-sleep assertion held for the injector's lifetime.
|
||||
func (m *MacInputInjector) Close() {
|
||||
releasePreventIdleSleep()
|
||||
}
|
||||
|
||||
func keysymToMacKeycode(keysym uint32) uint16 {
|
||||
if keysym >= 0x61 && keysym <= 0x7a {
|
||||
return asciiToMacKey[keysym-0x61]
|
||||
}
|
||||
if keysym >= 0x41 && keysym <= 0x5a {
|
||||
return asciiToMacKey[keysym-0x41]
|
||||
}
|
||||
if keysym >= 0x30 && keysym <= 0x39 {
|
||||
return digitToMacKey[keysym-0x30]
|
||||
}
|
||||
if code, ok := specialKeyMap[keysym]; ok {
|
||||
return code
|
||||
}
|
||||
return 0xFFFF
|
||||
}
|
||||
|
||||
var asciiToMacKey = [26]uint16{
|
||||
0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04,
|
||||
0x22, 0x26, 0x28, 0x25, 0x2E, 0x2D, 0x1F, 0x23,
|
||||
0x0C, 0x0F, 0x01, 0x11, 0x20, 0x09, 0x0D, 0x07,
|
||||
0x10, 0x06,
|
||||
}
|
||||
|
||||
var digitToMacKey = [10]uint16{
|
||||
0x1D, 0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19,
|
||||
}
|
||||
|
||||
var specialKeyMap = map[uint32]uint16{
|
||||
// Whitespace and editing
|
||||
0x0020: 0x31, // space
|
||||
0xff08: 0x33, // BackSpace
|
||||
0xff09: 0x30, // Tab
|
||||
0xff0d: 0x24, // Return
|
||||
0xff1b: 0x35, // Escape
|
||||
0xffff: 0x75, // Delete (forward)
|
||||
|
||||
// Navigation
|
||||
0xff50: 0x73, // Home
|
||||
0xff51: 0x7B, // Left
|
||||
0xff52: 0x7E, // Up
|
||||
0xff53: 0x7C, // Right
|
||||
0xff54: 0x7D, // Down
|
||||
0xff55: 0x74, // Page_Up
|
||||
0xff56: 0x79, // Page_Down
|
||||
0xff57: 0x77, // End
|
||||
0xff63: 0x72, // Insert (Help on Mac)
|
||||
|
||||
// Modifiers
|
||||
0xffe1: 0x38, // Shift_L
|
||||
0xffe2: 0x3C, // Shift_R
|
||||
0xffe3: 0x3B, // Control_L
|
||||
0xffe4: 0x3E, // Control_R
|
||||
0xffe5: 0x39, // Caps_Lock
|
||||
0xffe9: 0x3A, // Alt_L (Option)
|
||||
0xffea: 0x3D, // Alt_R (Option)
|
||||
0xffe7: 0x37, // Meta_L (Command)
|
||||
0xffe8: 0x36, // Meta_R (Command)
|
||||
0xffeb: 0x37, // Super_L (Command)
|
||||
0xffec: 0x36, // Super_R (Command)
|
||||
|
||||
// Mode_switch / ISO_Level3_Shift (for macOS Option remap on layouts)
|
||||
0xff7e: 0x3A, // Mode_switch -> Option
|
||||
0xfe03: 0x3D, // ISO_Level3_Shift -> Right Option
|
||||
|
||||
// Function keys
|
||||
0xffbe: 0x7A, // F1
|
||||
0xffbf: 0x78, // F2
|
||||
0xffc0: 0x63, // F3
|
||||
0xffc1: 0x76, // F4
|
||||
0xffc2: 0x60, // F5
|
||||
0xffc3: 0x61, // F6
|
||||
0xffc4: 0x62, // F7
|
||||
0xffc5: 0x64, // F8
|
||||
0xffc6: 0x65, // F9
|
||||
0xffc7: 0x6D, // F10
|
||||
0xffc8: 0x67, // F11
|
||||
0xffc9: 0x6F, // F12
|
||||
0xffca: 0x69, // F13
|
||||
0xffcb: 0x6B, // F14
|
||||
0xffcc: 0x71, // F15
|
||||
0xffcd: 0x6A, // F16
|
||||
0xffce: 0x40, // F17
|
||||
0xffcf: 0x4F, // F18
|
||||
0xffd0: 0x50, // F19
|
||||
0xffd1: 0x5A, // F20
|
||||
|
||||
// Punctuation (US keyboard layout, keysym = ASCII code)
|
||||
0x002d: 0x1B, // minus -
|
||||
0x003d: 0x18, // equal =
|
||||
0x005b: 0x21, // bracketleft [
|
||||
0x005d: 0x1E, // bracketright ]
|
||||
0x005c: 0x2A, // backslash
|
||||
0x003b: 0x29, // semicolon ;
|
||||
0x0027: 0x27, // apostrophe '
|
||||
0x0060: 0x32, // grave `
|
||||
0x002c: 0x2B, // comma ,
|
||||
0x002e: 0x2F, // period .
|
||||
0x002f: 0x2C, // slash /
|
||||
|
||||
// Shifted punctuation (clients sometimes send these as separate keysyms)
|
||||
0x005f: 0x1B, // underscore _ (shift+minus)
|
||||
0x002b: 0x18, // plus + (shift+equal)
|
||||
0x007b: 0x21, // braceleft { (shift+[)
|
||||
0x007d: 0x1E, // braceright } (shift+])
|
||||
0x007c: 0x2A, // bar | (shift+\)
|
||||
0x003a: 0x29, // colon : (shift+;)
|
||||
0x0022: 0x27, // quotedbl " (shift+')
|
||||
0x007e: 0x32, // tilde ~ (shift+`)
|
||||
0x003c: 0x2B, // less < (shift+,)
|
||||
0x003e: 0x2F, // greater > (shift+.)
|
||||
0x003f: 0x2C, // question ? (shift+/)
|
||||
0x0021: 0x12, // exclam ! (shift+1)
|
||||
0x0040: 0x13, // at @ (shift+2)
|
||||
0x0023: 0x14, // numbersign # (shift+3)
|
||||
0x0024: 0x15, // dollar $ (shift+4)
|
||||
0x0025: 0x17, // percent % (shift+5)
|
||||
0x005e: 0x16, // asciicircum ^ (shift+6)
|
||||
0x0026: 0x1A, // ampersand & (shift+7)
|
||||
0x002a: 0x1C, // asterisk * (shift+8)
|
||||
0x0028: 0x19, // parenleft ( (shift+9)
|
||||
0x0029: 0x1D, // parenright ) (shift+0)
|
||||
|
||||
// Numpad
|
||||
0xffb0: 0x52, // KP_0
|
||||
0xffb1: 0x53, // KP_1
|
||||
0xffb2: 0x54, // KP_2
|
||||
0xffb3: 0x55, // KP_3
|
||||
0xffb4: 0x56, // KP_4
|
||||
0xffb5: 0x57, // KP_5
|
||||
0xffb6: 0x58, // KP_6
|
||||
0xffb7: 0x59, // KP_7
|
||||
0xffb8: 0x5B, // KP_8
|
||||
0xffb9: 0x5C, // KP_9
|
||||
0xffae: 0x41, // KP_Decimal
|
||||
0xffaa: 0x43, // KP_Multiply
|
||||
0xffab: 0x45, // KP_Add
|
||||
0xffad: 0x4E, // KP_Subtract
|
||||
0xffaf: 0x4B, // KP_Divide
|
||||
0xff8d: 0x4C, // KP_Enter
|
||||
0xffbd: 0x51, // KP_Equal
|
||||
}
|
||||
|
||||
var _ InputInjector = (*MacInputInjector)(nil)
|
||||
17
client/vnc/server/input_uinput_freebsd.go
Normal file
17
client/vnc/server/input_uinput_freebsd.go
Normal file
@@ -0,0 +1,17 @@
|
||||
//go:build freebsd
|
||||
|
||||
package server
|
||||
|
||||
import "fmt"
|
||||
|
||||
// UInputInjector is a freebsd placeholder; the linux uinput implementation
|
||||
// uses Linux-only ioctls (UI_DEV_CREATE etc.) and is not portable.
|
||||
type UInputInjector struct {
|
||||
StubInputInjector
|
||||
}
|
||||
|
||||
// NewUInputInjector always returns an error on freebsd so callers fall back
|
||||
// to a stub or platform-appropriate injector.
|
||||
func NewUInputInjector(_, _ int) (*UInputInjector, error) {
|
||||
return nil, fmt.Errorf("uinput not implemented on freebsd")
|
||||
}
|
||||
488
client/vnc/server/input_uinput_linux.go
Normal file
488
client/vnc/server/input_uinput_linux.go
Normal file
@@ -0,0 +1,488 @@
|
||||
//go:build linux
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// /dev/uinput ioctl numbers. Computed from the kernel _IO/_IOW macros so
|
||||
// we don't depend on cgo. UINPUT_IOCTL_BASE = 'U' = 0x55.
|
||||
const (
|
||||
uiDevCreate = 0x5501
|
||||
uiDevDestroy = 0x5502
|
||||
// _IOW('U', 3, struct uinput_setup); uinput_setup is 92 bytes on amd64.
|
||||
uiDevSetup = (1 << 30) | (92 << 16) | (0x55 << 8) | 3
|
||||
uiSetEvBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 100
|
||||
uiSetKeyBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 101
|
||||
uiSetAbsBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 103
|
||||
uinputAbsSize = 64 // legacy struct uses absmin/absmax/absfuzz/absflat[64].
|
||||
)
|
||||
|
||||
// Linux input event types and key codes (linux/input-event-codes.h).
|
||||
const (
|
||||
evSyn = 0x00
|
||||
evKey = 0x01
|
||||
evAbs = 0x03
|
||||
evRep = 0x14
|
||||
|
||||
synReport = 0
|
||||
|
||||
absX = 0x00
|
||||
absY = 0x01
|
||||
|
||||
btnLeft = 0x110
|
||||
btnRight = 0x111
|
||||
btnMiddle = 0x112
|
||||
btnSide = 0x113 // mouse-back (X1)
|
||||
btnExtra = 0x114 // mouse-forward (X2)
|
||||
)
|
||||
|
||||
// inputEvent matches struct input_event for x86_64 (timeval is 16 bytes).
|
||||
// Total size 24 bytes; Go's natural alignment matches the kernel layout.
|
||||
type inputEvent struct {
|
||||
TvSec int64
|
||||
TvUsec int64
|
||||
Type uint16
|
||||
Code uint16
|
||||
Value int32
|
||||
}
|
||||
|
||||
// UInputInjector synthesizes keyboard and mouse events via /dev/uinput.
|
||||
// Used as a fallback when X11 isn't running, e.g. at the kernel console
|
||||
// or pre-login screen on a server without X. Requires root or
|
||||
// CAP_SYS_ADMIN, which the netbird service has.
|
||||
type UInputInjector struct {
|
||||
mu sync.Mutex
|
||||
fd int
|
||||
closeOnce sync.Once
|
||||
keysymToKey map[uint32]uint16
|
||||
prevButtons uint16
|
||||
screenW int
|
||||
screenH int
|
||||
}
|
||||
|
||||
// NewUInputInjector opens /dev/uinput and registers a virtual keyboard +
|
||||
// absolute pointer device sized to (w, h). The dimensions are needed
|
||||
// because uinput's ABS axes don't autoscale; we always send absolute
|
||||
// coordinates and let the kernel route them to the right monitor.
|
||||
func NewUInputInjector(w, h int) (*UInputInjector, error) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil, fmt.Errorf("invalid screen size: %dx%d", w, h)
|
||||
}
|
||||
fd, err := unix.Open("/dev/uinput", unix.O_WRONLY|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open /dev/uinput: %w", err)
|
||||
}
|
||||
|
||||
if err := setBit(fd, uiSetEvBit, evKey); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err := setBit(fd, uiSetEvBit, evAbs); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err := setBit(fd, uiSetEvBit, evSyn); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
// Advertise key auto-repeat so the kernel input core repeats held
|
||||
// keys at the configured rate (default ~250 ms delay, ~33 ms period).
|
||||
// Without this, holding Backspace etc. only deletes one character.
|
||||
if err := setBit(fd, uiSetEvBit, evRep); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keymap := buildUInputKeymap()
|
||||
for _, key := range keymap {
|
||||
if err := setBit(fd, uiSetKeyBit, uint32(key)); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("UI_SET_KEYBIT %d: %w", key, err)
|
||||
}
|
||||
}
|
||||
for _, btn := range []uint16{btnLeft, btnRight, btnMiddle, btnSide, btnExtra} {
|
||||
if err := setBit(fd, uiSetKeyBit, uint32(btn)); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("UI_SET_KEYBIT btn %d: %w", btn, err)
|
||||
}
|
||||
}
|
||||
if err := setBit(fd, uiSetAbsBit, absX); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err := setBit(fd, uiSetAbsBit, absY); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := writeUInputUserDev(fd, w, h); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), uiDevCreate, 0); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("UI_DEV_CREATE: %v", e)
|
||||
}
|
||||
// Give udev a moment to settle before sending events.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
inj := &UInputInjector{
|
||||
fd: fd,
|
||||
keysymToKey: keymapByKeysym(keymap),
|
||||
screenW: w,
|
||||
screenH: h,
|
||||
}
|
||||
log.Infof("uinput injector ready: %dx%d, %d keys", w, h, len(inj.keysymToKey))
|
||||
return inj, nil
|
||||
}
|
||||
|
||||
func setBit(fd int, op uintptr, code uint32) error {
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), op, uintptr(code)); e != 0 {
|
||||
return fmt.Errorf("ioctl 0x%x %d: %v", op, code, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUInputUserDev uses the legacy uinput_user_dev path (write the
|
||||
// whole struct then UI_DEV_CREATE) which is universally supported on
|
||||
// older and current kernels alike. uinput_user_dev is name(80) + id(8) +
|
||||
// ff_effects_max(4) + absmax/absmin/absfuzz/absflat[64] = 92 + 4*64*4 =
|
||||
// 1116 bytes total.
|
||||
func writeUInputUserDev(fd, w, h int) error {
|
||||
const sz = 80 + 8 + 4 + uinputAbsSize*4*4
|
||||
buf := make([]byte, sz)
|
||||
copy(buf[0:80], []byte("netbird-vnc-uinput"))
|
||||
// id: BUS_VIRTUAL=0x06, vendor=0x0001, product=0x0001, version=1.
|
||||
binary.LittleEndian.PutUint16(buf[80:82], 0x06)
|
||||
binary.LittleEndian.PutUint16(buf[82:84], 0x0001)
|
||||
binary.LittleEndian.PutUint16(buf[84:86], 0x0001)
|
||||
binary.LittleEndian.PutUint16(buf[86:88], 0x0001)
|
||||
// ff_effects_max(4) at 88..92 stays zero.
|
||||
// absmax[64] at 92..348: set absX/absY.
|
||||
absmaxOff := 80 + 8 + 4
|
||||
absminOff := absmaxOff + uinputAbsSize*4
|
||||
binary.LittleEndian.PutUint32(buf[absmaxOff+absX*4:], uint32(w-1))
|
||||
binary.LittleEndian.PutUint32(buf[absmaxOff+absY*4:], uint32(h-1))
|
||||
binary.LittleEndian.PutUint32(buf[absminOff+absX*4:], 0)
|
||||
binary.LittleEndian.PutUint32(buf[absminOff+absY*4:], 0)
|
||||
if _, err := unix.Write(fd, buf); err != nil {
|
||||
return fmt.Errorf("write uinput_user_dev: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emit writes a single input_event to the device. Caller-locked.
|
||||
func (u *UInputInjector) emit(typ, code uint16, value int32) error {
|
||||
ev := inputEvent{Type: typ, Code: code, Value: value}
|
||||
buf := (*[unsafe.Sizeof(inputEvent{})]byte)(unsafe.Pointer(&ev))[:]
|
||||
_, err := unix.Write(u.fd, buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *UInputInjector) sync() {
|
||||
_ = u.emit(evSyn, synReport, 0)
|
||||
}
|
||||
|
||||
// InjectKey synthesizes a press or release for the given X11 keysym.
|
||||
func (u *UInputInjector) InjectKey(keysym uint32, down bool) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
code, ok := u.keysymToKey[keysym]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
u.emitKeyCode(code, down)
|
||||
}
|
||||
|
||||
// InjectKeyScancode injects a press or release using the QEMU scancode.
|
||||
// uinput speaks Linux KEY_* codes natively, so we map QEMU scancode →
|
||||
// KEY_* via qemuToLinuxKey. On miss (scancode we don't have a mapping
|
||||
// for) we fall back to the keysym path, which is exactly the legacy
|
||||
// behaviour.
|
||||
func (u *UInputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) {
|
||||
code := qemuScancodeToLinuxKey(scancode)
|
||||
if code == 0 {
|
||||
u.InjectKey(keysym, down)
|
||||
return
|
||||
}
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
u.emitKeyCode(uint16(code), down)
|
||||
}
|
||||
|
||||
// emitKeyCode emits one key down/up event plus a sync. Caller holds u.mu.
|
||||
func (u *UInputInjector) emitKeyCode(code uint16, down bool) {
|
||||
value := int32(0)
|
||||
if down {
|
||||
value = 1
|
||||
}
|
||||
if err := u.emit(evKey, code, value); err != nil {
|
||||
log.Tracef("uinput emit key: %v", err)
|
||||
return
|
||||
}
|
||||
u.sync()
|
||||
}
|
||||
|
||||
// InjectPointer moves the absolute pointer and presses/releases buttons
|
||||
// based on the RFB button mask delta against the previous mask.
|
||||
func (u *UInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if serverW <= 1 || serverH <= 1 {
|
||||
return
|
||||
}
|
||||
absXVal := int32(x * (u.screenW - 1) / (serverW - 1))
|
||||
absYVal := int32(y * (u.screenH - 1) / (serverH - 1))
|
||||
_ = u.emit(evAbs, absX, absXVal)
|
||||
_ = u.emit(evAbs, absY, absYVal)
|
||||
|
||||
type btnMap struct {
|
||||
bit uint16
|
||||
key uint16
|
||||
}
|
||||
for _, b := range []btnMap{
|
||||
{0x01, btnLeft},
|
||||
{0x02, btnMiddle},
|
||||
{0x04, btnRight},
|
||||
{1 << 7, btnSide},
|
||||
{1 << 8, btnExtra},
|
||||
} {
|
||||
pressed := buttonMask&b.bit != 0
|
||||
was := u.prevButtons&b.bit != 0
|
||||
if pressed && !was {
|
||||
_ = u.emit(evKey, b.key, 1)
|
||||
} else if !pressed && was {
|
||||
_ = u.emit(evKey, b.key, 0)
|
||||
}
|
||||
}
|
||||
u.prevButtons = buttonMask
|
||||
u.sync()
|
||||
}
|
||||
|
||||
// SetClipboard is a no-op on the framebuffer console: there is no system
|
||||
// clipboard daemon. Use TypeText (Paste button) to deliver host text.
|
||||
func (u *UInputInjector) SetClipboard(_ string) {
|
||||
// no system clipboard daemon on framebuffer console
|
||||
}
|
||||
|
||||
// GetClipboard returns empty: no clipboard outside X11/Wayland.
|
||||
func (u *UInputInjector) GetClipboard() string { return "" }
|
||||
|
||||
// TypeText synthesizes the given UTF-8 text as keystrokes. Only ASCII
|
||||
// printable characters and newline are typed; other runes are skipped.
|
||||
// This drives the "paste" button: with no console clipboard available,
|
||||
// keystroke-by-keystroke entry is the only way to deliver a password to
|
||||
// a TTY login prompt.
|
||||
func (u *UInputInjector) TypeText(text string) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
const maxChars = 4096
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= maxChars {
|
||||
break
|
||||
}
|
||||
count++
|
||||
code, shift, ok := keyForRune(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if shift {
|
||||
_ = u.emit(evKey, keyLeftShift, 1)
|
||||
}
|
||||
_ = u.emit(evKey, code, 1)
|
||||
_ = u.emit(evKey, code, 0)
|
||||
if shift {
|
||||
_ = u.emit(evKey, keyLeftShift, 0)
|
||||
}
|
||||
u.sync()
|
||||
}
|
||||
}
|
||||
|
||||
// Close destroys the virtual uinput device and closes the file descriptor.
|
||||
func (u *UInputInjector) Close() {
|
||||
u.closeOnce.Do(func() {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if u.fd >= 0 {
|
||||
_, _, _ = unix.Syscall(unix.SYS_IOCTL, uintptr(u.fd), uiDevDestroy, 0)
|
||||
_ = unix.Close(u.fd)
|
||||
u.fd = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Linux KEY_* codes live in scancodes.go (shared with the QEMU scancode
|
||||
// path). Don't duplicate them here.
|
||||
|
||||
// buildUInputKeymap returns every linux KEY_ code we want the virtual
|
||||
// device to advertise during UI_SET_KEYBIT. Order doesn't matter.
|
||||
func buildUInputKeymap() []uint16 {
|
||||
out := make([]uint16, 0, 128)
|
||||
// Letters: KEY_A=30, KEY_B=48, etc; not a clean range. The kernel's
|
||||
// row-by-row layout is qwertyuiop / asdfghjkl / zxcvbnm.
|
||||
letters := []uint16{
|
||||
30, 48, 46, 32, 18, 33, 34, 35, 23, 36, 37, 38, 50, // a..m
|
||||
49, 24, 25, 16, 19, 31, 20, 22, 47, 17, 45, 21, 44, // n..z
|
||||
}
|
||||
out = append(out, letters...)
|
||||
// Top-row digits: KEY_1..KEY_0 = 2..11.
|
||||
for i := uint16(2); i <= 11; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
// Function keys F1..F12 = 59..68 + 87, 88. We only register F1..F12
|
||||
// which the kernel header enumerates as a contiguous block.
|
||||
for i := uint16(59); i <= 68; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
out = append(out, 87, 88)
|
||||
out = append(out, []uint16{
|
||||
keyEsc, keyMinus, keyEqual, keyBackspace, keyTab, keyEnter,
|
||||
keyLeftCtrl, keyRightCtrl, keyLeftShift, keyRightShift,
|
||||
keyLeftAlt, keyRightAlt, keyLeftMeta, keyRightMeta,
|
||||
keySpace, keyCapsLock,
|
||||
keyLeftBracket, keyRightBracket, keyBackslash,
|
||||
keySemicolon, keyApostrophe, keyGrave,
|
||||
keyComma, keyDot, keySlash,
|
||||
keyHome, keyEnd, keyPageUp, keyPageDown,
|
||||
keyUp, keyDown, keyLeft, keyRight,
|
||||
keyInsert, keyDelete,
|
||||
}...)
|
||||
return out
|
||||
}
|
||||
|
||||
// keymapByKeysym maps X11 keysyms (the values our session receives over
|
||||
// RFB) onto Linux KEY_ codes. Shifted ASCII keysyms (uppercase letters,
|
||||
// "!@#..." etc.) map to the same scan code as their unshifted twin: the
|
||||
// client also sends a separate Shift keysym (0xffe1), so the kernel
|
||||
// composes the final character from the held modifier + scan code.
|
||||
func keymapByKeysym(_ []uint16) map[uint32]uint16 {
|
||||
letters := map[rune]uint16{
|
||||
'a': 30, 'b': 48, 'c': 46, 'd': 32, 'e': 18, 'f': 33, 'g': 34,
|
||||
'h': 35, 'i': 23, 'j': 36, 'k': 37, 'l': 38, 'm': 50,
|
||||
'n': 49, 'o': 24, 'p': 25, 'q': 16, 'r': 19, 's': 31, 't': 20,
|
||||
'u': 22, 'v': 47, 'w': 17, 'x': 45, 'y': 21, 'z': 44,
|
||||
}
|
||||
m := map[uint32]uint16{
|
||||
// Digits.
|
||||
'0': 11, '1': 2, '2': 3, '3': 4, '4': 5, '5': 6, '6': 7,
|
||||
'7': 8, '8': 9, '9': 10,
|
||||
// Shifted digits (US layout).
|
||||
')': 11, '!': 2, '@': 3, '#': 4, '$': 5, '%': 6, '^': 7,
|
||||
'&': 8, '*': 9, '(': 10,
|
||||
// Punctuation (US layout) and shifted twins.
|
||||
' ': keySpace,
|
||||
'-': keyMinus, '_': keyMinus,
|
||||
'=': keyEqual, '+': keyEqual,
|
||||
'[': keyLeftBracket, '{': keyLeftBracket,
|
||||
']': keyRightBracket, '}': keyRightBracket,
|
||||
'\\': keyBackslash, '|': keyBackslash,
|
||||
';': keySemicolon, ':': keySemicolon,
|
||||
'\'': keyApostrophe, '"': keyApostrophe,
|
||||
'`': keyGrave, '~': keyGrave,
|
||||
',': keyComma, '<': keyComma,
|
||||
'.': keyDot, '>': keyDot,
|
||||
'/': keySlash, '?': keySlash,
|
||||
// Special keys (X11 keysyms).
|
||||
0xff08: keyBackspace, 0xff09: keyTab, 0xff0d: keyEnter,
|
||||
0xff1b: keyEsc, 0xffff: keyDelete,
|
||||
0xff50: keyHome, 0xff57: keyEnd,
|
||||
0xff51: keyLeft, 0xff52: keyUp, 0xff53: keyRight, 0xff54: keyDown,
|
||||
0xff55: keyPageUp, 0xff56: keyPageDown, 0xff63: keyInsert,
|
||||
0xffe1: keyLeftShift, 0xffe2: keyRightShift,
|
||||
0xffe3: keyLeftCtrl, 0xffe4: keyRightCtrl,
|
||||
0xffe9: keyLeftAlt, 0xffea: keyRightAlt,
|
||||
0xffeb: keyLeftMeta, 0xffec: keyRightMeta,
|
||||
}
|
||||
// Letters: register both lowercase and uppercase keysyms onto the same
|
||||
// KEY_ code. The client sends Shift separately for uppercase.
|
||||
for r, code := range letters {
|
||||
m[uint32(r)] = code
|
||||
m[uint32(r-'a'+'A')] = code
|
||||
}
|
||||
// Function keys F1..F12 (X11 keysyms 0xffbe..0xffc9 → KEY_F1..KEY_F12).
|
||||
xF := uint32(0xffbe)
|
||||
codes := []uint16{59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88}
|
||||
for i, c := range codes {
|
||||
m[xF+uint32(i)] = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyForRune maps a printable rune to (keycode, needsShift). Used by
|
||||
// TypeText to synthesize keystrokes for a paste payload.
|
||||
func keyForRune(r rune) (uint16, bool, bool) {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
m := map[rune]uint16{
|
||||
'a': 30, 'b': 48, 'c': 46, 'd': 32, 'e': 18, 'f': 33, 'g': 34,
|
||||
'h': 35, 'i': 23, 'j': 36, 'k': 37, 'l': 38, 'm': 50,
|
||||
'n': 49, 'o': 24, 'p': 25, 'q': 16, 'r': 19, 's': 31, 't': 20,
|
||||
'u': 22, 'v': 47, 'w': 17, 'x': 45, 'y': 21, 'z': 44,
|
||||
}
|
||||
return m[r], false, true
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
c, _, ok := keyForRune(unicode.ToLower(r))
|
||||
return c, true, ok
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
nums := []uint16{11, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
idx := int(r - '0')
|
||||
if idx < 0 || idx >= len(nums) { //nolint:gosec // explicit bound disarms G602
|
||||
return 0, false, false
|
||||
}
|
||||
return nums[idx], false, true
|
||||
}
|
||||
if r == '\n' || r == '\r' {
|
||||
return keyEnter, false, true
|
||||
}
|
||||
if k, ok := punctUnshifted[r]; ok {
|
||||
return k, false, true
|
||||
}
|
||||
if k, ok := punctShifted[r]; ok {
|
||||
return k, true, true
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// punctUnshifted maps ASCII punctuation that needs no Shift to its uinput
|
||||
// KEY_* code. Split out of keyForRune's switch to keep the function's
|
||||
// cognitive complexity below Sonar's threshold.
|
||||
var punctUnshifted = map[rune]uint16{
|
||||
' ': keySpace,
|
||||
'\t': keyTab,
|
||||
'-': keyMinus,
|
||||
'=': keyEqual,
|
||||
'[': keyLeftBracket,
|
||||
']': keyRightBracket,
|
||||
'\\': keyBackslash,
|
||||
';': keySemicolon,
|
||||
'\'': keyApostrophe,
|
||||
'`': keyGrave,
|
||||
',': keyComma,
|
||||
'.': keyDot,
|
||||
'/': keySlash,
|
||||
}
|
||||
|
||||
// punctShifted maps ASCII punctuation that requires Shift to its base KEY_*
|
||||
// code; the caller adds the shift modifier itself.
|
||||
var punctShifted = map[rune]uint16{
|
||||
'!': 2, '@': 3, '#': 4, '$': 5, '%': 6, '^': 7, '&': 8, '*': 9,
|
||||
'(': 10, ')': 11,
|
||||
'_': keyMinus, '+': keyEqual,
|
||||
'{': keyLeftBracket, '}': keyRightBracket, '|': keyBackslash,
|
||||
':': keySemicolon, '"': keyApostrophe, '~': keyGrave,
|
||||
'<': keyComma, '>': keyDot, '?': keySlash,
|
||||
}
|
||||
|
||||
var _ InputInjector = (*UInputInjector)(nil)
|
||||
599
client/vnc/server/input_windows.go
Normal file
599
client/vnc/server/input_windows.go
Normal file
@@ -0,0 +1,599 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
procOpenEventW = kernel32.NewProc("OpenEventW")
|
||||
procSendInput = user32.NewProc("SendInput")
|
||||
procVkKeyScanA = user32.NewProc("VkKeyScanA")
|
||||
)
|
||||
|
||||
const eventModifyState = 0x0002
|
||||
|
||||
const (
|
||||
inputMouse = 0
|
||||
inputKeyboard = 1
|
||||
|
||||
mouseeventfMove = 0x0001
|
||||
mouseeventfLeftDown = 0x0002
|
||||
mouseeventfLeftUp = 0x0004
|
||||
mouseeventfRightDown = 0x0008
|
||||
mouseeventfRightUp = 0x0010
|
||||
mouseeventfMiddleDown = 0x0020
|
||||
mouseeventfMiddleUp = 0x0040
|
||||
mouseeventfXDown = 0x0080
|
||||
mouseeventfXUp = 0x0100
|
||||
mouseeventfWheel = 0x0800
|
||||
mouseeventfAbsolute = 0x8000
|
||||
|
||||
// X-button identifiers carried in the dwData field of MOUSEEVENTF_X*
|
||||
// events. XBUTTON1 is mouse-back, XBUTTON2 is mouse-forward.
|
||||
xButton1 = 0x0001
|
||||
xButton2 = 0x0002
|
||||
|
||||
wheelDelta = 120
|
||||
|
||||
keyeventfExtendedKey = 0x0001
|
||||
keyeventfKeyUp = 0x0002
|
||||
keyeventfUnicode = 0x0004
|
||||
keyeventfScanCode = 0x0008
|
||||
)
|
||||
|
||||
// maxTypedClipboardChars caps the number of characters we will synthesize as
|
||||
// keystrokes when falling back on the Winlogon desktop. Passwords are short;
|
||||
// a huge clipboard getting typed into the login screen would be surprising.
|
||||
const maxTypedClipboardChars = 4096
|
||||
|
||||
type mouseInput struct {
|
||||
Dx int32
|
||||
Dy int32
|
||||
MouseData uint32
|
||||
DwFlags uint32
|
||||
Time uint32
|
||||
DwExtraInfo uintptr
|
||||
}
|
||||
|
||||
type keybdInput struct {
|
||||
WVk uint16
|
||||
WScan uint16
|
||||
DwFlags uint32
|
||||
Time uint32
|
||||
DwExtraInfo uintptr
|
||||
_ [8]byte
|
||||
}
|
||||
|
||||
type inputUnion [32]byte
|
||||
|
||||
type winInput struct {
|
||||
Type uint32
|
||||
_ [4]byte
|
||||
Data inputUnion
|
||||
}
|
||||
|
||||
func sendMouseInput(flags uint32, dx, dy int32, mouseData uint32) {
|
||||
mi := mouseInput{
|
||||
Dx: dx,
|
||||
Dy: dy,
|
||||
MouseData: mouseData,
|
||||
DwFlags: flags,
|
||||
}
|
||||
inp := winInput{Type: inputMouse}
|
||||
copy(inp.Data[:], (*[unsafe.Sizeof(mi)]byte)(unsafe.Pointer(&mi))[:])
|
||||
r, _, err := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
|
||||
if r == 0 {
|
||||
log.Tracef("SendInput(mouse flags=0x%x): %v", flags, err)
|
||||
}
|
||||
}
|
||||
|
||||
func sendKeyInput(vk uint16, scanCode uint16, flags uint32) {
|
||||
ki := keybdInput{
|
||||
WVk: vk,
|
||||
WScan: scanCode,
|
||||
DwFlags: flags,
|
||||
}
|
||||
inp := winInput{Type: inputKeyboard}
|
||||
copy(inp.Data[:], (*[unsafe.Sizeof(ki)]byte)(unsafe.Pointer(&ki))[:])
|
||||
r, _, err := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
|
||||
if r == 0 {
|
||||
log.Tracef("SendInput(key vk=0x%x): %v", vk, err)
|
||||
}
|
||||
}
|
||||
|
||||
const sasEventName = `Global\NetBirdVNC_SAS`
|
||||
|
||||
type inputCmd struct {
|
||||
isKey bool
|
||||
isScancode bool
|
||||
isClipboard bool
|
||||
isType bool
|
||||
keysym uint32
|
||||
scancode uint32
|
||||
down bool
|
||||
buttonMask uint16
|
||||
x, y int
|
||||
serverW int
|
||||
serverH int
|
||||
clipText string
|
||||
}
|
||||
|
||||
// WindowsInputInjector delivers input events from a dedicated OS thread that
|
||||
// calls switchToInputDesktop before each injection. SendInput targets the
|
||||
// calling thread's desktop, so the injection thread must be on the same
|
||||
// desktop the user sees.
|
||||
type WindowsInputInjector struct {
|
||||
ch chan inputCmd
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
prevButtonMask uint16
|
||||
// lastQueuedButtonMask is the most recent buttonMask submitted to ch
|
||||
// by InjectPointer. Compared against the incoming sample to decide
|
||||
// whether the new event is move-only (lossy enqueue) or carries a
|
||||
// button/wheel transition (reliable enqueue).
|
||||
lastQueuedButtonMask uint16
|
||||
lastQueuedMaskValid bool
|
||||
queueMu sync.Mutex
|
||||
ctrlDown bool
|
||||
altDown bool
|
||||
}
|
||||
|
||||
// NewWindowsInputInjector creates a desktop-aware input injector.
|
||||
func NewWindowsInputInjector() *WindowsInputInjector {
|
||||
w := &WindowsInputInjector{
|
||||
ch: make(chan inputCmd, 64),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go w.loop()
|
||||
return w
|
||||
}
|
||||
|
||||
// Close stops the injector loop. Safe to call multiple times. Subsequent
|
||||
// Inject*/SetClipboard/TypeText calls become no-ops; we use a separate
|
||||
// signal channel rather than closing ch so late senders can't panic.
|
||||
func (w *WindowsInputInjector) Close() {
|
||||
w.closeOnce.Do(func() {
|
||||
close(w.closed)
|
||||
})
|
||||
}
|
||||
|
||||
// tryEnqueue posts a command unless the injector is closed or the channel is
|
||||
// full. Non-blocking so callers (RFB read loop) never stall.
|
||||
func (w *WindowsInputInjector) tryEnqueue(cmd inputCmd) {
|
||||
select {
|
||||
case <-w.closed:
|
||||
case w.ch <- cmd:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// enqueueReliable posts a command and blocks until it's accepted or the
|
||||
// injector closes. Used for edge-triggered events (button/wheel) where a
|
||||
// drop would desynchronize prevButtonMask in dispatch().
|
||||
func (w *WindowsInputInjector) enqueueReliable(cmd inputCmd) {
|
||||
select {
|
||||
case <-w.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case w.ch <- cmd:
|
||||
case <-w.closed:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) loop() {
|
||||
runtime.LockOSThread()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.closed:
|
||||
return
|
||||
case cmd := <-w.ch:
|
||||
w.dispatch(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) dispatch(cmd inputCmd) {
|
||||
// Switch to the current input desktop so SendInput and the clipboard
|
||||
// API target the desktop the user sees. The returned name tells us
|
||||
// whether we are on the secure Winlogon desktop.
|
||||
_, _ = switchToInputDesktop()
|
||||
|
||||
switch {
|
||||
case cmd.isClipboard:
|
||||
w.doSetClipboard(cmd.clipText)
|
||||
case cmd.isType:
|
||||
w.typeUnicodeText(cmd.clipText)
|
||||
case cmd.isScancode:
|
||||
w.doInjectKeyScancode(cmd.scancode, cmd.keysym, cmd.down)
|
||||
case cmd.isKey:
|
||||
w.doInjectKey(cmd.keysym, cmd.down)
|
||||
default:
|
||||
w.doInjectPointer(cmd.buttonMask, cmd.x, cmd.y, cmd.serverW, cmd.serverH)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectKey queues a key event for injection on the input desktop thread.
|
||||
func (w *WindowsInputInjector) InjectKey(keysym uint32, down bool) {
|
||||
w.tryEnqueue(inputCmd{isKey: true, keysym: keysym, down: down})
|
||||
}
|
||||
|
||||
// InjectKeyScancode queues a raw-scancode key event. PC AT Set 1 maps
|
||||
// directly onto what SendInput's KEYEVENTF_SCANCODE flag wants, so the
|
||||
// only translation is splitting the optional 0xE0 prefix off into the
|
||||
// KEYEVENTF_EXTENDEDKEY flag. keysym is the client-provided fallback we
|
||||
// reach for if the scancode is zero.
|
||||
func (w *WindowsInputInjector) InjectKeyScancode(scancode uint32, keysym uint32, down bool) {
|
||||
if scancode == 0 {
|
||||
w.InjectKey(keysym, down)
|
||||
return
|
||||
}
|
||||
w.tryEnqueue(inputCmd{isScancode: true, scancode: scancode, keysym: keysym, down: down})
|
||||
}
|
||||
|
||||
// InjectPointer queues a pointer event for injection on the input desktop
|
||||
// thread. Move-only updates use lossy enqueue (next sample carries fresher
|
||||
// position anyway), but any sample whose buttonMask differs from the last
|
||||
// queued mask is enqueued reliably so wheel ticks and button transitions
|
||||
// can't be dropped under backpressure.
|
||||
func (w *WindowsInputInjector) InjectPointer(buttonMask uint16, x, y, serverW, serverH int) {
|
||||
cmd := inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH}
|
||||
w.queueMu.Lock()
|
||||
transition := !w.lastQueuedMaskValid || w.lastQueuedButtonMask != buttonMask
|
||||
w.lastQueuedButtonMask = buttonMask
|
||||
w.lastQueuedMaskValid = true
|
||||
w.queueMu.Unlock()
|
||||
if transition {
|
||||
w.enqueueReliable(cmd)
|
||||
return
|
||||
}
|
||||
w.tryEnqueue(cmd)
|
||||
}
|
||||
|
||||
// doInjectKeyScancode injects a key event using the QEMU scancode directly,
|
||||
// bypassing the keysym→VK lookup. Windows accepts PC AT Set 1 scancodes
|
||||
// natively via KEYEVENTF_SCANCODE, so the only work is splitting the
|
||||
// optional 0xE0 prefix off into the EXTENDEDKEY flag and tracking
|
||||
// modifier state for the SAS Ctrl+Alt+Del shortcut.
|
||||
func (w *WindowsInputInjector) doInjectKeyScancode(scancode, keysym uint32, down bool) {
|
||||
switch keysym {
|
||||
case 0xffe3, 0xffe4:
|
||||
w.ctrlDown = down
|
||||
case 0xffe9, 0xffea:
|
||||
w.altDown = down
|
||||
}
|
||||
if (keysym == 0xff9f || keysym == 0xffff) && w.ctrlDown && w.altDown && down {
|
||||
signalSAS()
|
||||
return
|
||||
}
|
||||
flags := uint32(keyeventfScanCode)
|
||||
if !down {
|
||||
flags |= keyeventfKeyUp
|
||||
}
|
||||
if qemuScancodeIsExtended(scancode) {
|
||||
flags |= keyeventfExtendedKey
|
||||
}
|
||||
sendKeyInput(0, qemuScancodeLowByte(scancode), flags)
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) doInjectKey(keysym uint32, down bool) {
|
||||
switch keysym {
|
||||
case 0xffe3, 0xffe4:
|
||||
w.ctrlDown = down
|
||||
case 0xffe9, 0xffea:
|
||||
w.altDown = down
|
||||
}
|
||||
|
||||
if (keysym == 0xff9f || keysym == 0xffff) && w.ctrlDown && w.altDown && down {
|
||||
signalSAS()
|
||||
return
|
||||
}
|
||||
|
||||
vk, _, extended := keysym2VK(keysym)
|
||||
if vk == 0 {
|
||||
return
|
||||
}
|
||||
var flags uint32
|
||||
if !down {
|
||||
flags |= keyeventfKeyUp
|
||||
}
|
||||
if extended {
|
||||
flags |= keyeventfExtendedKey
|
||||
}
|
||||
sendKeyInput(vk, 0, flags)
|
||||
}
|
||||
|
||||
// signalSAS signals the SAS named event. A listener in Session 0
|
||||
// (startSASListener) calls SendSAS to trigger the Secure Attention Sequence.
|
||||
func signalSAS() {
|
||||
namePtr, err := windows.UTF16PtrFromString(sasEventName)
|
||||
if err != nil {
|
||||
log.Warnf("SAS UTF16: %v", err)
|
||||
return
|
||||
}
|
||||
h, _, lerr := procOpenEventW.Call(
|
||||
uintptr(eventModifyState),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
)
|
||||
if h == 0 {
|
||||
log.Warnf("OpenEvent(%s): %v", sasEventName, lerr)
|
||||
return
|
||||
}
|
||||
ev := windows.Handle(h)
|
||||
defer func() { _ = windows.CloseHandle(ev) }()
|
||||
if err := windows.SetEvent(ev); err != nil {
|
||||
log.Warnf("SetEvent SAS: %v", err)
|
||||
} else {
|
||||
log.Info("SAS event signaled")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) doInjectPointer(buttonMask uint16, x, y, serverW, serverH int) {
|
||||
if serverW == 0 || serverH == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
absX := int32(x * 65535 / serverW)
|
||||
absY := int32(y * 65535 / serverH)
|
||||
|
||||
sendMouseInput(mouseeventfMove|mouseeventfAbsolute, absX, absY, 0)
|
||||
|
||||
changed := buttonMask ^ w.prevButtonMask
|
||||
w.prevButtonMask = buttonMask
|
||||
|
||||
type btnMap struct {
|
||||
bit uint16
|
||||
down uint32
|
||||
up uint32
|
||||
}
|
||||
buttons := [...]btnMap{
|
||||
{0x01, mouseeventfLeftDown, mouseeventfLeftUp},
|
||||
{0x02, mouseeventfMiddleDown, mouseeventfMiddleUp},
|
||||
{0x04, mouseeventfRightDown, mouseeventfRightUp},
|
||||
}
|
||||
for _, b := range buttons {
|
||||
if changed&b.bit == 0 {
|
||||
continue
|
||||
}
|
||||
var flags uint32
|
||||
if buttonMask&b.bit != 0 {
|
||||
flags = b.down
|
||||
} else {
|
||||
flags = b.up
|
||||
}
|
||||
sendMouseInput(flags|mouseeventfAbsolute, absX, absY, 0)
|
||||
}
|
||||
|
||||
negWheelDelta := ^uint32(wheelDelta - 1)
|
||||
if changed&0x08 != 0 && buttonMask&0x08 != 0 {
|
||||
sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, wheelDelta)
|
||||
}
|
||||
if changed&0x10 != 0 && buttonMask&0x10 != 0 {
|
||||
sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, negWheelDelta)
|
||||
}
|
||||
|
||||
// XBUTTON1/back at bit 7, XBUTTON2/forward at bit 8. SendInput
|
||||
// MOUSEEVENTF_X{DOWN,UP} carries the X button number in dwData.
|
||||
xbuttons := [...]struct {
|
||||
bit uint16
|
||||
data uint32
|
||||
}{
|
||||
{1 << 7, xButton1},
|
||||
{1 << 8, xButton2},
|
||||
}
|
||||
for _, b := range xbuttons {
|
||||
if changed&b.bit == 0 {
|
||||
continue
|
||||
}
|
||||
var flags uint32 = mouseeventfXUp
|
||||
if buttonMask&b.bit != 0 {
|
||||
flags = mouseeventfXDown
|
||||
}
|
||||
sendMouseInput(flags|mouseeventfAbsolute, absX, absY, b.data)
|
||||
}
|
||||
}
|
||||
|
||||
// keysym2VK converts an X11 keysym to a Windows virtual key code.
|
||||
func keysym2VK(keysym uint32) (vk uint16, scan uint16, extended bool) {
|
||||
if keysym >= 0x20 && keysym <= 0x7e {
|
||||
r, _, _ := procVkKeyScanA.Call(uintptr(keysym))
|
||||
vk = uint16(r & 0xff)
|
||||
return
|
||||
}
|
||||
|
||||
if keysym >= 0xffbe && keysym <= 0xffc9 {
|
||||
vk = uint16(0x70 + keysym - 0xffbe)
|
||||
return
|
||||
}
|
||||
|
||||
switch keysym {
|
||||
case 0xff08:
|
||||
vk = 0x08 // Backspace
|
||||
case 0xff09:
|
||||
vk = 0x09 // Tab
|
||||
case 0xff0d:
|
||||
vk = 0x0d // Return
|
||||
case 0xff1b:
|
||||
vk = 0x1b // Escape
|
||||
case 0xff63:
|
||||
vk, extended = 0x2d, true // Insert
|
||||
case 0xff9f, 0xffff:
|
||||
vk, extended = 0x2e, true // Delete
|
||||
case 0xff50:
|
||||
vk, extended = 0x24, true // Home
|
||||
case 0xff57:
|
||||
vk, extended = 0x23, true // End
|
||||
case 0xff55:
|
||||
vk, extended = 0x21, true // PageUp
|
||||
case 0xff56:
|
||||
vk, extended = 0x22, true // PageDown
|
||||
case 0xff51:
|
||||
vk, extended = 0x25, true // Left
|
||||
case 0xff52:
|
||||
vk, extended = 0x26, true // Up
|
||||
case 0xff53:
|
||||
vk, extended = 0x27, true // Right
|
||||
case 0xff54:
|
||||
vk, extended = 0x28, true // Down
|
||||
case 0xffe1, 0xffe2:
|
||||
vk = 0x10 // Shift
|
||||
case 0xffe3, 0xffe4:
|
||||
vk = 0x11 // Control
|
||||
case 0xffe9, 0xffea:
|
||||
vk = 0x12 // Alt
|
||||
case 0xffe5:
|
||||
vk = 0x14 // CapsLock
|
||||
case 0xffe7, 0xffeb:
|
||||
vk, extended = 0x5B, true // Meta_L / Super_L -> Left Windows
|
||||
case 0xffe8, 0xffec:
|
||||
vk, extended = 0x5C, true // Meta_R / Super_R -> Right Windows
|
||||
case 0xff61:
|
||||
vk = 0x2c // PrintScreen
|
||||
case 0xff13:
|
||||
vk = 0x13 // Pause
|
||||
case 0xff14:
|
||||
vk = 0x91 // ScrollLock
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
procOpenClipboard = user32.NewProc("OpenClipboard")
|
||||
procCloseClipboard = user32.NewProc("CloseClipboard")
|
||||
procEmptyClipboard = user32.NewProc("EmptyClipboard")
|
||||
procSetClipboardData = user32.NewProc("SetClipboardData")
|
||||
procGetClipboardData = user32.NewProc("GetClipboardData")
|
||||
procIsClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
|
||||
|
||||
procGlobalAlloc = kernel32.NewProc("GlobalAlloc")
|
||||
procGlobalLock = kernel32.NewProc("GlobalLock")
|
||||
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
procGlobalFree = kernel32.NewProc("GlobalFree")
|
||||
)
|
||||
|
||||
const (
|
||||
cfUnicodeText = 13
|
||||
gmemMoveable = 0x0002
|
||||
)
|
||||
|
||||
// SetClipboard queues a request to update the Windows clipboard with the
|
||||
// given UTF-8 text. The work runs on the input thread so it follows the
|
||||
// current input desktop. Secure desktops (Winlogon, UAC) have isolated
|
||||
// clipboards we cannot reach, so the call is a no-op there; use TypeText
|
||||
// to enter text into a secure desktop instead.
|
||||
func (w *WindowsInputInjector) SetClipboard(text string) {
|
||||
w.tryEnqueue(inputCmd{isClipboard: true, clipText: text})
|
||||
}
|
||||
|
||||
// TypeText queues a request to synthesize the given text as Unicode
|
||||
// keystrokes on the current input desktop. Targets the secure desktop
|
||||
// when the user is on Winlogon/UAC, where the clipboard is unreachable.
|
||||
func (w *WindowsInputInjector) TypeText(text string) {
|
||||
w.tryEnqueue(inputCmd{isType: true, clipText: text})
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) doSetClipboard(text string) {
|
||||
utf16, err := windows.UTF16FromString(text)
|
||||
if err != nil {
|
||||
log.Tracef("clipboard UTF16 encode: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
size := uintptr(len(utf16) * 2)
|
||||
hMem, _, _ := procGlobalAlloc.Call(gmemMoveable, size)
|
||||
if hMem == 0 {
|
||||
log.Tracef("GlobalAlloc for clipboard: allocation returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
ptr, _, _ := procGlobalLock.Call(hMem)
|
||||
if ptr == 0 {
|
||||
log.Tracef("GlobalLock for clipboard: lock returned nil")
|
||||
_, _, _ = procGlobalFree.Call(hMem)
|
||||
return
|
||||
}
|
||||
copy(unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), len(utf16)), utf16)
|
||||
_, _, _ = procGlobalUnlock.Call(hMem)
|
||||
|
||||
r, _, lerr := procOpenClipboard.Call(0)
|
||||
if r == 0 {
|
||||
log.Tracef("OpenClipboard: %v", lerr)
|
||||
_, _, _ = procGlobalFree.Call(hMem)
|
||||
return
|
||||
}
|
||||
defer logCleanupCall("CloseClipboard", procCloseClipboard)
|
||||
|
||||
_, _, _ = procEmptyClipboard.Call()
|
||||
r, _, lerr = procSetClipboardData.Call(cfUnicodeText, hMem)
|
||||
if r == 0 {
|
||||
log.Tracef("SetClipboardData: %v", lerr)
|
||||
// Ownership only transfers to the OS on success; on failure we
|
||||
// still own hMem and must free it.
|
||||
_, _, _ = procGlobalFree.Call(hMem)
|
||||
}
|
||||
}
|
||||
|
||||
// typeUnicodeText synthesizes the given text as Unicode keystrokes via
|
||||
// SendInput+KEYEVENTF_UNICODE. Used on the Winlogon secure desktop where the
|
||||
// clipboard is isolated: this lets a VNC client paste a password into the
|
||||
// login or credential prompt by sending ClientCutText.
|
||||
func (w *WindowsInputInjector) typeUnicodeText(text string) {
|
||||
utf16, err := windows.UTF16FromString(text)
|
||||
if err != nil {
|
||||
log.Tracef("clipboard UTF16 encode: %v", err)
|
||||
return
|
||||
}
|
||||
if len(utf16) > 0 && utf16[len(utf16)-1] == 0 {
|
||||
utf16 = utf16[:len(utf16)-1]
|
||||
}
|
||||
if len(utf16) > maxTypedClipboardChars {
|
||||
log.Warnf("clipboard paste on Winlogon truncated to %d chars", maxTypedClipboardChars)
|
||||
utf16 = utf16[:maxTypedClipboardChars]
|
||||
}
|
||||
for _, c := range utf16 {
|
||||
sendKeyInput(0, c, keyeventfUnicode)
|
||||
sendKeyInput(0, c, keyeventfUnicode|keyeventfKeyUp)
|
||||
}
|
||||
}
|
||||
|
||||
// GetClipboard reads the Windows clipboard as UTF-8 text.
|
||||
func (w *WindowsInputInjector) GetClipboard() string {
|
||||
r, _, _ := procIsClipboardFormatAvailable.Call(cfUnicodeText)
|
||||
if r == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
r, _, lerr := procOpenClipboard.Call(0)
|
||||
if r == 0 {
|
||||
log.Tracef("OpenClipboard for read: %v", lerr)
|
||||
return ""
|
||||
}
|
||||
defer logCleanupCall("CloseClipboard", procCloseClipboard)
|
||||
|
||||
hData, _, _ := procGetClipboardData.Call(cfUnicodeText)
|
||||
if hData == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ptr, _, _ := procGlobalLock.Call(hData)
|
||||
if ptr == 0 {
|
||||
return ""
|
||||
}
|
||||
defer logCleanupCallArgs("GlobalUnlock", procGlobalUnlock, hData)
|
||||
|
||||
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr)))
|
||||
}
|
||||
|
||||
var _ InputInjector = (*WindowsInputInjector)(nil)
|
||||
|
||||
var _ ScreenCapturer = (*DesktopCapturer)(nil)
|
||||
312
client/vnc/server/input_x11.go
Normal file
312
client/vnc/server/input_x11.go
Normal file
@@ -0,0 +1,312 @@
|
||||
//go:build unix && !darwin && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/jezek/xgb"
|
||||
"github.com/jezek/xgb/xproto"
|
||||
"github.com/jezek/xgb/xtest"
|
||||
)
|
||||
|
||||
// X11InputInjector injects keyboard and mouse events via the XTest extension.
|
||||
type X11InputInjector struct {
|
||||
conn *xgb.Conn
|
||||
root xproto.Window
|
||||
screen *xproto.ScreenInfo
|
||||
display string
|
||||
keysymMap map[uint32]byte
|
||||
lastButtons uint16
|
||||
clipboardTool string
|
||||
clipboardToolName string
|
||||
}
|
||||
|
||||
// NewX11InputInjector connects to the X11 display and initializes XTest.
|
||||
func NewX11InputInjector(display string) (*X11InputInjector, error) {
|
||||
detectX11Display()
|
||||
|
||||
if display == "" {
|
||||
display = os.Getenv(envDisplay)
|
||||
}
|
||||
if display == "" {
|
||||
return nil, fmt.Errorf("DISPLAY not set and no Xorg process found")
|
||||
}
|
||||
|
||||
conn, err := xgb.NewConnDisplay(display)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to X11 display %s: %w", display, err)
|
||||
}
|
||||
|
||||
if err := xtest.Init(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("init XTest extension: %w", err)
|
||||
}
|
||||
|
||||
setup := xproto.Setup(conn)
|
||||
if len(setup.Roots) == 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("no X11 screens")
|
||||
}
|
||||
screen := setup.Roots[0]
|
||||
|
||||
inj := &X11InputInjector{
|
||||
conn: conn,
|
||||
root: screen.Root,
|
||||
screen: &screen,
|
||||
display: display,
|
||||
}
|
||||
inj.cacheKeyboardMapping()
|
||||
inj.resolveClipboardTool()
|
||||
|
||||
log.Infof("X11 input injector ready (display=%s)", display)
|
||||
return inj, nil
|
||||
}
|
||||
|
||||
// InjectKey simulates a key press or release. keysym is an X11 KeySym.
|
||||
func (x *X11InputInjector) InjectKey(keysym uint32, down bool) {
|
||||
keycode := x.keysymToKeycode(keysym)
|
||||
if keycode == 0 {
|
||||
return
|
||||
}
|
||||
x.fakeKeyEvent(keycode, down)
|
||||
}
|
||||
|
||||
// InjectKeyScancode injects using the QEMU scancode by translating to a
|
||||
// Linux KEY_ code and then to an X11 keycode (KEY_* + xkbKeycodeOffset).
|
||||
// On a server running a standard XKB keymap this is layout-independent:
|
||||
// the scancode names the physical key, the server's layout determines the
|
||||
// resulting character. Falls back to the keysym path when the scancode
|
||||
// has no Linux mapping.
|
||||
func (x *X11InputInjector) InjectKeyScancode(scancode, keysym uint32, down bool) {
|
||||
linuxKey := qemuScancodeToLinuxKey(scancode)
|
||||
if linuxKey == 0 {
|
||||
x.InjectKey(keysym, down)
|
||||
return
|
||||
}
|
||||
x.fakeKeyEvent(byte(linuxKey+xkbKeycodeOffset), down)
|
||||
}
|
||||
|
||||
// xkbKeycodeOffset is the per-server constant offset between Linux KEY_*
|
||||
// event codes and the X server's keycode space under XKB. The X protocol
|
||||
// reserves keycodes 0..7 for internal use, so any normal XKB keymap
|
||||
// starts at 8 (KEY_ESC=1 → X keycode 9, KEY_A=30 → X keycode 38, etc.).
|
||||
const xkbKeycodeOffset = 8
|
||||
|
||||
// fakeKeyEvent sends an XTest FakeInput for a press or release.
|
||||
func (x *X11InputInjector) fakeKeyEvent(keycode byte, down bool) {
|
||||
var eventType byte
|
||||
if down {
|
||||
eventType = xproto.KeyPress
|
||||
} else {
|
||||
eventType = xproto.KeyRelease
|
||||
}
|
||||
xtest.FakeInput(x.conn, eventType, keycode, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
|
||||
// InjectPointer simulates mouse movement and button events.
|
||||
func (x *X11InputInjector) InjectPointer(buttonMask uint16, px, py, serverW, serverH int) {
|
||||
if serverW == 0 || serverH == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Scale to actual screen coordinates.
|
||||
screenW := int(x.screen.WidthInPixels)
|
||||
screenH := int(x.screen.HeightInPixels)
|
||||
absX := px * screenW / serverW
|
||||
absY := py * screenH / serverH
|
||||
|
||||
// Move pointer.
|
||||
xtest.FakeInput(x.conn, xproto.MotionNotify, 0, 0, x.root, int16(absX), int16(absY), 0)
|
||||
|
||||
// Handle button events. RFB button mask: bit0=left, bit1=middle, bit2=right,
|
||||
// bit3=scrollUp, bit4=scrollDown. X11 buttons: 1=left, 2=middle, 3=right,
|
||||
// 4=scrollUp, 5=scrollDown.
|
||||
type btnMap struct {
|
||||
rfbBit uint16
|
||||
x11Btn byte
|
||||
}
|
||||
// X11 button numbers: 1=left, 2=middle, 3=right, 4/5=scroll up/down,
|
||||
// 6/7=scroll left/right (skipped), 8=back, 9=forward.
|
||||
buttons := [...]btnMap{
|
||||
{0x01, 1},
|
||||
{0x02, 2},
|
||||
{0x04, 3},
|
||||
{0x08, 4},
|
||||
{0x10, 5},
|
||||
{1 << 7, 8},
|
||||
{1 << 8, 9},
|
||||
}
|
||||
|
||||
for _, b := range buttons {
|
||||
pressed := buttonMask&b.rfbBit != 0
|
||||
wasPressed := x.lastButtons&b.rfbBit != 0
|
||||
if b.x11Btn == 4 || b.x11Btn == 5 {
|
||||
// Scroll: send press+release on each scroll event.
|
||||
if pressed {
|
||||
xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
xtest.FakeInput(x.conn, xproto.ButtonRelease, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
} else {
|
||||
if pressed && !wasPressed {
|
||||
xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
} else if !pressed && wasPressed {
|
||||
xtest.FakeInput(x.conn, xproto.ButtonRelease, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
x.lastButtons = buttonMask
|
||||
}
|
||||
|
||||
// cacheKeyboardMapping fetches the X11 keyboard mapping once and stores it
|
||||
// as a keysym-to-keycode map, avoiding a round-trip per keystroke.
|
||||
func (x *X11InputInjector) cacheKeyboardMapping() {
|
||||
setup := xproto.Setup(x.conn)
|
||||
minKeycode := setup.MinKeycode
|
||||
maxKeycode := setup.MaxKeycode
|
||||
|
||||
reply, err := xproto.GetKeyboardMapping(x.conn, minKeycode,
|
||||
byte(maxKeycode-minKeycode+1)).Reply()
|
||||
if err != nil {
|
||||
log.Debugf("cache keyboard mapping: %v", err)
|
||||
x.keysymMap = make(map[uint32]byte)
|
||||
return
|
||||
}
|
||||
|
||||
m := make(map[uint32]byte, int(maxKeycode-minKeycode+1)*int(reply.KeysymsPerKeycode))
|
||||
keysymsPerKeycode := int(reply.KeysymsPerKeycode)
|
||||
for i := int(minKeycode); i <= int(maxKeycode); i++ {
|
||||
offset := (i - int(minKeycode)) * keysymsPerKeycode
|
||||
for j := 0; j < keysymsPerKeycode; j++ {
|
||||
ks := uint32(reply.Keysyms[offset+j])
|
||||
if ks != 0 {
|
||||
if _, exists := m[ks]; !exists {
|
||||
m[ks] = byte(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x.keysymMap = m
|
||||
}
|
||||
|
||||
// keysymToKeycode looks up a cached keysym-to-keycode mapping.
|
||||
// Returns 0 if the keysym is not mapped.
|
||||
func (x *X11InputInjector) keysymToKeycode(keysym uint32) byte {
|
||||
return x.keysymMap[keysym]
|
||||
}
|
||||
|
||||
// SetClipboard sets the X11 clipboard using xclip or xsel.
|
||||
func (x *X11InputInjector) SetClipboard(text string) {
|
||||
if x.clipboardTool == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if x.clipboardToolName == "xclip" {
|
||||
cmd = exec.Command(x.clipboardTool, "-selection", "clipboard")
|
||||
} else {
|
||||
cmd = exec.Command(x.clipboardTool, "--clipboard", "--input")
|
||||
}
|
||||
cmd.Env = x.clipboardEnv()
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Debugf("set clipboard via %s: %v", x.clipboardToolName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TypeText synthesizes the given text as keystrokes via XTest. Used in
|
||||
// places where the focused application isn't clipboard-aware (e.g. a TTY
|
||||
// login in an X11 session, an SDDM/GDM password field that ignores
|
||||
// XSelection, or a kiosk app), so stuffing the X clipboard and relying on
|
||||
// Ctrl+V would not reach the input.
|
||||
//
|
||||
// Limitation: only ASCII printable characters are typed. Non-ASCII runes
|
||||
// are skipped: a paste workflow for them needs Wayland-aware text input
|
||||
// or layout introspection that this path does not implement.
|
||||
func (x *X11InputInjector) TypeText(text string) {
|
||||
const maxChars = 4096
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= maxChars {
|
||||
break
|
||||
}
|
||||
count++
|
||||
keysym, shift, ok := keysymForASCIIRune(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keycode := x.keysymToKeycode(keysym)
|
||||
if keycode == 0 {
|
||||
continue
|
||||
}
|
||||
var shiftCode byte
|
||||
if shift {
|
||||
shiftCode = x.keysymToKeycode(0xffe1) // Shift_L
|
||||
if shiftCode != 0 {
|
||||
xtest.FakeInput(x.conn, xproto.KeyPress, shiftCode, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
xtest.FakeInput(x.conn, xproto.KeyPress, keycode, 0, x.root, 0, 0, 0)
|
||||
xtest.FakeInput(x.conn, xproto.KeyRelease, keycode, 0, x.root, 0, 0, 0)
|
||||
if shift && shiftCode != 0 {
|
||||
xtest.FakeInput(x.conn, xproto.KeyRelease, shiftCode, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x *X11InputInjector) resolveClipboardTool() {
|
||||
for _, name := range []string{"xclip", "xsel"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil {
|
||||
x.clipboardTool = path
|
||||
x.clipboardToolName = name
|
||||
log.Debugf("clipboard tool resolved to %s", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Debugf("no clipboard tool (xclip/xsel) found, clipboard sync disabled")
|
||||
}
|
||||
|
||||
// GetClipboard reads the X11 clipboard using xclip or xsel.
|
||||
func (x *X11InputInjector) GetClipboard() string {
|
||||
if x.clipboardTool == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if x.clipboardToolName == "xclip" {
|
||||
cmd = exec.Command(x.clipboardTool, "-selection", "clipboard", "-o")
|
||||
} else {
|
||||
cmd = exec.Command(x.clipboardTool, "--clipboard", "--output")
|
||||
}
|
||||
cmd.Env = x.clipboardEnv()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Exit status 1 just means there is no STRING selection set yet,
|
||||
// which is the steady state on a fresh Xvfb session, logging it
|
||||
// every clipboard poll (2s) floods the trace stream.
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (x *X11InputInjector) clipboardEnv() []string {
|
||||
env := []string{envDisplay + "=" + x.display}
|
||||
if auth := os.Getenv(envXAuthority); auth != "" {
|
||||
env = append(env, envXAuthority+"="+auth)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Close releases X11 resources.
|
||||
func (x *X11InputInjector) Close() {
|
||||
x.conn.Close()
|
||||
}
|
||||
|
||||
var _ InputInjector = (*X11InputInjector)(nil)
|
||||
var _ ScreenCapturer = (*X11Poller)(nil)
|
||||
73
client/vnc/server/keysym_typetext.go
Normal file
73
client/vnc/server/keysym_typetext.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
// keysymForASCIIRune maps an ASCII rune to (X11 keysym for the unshifted
|
||||
// version, needsShift). Used by TypeText implementations on each platform
|
||||
// so the caller can explicitly press Shift instead of relying on the
|
||||
// server-side modifier state. Returns ok=false for runes outside the
|
||||
// supported set; non-ASCII text is dropped by TypeText.
|
||||
func keysymForASCIIRune(r rune) (uint32, bool, bool) {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
return uint32(r), false, true
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return uint32(r - 'A' + 'a'), true, true
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
return uint32(r), false, true
|
||||
}
|
||||
switch r {
|
||||
case ' ':
|
||||
return 0x20, false, true
|
||||
case '\n', '\r':
|
||||
return 0xff0d, false, true // Return
|
||||
case '\t':
|
||||
return 0xff09, false, true // Tab
|
||||
case '-', '=', '[', ']', '\\', ';', '\'', '`', ',', '.', '/':
|
||||
return uint32(r), false, true
|
||||
case '!':
|
||||
return '1', true, true
|
||||
case '@':
|
||||
return '2', true, true
|
||||
case '#':
|
||||
return '3', true, true
|
||||
case '$':
|
||||
return '4', true, true
|
||||
case '%':
|
||||
return '5', true, true
|
||||
case '^':
|
||||
return '6', true, true
|
||||
case '&':
|
||||
return '7', true, true
|
||||
case '*':
|
||||
return '8', true, true
|
||||
case '(':
|
||||
return '9', true, true
|
||||
case ')':
|
||||
return '0', true, true
|
||||
case '_':
|
||||
return '-', true, true
|
||||
case '+':
|
||||
return '=', true, true
|
||||
case '{':
|
||||
return '[', true, true
|
||||
case '}':
|
||||
return ']', true, true
|
||||
case '|':
|
||||
return '\\', true, true
|
||||
case ':':
|
||||
return ';', true, true
|
||||
case '"':
|
||||
return '\'', true, true
|
||||
case '~':
|
||||
return '`', true, true
|
||||
case '<':
|
||||
return ',', true, true
|
||||
case '>':
|
||||
return '.', true, true
|
||||
case '?':
|
||||
return '/', true, true
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
225
client/vnc/server/metrics_conn.go
Normal file
225
client/vnc/server/metrics_conn.go
Normal file
@@ -0,0 +1,225 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SessionTick is one sampling slice of a VNC session's wire activity.
|
||||
// BytesOut / Writes / FBUs are deltas observed during this tick;
|
||||
// Max* fields are the high-water marks observed during this tick (reset
|
||||
// at the start of the next). Period is the wall-clock duration covered
|
||||
// (typically sessionTickInterval, shorter for the final flush).
|
||||
type SessionTick struct {
|
||||
Period time.Duration
|
||||
BytesOut uint64
|
||||
Writes uint64
|
||||
FBUs uint64
|
||||
MaxFBUBytes uint64
|
||||
MaxFBURects uint64
|
||||
MaxWriteBytes uint64
|
||||
WriteNanos uint64
|
||||
}
|
||||
|
||||
// sessionTickInterval is how often metricsConn emits a SessionTick. One
|
||||
// second covers roughly one FBU round-trip at typical client request
|
||||
// cadences during steady-state activity.
|
||||
const sessionTickInterval = time.Second
|
||||
|
||||
// metricsConn wraps a net.Conn and tracks per-session byte / write / FBU
|
||||
// counters. Updates are atomic so the cost is a few atomic ops per Write
|
||||
// (well under 100 ns), negligible against the syscall itself, so the wrap
|
||||
// is always installed. A goroutine emits a SessionTick to the recorder
|
||||
// every sessionTickInterval (only when the tick has activity to report);
|
||||
// a final partial-tick flush runs on Close.
|
||||
type metricsConn struct {
|
||||
net.Conn
|
||||
|
||||
recorder func(SessionTick)
|
||||
|
||||
bytesOut atomic.Uint64
|
||||
writes atomic.Uint64
|
||||
writeNanos atomic.Uint64
|
||||
largestPkt atomic.Uint64
|
||||
fbus atomic.Uint64
|
||||
fbuBytes atomic.Uint64
|
||||
fbuRects atomic.Uint64
|
||||
maxFBUBytes atomic.Uint64
|
||||
maxFBURects atomic.Uint64
|
||||
|
||||
tickMu sync.Mutex
|
||||
tickStart time.Time
|
||||
tickPrevB uint64
|
||||
tickPrevW uint64
|
||||
tickPrevF uint64
|
||||
tickPrevNS uint64
|
||||
|
||||
// busyMu guards the sliding window used by BusyFraction.
|
||||
busyMu sync.Mutex
|
||||
busyLastTime time.Time
|
||||
busyLastNanos uint64
|
||||
busyFraction float64
|
||||
|
||||
closeOnce sync.Once
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newMetricsConn(c net.Conn, recorder func(SessionTick)) net.Conn {
|
||||
m := &metricsConn{
|
||||
Conn: c,
|
||||
recorder: recorder,
|
||||
tickStart: time.Now(),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
if recorder != nil {
|
||||
go m.tickLoop()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// tickLoop emits a SessionTick every sessionTickInterval until done.
|
||||
// Empty ticks (no writes since the last tick) are skipped.
|
||||
func (m *metricsConn) tickLoop() {
|
||||
t := time.NewTicker(sessionTickInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-m.done:
|
||||
return
|
||||
case <-t.C:
|
||||
m.flushTick(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// flushTick computes deltas since the last tick, resets the per-tick max
|
||||
// trackers, and emits a SessionTick to the recorder. final=true forces
|
||||
// emission even if no writes happened (used at session close to record
|
||||
// the trailing partial period).
|
||||
func (m *metricsConn) flushTick(final bool) {
|
||||
m.tickMu.Lock()
|
||||
defer m.tickMu.Unlock()
|
||||
|
||||
b := m.bytesOut.Load()
|
||||
w := m.writes.Load()
|
||||
f := m.fbus.Load()
|
||||
ns := m.writeNanos.Load()
|
||||
|
||||
db := b - m.tickPrevB
|
||||
dw := w - m.tickPrevW
|
||||
df := f - m.tickPrevF
|
||||
dns := ns - m.tickPrevNS
|
||||
m.tickPrevB, m.tickPrevW, m.tickPrevF, m.tickPrevNS = b, w, f, ns
|
||||
|
||||
maxFBU := m.maxFBUBytes.Swap(0)
|
||||
maxRects := m.maxFBURects.Swap(0)
|
||||
maxPkt := m.largestPkt.Swap(0)
|
||||
|
||||
period := time.Since(m.tickStart)
|
||||
m.tickStart = time.Now()
|
||||
|
||||
if dw == 0 && !final {
|
||||
return
|
||||
}
|
||||
m.recorder(SessionTick{
|
||||
Period: period,
|
||||
BytesOut: db,
|
||||
Writes: dw,
|
||||
FBUs: df,
|
||||
MaxFBUBytes: maxFBU,
|
||||
MaxFBURects: maxRects,
|
||||
MaxWriteBytes: maxPkt,
|
||||
WriteNanos: dns,
|
||||
})
|
||||
}
|
||||
|
||||
// BusyFraction reports the fraction of recent wall time that Write spent
|
||||
// blocked in the underlying socket, as an exponentially smoothed value in
|
||||
// [0, 1]. Approximates downstream backpressure: persistent values near 1
|
||||
// mean the socket cannot keep up with the encoder's output. Callers can
|
||||
// throttle JPEG quality or skip frames in response.
|
||||
func (m *metricsConn) BusyFraction() float64 {
|
||||
now := time.Now()
|
||||
ns := m.writeNanos.Load()
|
||||
|
||||
m.busyMu.Lock()
|
||||
defer m.busyMu.Unlock()
|
||||
if m.busyLastTime.IsZero() {
|
||||
m.busyLastTime = now
|
||||
m.busyLastNanos = ns
|
||||
return 0
|
||||
}
|
||||
period := now.Sub(m.busyLastTime)
|
||||
if period < 50*time.Millisecond {
|
||||
return m.busyFraction
|
||||
}
|
||||
delta := ns - m.busyLastNanos
|
||||
sample := float64(delta) / float64(period.Nanoseconds())
|
||||
if sample > 1 {
|
||||
sample = 1
|
||||
}
|
||||
const alpha = 0.4
|
||||
m.busyFraction = alpha*sample + (1-alpha)*m.busyFraction
|
||||
m.busyLastTime = now
|
||||
m.busyLastNanos = ns
|
||||
return m.busyFraction
|
||||
}
|
||||
|
||||
// isFBUHeader reports whether the given Write payload is the 4-byte
|
||||
// FramebufferUpdate header (message type 0, padding 0, rect-count high
|
||||
// byte). Rect bodies are written separately by sendDirtyAndMoves, so the
|
||||
// FBU/rect boundary lines up with Write boundaries.
|
||||
func isFBUHeader(p []byte) bool {
|
||||
return len(p) == 4 && p[0] == serverFramebufferUpdate
|
||||
}
|
||||
|
||||
func (m *metricsConn) Write(p []byte) (int, error) {
|
||||
if isFBUHeader(p) {
|
||||
if b := m.fbuBytes.Swap(0); b > 0 {
|
||||
if b > m.maxFBUBytes.Load() {
|
||||
m.maxFBUBytes.Store(b)
|
||||
}
|
||||
}
|
||||
if r := m.fbuRects.Swap(0); r > 0 {
|
||||
if r > m.maxFBURects.Load() {
|
||||
m.maxFBURects.Store(r)
|
||||
}
|
||||
}
|
||||
m.fbus.Add(1)
|
||||
}
|
||||
|
||||
t0 := time.Now()
|
||||
n, err := m.Conn.Write(p)
|
||||
m.writeNanos.Add(uint64(time.Since(t0).Nanoseconds()))
|
||||
m.bytesOut.Add(uint64(n))
|
||||
m.writes.Add(1)
|
||||
if !isFBUHeader(p) {
|
||||
m.fbuBytes.Add(uint64(n))
|
||||
m.fbuRects.Add(1)
|
||||
}
|
||||
if uint64(n) > m.largestPkt.Load() {
|
||||
m.largestPkt.Store(uint64(n))
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (m *metricsConn) Close() error {
|
||||
m.closeOnce.Do(func() {
|
||||
close(m.done)
|
||||
if m.recorder == nil {
|
||||
return
|
||||
}
|
||||
if b := m.fbuBytes.Swap(0); b > m.maxFBUBytes.Load() {
|
||||
m.maxFBUBytes.Store(b)
|
||||
}
|
||||
if r := m.fbuRects.Swap(0); r > m.maxFBURects.Load() {
|
||||
m.maxFBURects.Store(r)
|
||||
}
|
||||
m.flushTick(true)
|
||||
})
|
||||
return m.Conn.Close()
|
||||
}
|
||||
432
client/vnc/server/noise_auth_test.go
Normal file
432
client/vnc/server/noise_auth_test.go
Normal file
@@ -0,0 +1,432 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/flynn/noise"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/crypto/curve25519"
|
||||
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
// noiseTestServer starts a VNC server with a freshly generated identity
|
||||
// key and returns the listener address, the server, and the server's
|
||||
// static public key for client-side handshake setup.
|
||||
func noiseTestServer(t *testing.T) (net.Addr, *Server, []byte) {
|
||||
t.Helper()
|
||||
|
||||
kp, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, kp.Private)
|
||||
srv.SetDisableAuth(false)
|
||||
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
network := netip.MustParsePrefix("127.0.0.0/8")
|
||||
require.NoError(t, srv.Start(t.Context(), addr, network))
|
||||
srv.localAddr = netip.MustParseAddr("10.99.99.1")
|
||||
t.Cleanup(func() { _ = srv.Stop() })
|
||||
|
||||
return srv.listener.Addr(), srv, kp.Public
|
||||
}
|
||||
|
||||
// registerSessionKey enrolls a fresh X25519 keypair under the given user
|
||||
// ID into the server's authorizer with the requested OS-user wildcard
|
||||
// mapping. Returns the keypair so the test can drive the handshake.
|
||||
func registerSessionKey(t *testing.T, srv *Server, userID string) noise.DHKey {
|
||||
t.Helper()
|
||||
|
||||
kp, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
userHash, err := sshuserhash.HashUserID(userID)
|
||||
require.NoError(t, err)
|
||||
|
||||
srv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: []sshuserhash.UserIDHash{userHash},
|
||||
MachineUsers: map[string][]uint32{sshauth.Wildcard: {0}},
|
||||
SessionPubKeys: []sshauth.SessionPubKey{
|
||||
{PubKey: kp.Public, UserIDHash: userHash},
|
||||
},
|
||||
})
|
||||
return kp
|
||||
}
|
||||
|
||||
// writeHeaderPrefix writes the mode + zero-length-username prefix that
|
||||
// precedes the optional Noise handshake in the NetBird VNC header.
|
||||
func writeHeaderPrefix(t *testing.T, conn net.Conn, mode byte) {
|
||||
t.Helper()
|
||||
prefix := []byte{mode, 0, 0}
|
||||
_, err := conn.Write(prefix)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// writeHeaderTail writes the sessionID/width/height fields that follow
|
||||
// either the Noise msg2 (auth path) or the prefix alone (no-auth path).
|
||||
func writeHeaderTail(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
tail := make([]byte, 8)
|
||||
_, err := conn.Write(tail)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// performInitiator drives the initiator side of Noise_IK against the
|
||||
// server's identity public key, returns the resulting state. The Noise
|
||||
// msg2 produced by the server is read and consumed.
|
||||
func performInitiator(t *testing.T, conn net.Conn, clientKey noise.DHKey, serverPub []byte) {
|
||||
t.Helper()
|
||||
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: true,
|
||||
StaticKeypair: clientKey,
|
||||
PeerStatic: serverPub,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
msg1, _, _, err := state.WriteMessage(nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, noiseInitiatorMsgLen, len(msg1))
|
||||
|
||||
_, err = conn.Write(append([]byte("NBV3"), msg1...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
msg2 := make([]byte, noiseResponderMsgLen)
|
||||
_, err = io.ReadFull(conn, msg2)
|
||||
require.NoError(t, err)
|
||||
_, _, _, err = state.ReadMessage(nil, msg2)
|
||||
require.NoError(t, err, "server responder message must decrypt with the correct peer static")
|
||||
}
|
||||
|
||||
// readRFBFailure consumes the RFB version exchange and returns the
|
||||
// security-failure reason string. Fails the test if the server did not
|
||||
// send a failure (i.e. produced a non-zero security-types list).
|
||||
func readRFBFailure(t *testing.T, conn net.Conn) string {
|
||||
t.Helper()
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
var ver [12]byte
|
||||
_, err := io.ReadFull(conn, ver[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "RFB 003.008\n", string(ver[:]))
|
||||
|
||||
_, err = conn.Write(ver[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
var n [1]byte
|
||||
_, err = io.ReadFull(conn, n[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, byte(0), n[0], "expected security-failure (0 types)")
|
||||
|
||||
var rl [4]byte
|
||||
_, err = io.ReadFull(conn, rl[:])
|
||||
require.NoError(t, err)
|
||||
reason := make([]byte, binary.BigEndian.Uint32(rl[:]))
|
||||
_, err = io.ReadFull(conn, reason)
|
||||
require.NoError(t, err)
|
||||
return string(reason)
|
||||
}
|
||||
|
||||
// readRFBGreetingNoFailure asserts the server proceeded past auth: it
|
||||
// must offer at least one security type rather than a 0 failure.
|
||||
func readRFBGreetingNoFailure(t *testing.T, conn net.Conn) {
|
||||
t.Helper()
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
|
||||
var ver [12]byte
|
||||
_, err := io.ReadFull(conn, ver[:])
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "RFB 003.008\n", string(ver[:]))
|
||||
|
||||
_, err = conn.Write(ver[:])
|
||||
require.NoError(t, err)
|
||||
|
||||
var n [1]byte
|
||||
_, err = io.ReadFull(conn, n[:])
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, byte(0), n[0], "server must offer security types after a valid handshake")
|
||||
}
|
||||
|
||||
// TestNoise_RegisteredKey_AccessGranted exercises the happy path: a
|
||||
// session key enrolled in the authorizer completes a Noise_IK handshake
|
||||
// and the server proceeds to the RFB greeting.
|
||||
func TestNoise_RegisteredKey_AccessGranted(t *testing.T) {
|
||||
addr, srv, serverPub := noiseTestServer(t)
|
||||
clientKey := registerSessionKey(t, srv, "alice@example")
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
performInitiator(t, conn, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
readRFBGreetingNoFailure(t, conn)
|
||||
}
|
||||
|
||||
// TestNoise_UnregisteredClientStatic_Rejected proves the authorizer is
|
||||
// consulted: a syntactically-valid handshake from a key the server has
|
||||
// never been told about must be rejected fail-closed.
|
||||
func TestNoise_UnregisteredClientStatic_Rejected(t *testing.T) {
|
||||
addr, _, serverPub := noiseTestServer(t)
|
||||
// Auth is enabled but the authorizer was not updated, so the lookup
|
||||
// path returns ErrSessionKeyNotKnown.
|
||||
attackerKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
performInitiator(t, conn, attackerKey, serverPub)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
reason := readRFBFailure(t, conn)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "session pubkey not registered")
|
||||
}
|
||||
|
||||
// TestNoise_WrongServerStatic_HandshakeFails proves the server's
|
||||
// identity is bound into the handshake: an initiator using the wrong
|
||||
// peer static encrypts msg1 under keys the real server can't derive, so
|
||||
// the server fails the handshake and closes without RFB output.
|
||||
func TestNoise_WrongServerStatic_HandshakeFails(t *testing.T) {
|
||||
addr, srv, _ := noiseTestServer(t)
|
||||
clientKey := registerSessionKey(t, srv, "alice@example")
|
||||
|
||||
bogusServerKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: true,
|
||||
StaticKeypair: clientKey,
|
||||
PeerStatic: bogusServerKey.Public,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msg1, _, _, err := state.WriteMessage(nil, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(append([]byte("NBV3"), msg1...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var b [1]byte
|
||||
_, err = io.ReadFull(conn, b[:])
|
||||
require.Error(t, err, "server must close without RFB greeting when msg1 is sealed for a different server identity")
|
||||
}
|
||||
|
||||
// TestNoise_MalformedMsg1_ClosesConnection covers the case where the
|
||||
// magic prefix is correct but the following 96 bytes are random: the
|
||||
// noise library fails ReadMessage and the server closes silently.
|
||||
func TestNoise_MalformedMsg1_ClosesConnection(t *testing.T) {
|
||||
addr, _, _ := noiseTestServer(t)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
junk := make([]byte, noiseInitiatorMsgLen)
|
||||
for i := range junk {
|
||||
junk[i] = byte(i)
|
||||
}
|
||||
_, err = conn.Write(append([]byte("NBV3"), junk...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var b [1]byte
|
||||
_, err = io.ReadFull(conn, b[:])
|
||||
require.Error(t, err, "garbage msg1 must terminate the connection before any RFB output")
|
||||
}
|
||||
|
||||
// TestNoise_TruncatedMsg1_ClosesConnection sends fewer than the 96
|
||||
// bytes a Noise_IK msg1 must contain. The server's io.ReadFull short-
|
||||
// reads and closes; no RFB greeting must leak.
|
||||
func TestNoise_TruncatedMsg1_ClosesConnection(t *testing.T) {
|
||||
addr, _, _ := noiseTestServer(t)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
_, err = conn.Write([]byte("NBV3"))
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(make([]byte, 8))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, conn.(*net.TCPConn).CloseWrite())
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(2*time.Second)))
|
||||
buf := make([]byte, 64)
|
||||
n, err := conn.Read(buf)
|
||||
require.Equal(t, 0, n, "server must not emit RFB bytes after a truncated handshake")
|
||||
require.ErrorIs(t, err, io.EOF, "server must close the connection on truncated msg1")
|
||||
}
|
||||
|
||||
// TestNoise_AuthEnabled_NoHandshake_Rejected proves that with auth on,
|
||||
// a connection that skips the Noise prefix (older client / VNC client)
|
||||
// is rejected with AUTH_FORBIDDEN: identity proof missing.
|
||||
func TestNoise_AuthEnabled_NoHandshake_Rejected(t *testing.T) {
|
||||
addr, _, _ := noiseTestServer(t)
|
||||
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
reason := readRFBFailure(t, conn)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "identity proof missing")
|
||||
}
|
||||
|
||||
// TestNoise_RevokedKey_RejectedAfterAuthUpdate verifies the authorizer
|
||||
// honors revocations: a key that worked before a UpdateVNCAuth call
|
||||
// must stop working as soon as the new config omits it.
|
||||
func TestNoise_RevokedKey_RejectedAfterAuthUpdate(t *testing.T) {
|
||||
addr, srv, serverPub := noiseTestServer(t)
|
||||
clientKey := registerSessionKey(t, srv, "alice@example")
|
||||
|
||||
// First connection succeeds.
|
||||
conn1, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn1.Close()
|
||||
writeHeaderPrefix(t, conn1, ModeAttach)
|
||||
performInitiator(t, conn1, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn1)
|
||||
readRFBGreetingNoFailure(t, conn1)
|
||||
|
||||
// Revoke by pushing a fresh config that drops the pubkey entry.
|
||||
srv.UpdateVNCAuth(&sshauth.Config{})
|
||||
|
||||
// Same client, same Noise key, should now be denied.
|
||||
conn2, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn2.Close()
|
||||
writeHeaderPrefix(t, conn2, ModeAttach)
|
||||
performInitiator(t, conn2, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn2)
|
||||
|
||||
reason := readRFBFailure(t, conn2)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "session pubkey not registered")
|
||||
}
|
||||
|
||||
// TestNoise_NoIdentityKey_FailsClosed ensures a server constructed
|
||||
// without a static private key still rejects authenticated connections
|
||||
// fail-closed; it must not silently accept the client.
|
||||
func TestNoise_NoIdentityKey_FailsClosed(t *testing.T) {
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, nil)
|
||||
srv.SetDisableAuth(false)
|
||||
addr := netip.MustParseAddrPort("127.0.0.1:0")
|
||||
network := netip.MustParsePrefix("127.0.0.0/8")
|
||||
require.NoError(t, srv.Start(t.Context(), addr, network))
|
||||
srv.localAddr = netip.MustParseAddr("10.99.99.1")
|
||||
t.Cleanup(func() { _ = srv.Stop() })
|
||||
|
||||
clientKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
fakeServerKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conn, err := net.Dial("tcp", srv.listener.Addr().String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
writeHeaderPrefix(t, conn, ModeAttach)
|
||||
|
||||
state, err := noise.NewHandshakeState(noise.Config{
|
||||
CipherSuite: vncNoiseSuite,
|
||||
Pattern: noise.HandshakeIK,
|
||||
Initiator: true,
|
||||
StaticKeypair: clientKey,
|
||||
PeerStatic: fakeServerKey.Public,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
msg1, _, _, err := state.WriteMessage(nil, nil)
|
||||
require.NoError(t, err)
|
||||
_, err = conn.Write(append([]byte("NBV3"), msg1...))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second)))
|
||||
var b [1]byte
|
||||
_, err = io.ReadFull(conn, b[:])
|
||||
require.Error(t, err, "server without identity key must not write the RFB greeting")
|
||||
}
|
||||
|
||||
// TestNoise_DerivedIdentityPublicMatchesPrivate sanity-checks the
|
||||
// derivation done in New(): the identityPublic must be Curve25519.
|
||||
// Basepoint multiplied with identityKey.
|
||||
func TestNoise_DerivedIdentityPublicMatchesPrivate(t *testing.T) {
|
||||
priv := make([]byte, 32)
|
||||
for i := range priv {
|
||||
priv[i] = byte(i + 1)
|
||||
}
|
||||
srv := New(&testCapturer{}, &StubInputInjector{}, priv)
|
||||
|
||||
expected, err := curve25519.X25519(priv, curve25519.Basepoint)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, expected, srv.identityPublic)
|
||||
}
|
||||
|
||||
// TestNoise_SessionMode_OSUserCheckRunsAfterHandshake verifies that a
|
||||
// successful Noise handshake doesn't bypass OS-user authorization: an
|
||||
// authenticated key whose user index isn't mapped to the requested OS
|
||||
// user must be rejected.
|
||||
func TestNoise_SessionMode_OSUserCheckRunsAfterHandshake(t *testing.T) {
|
||||
addr, srv, serverPub := noiseTestServer(t)
|
||||
|
||||
clientKey, err := noise.DH25519.GenerateKeypair(nil)
|
||||
require.NoError(t, err)
|
||||
userHash, err := sshuserhash.HashUserID("alice@example")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Map Alice only to "alice" OS user, not the wildcard.
|
||||
srv.UpdateVNCAuth(&sshauth.Config{
|
||||
AuthorizedUsers: []sshuserhash.UserIDHash{userHash},
|
||||
MachineUsers: map[string][]uint32{"alice": {0}},
|
||||
SessionPubKeys: []sshauth.SessionPubKey{
|
||||
{PubKey: clientKey.Public, UserIDHash: userHash},
|
||||
},
|
||||
})
|
||||
|
||||
// Request session for "bob" — Noise succeeds, OS-user check denies.
|
||||
conn, err := net.Dial("tcp", addr.String())
|
||||
require.NoError(t, err)
|
||||
defer conn.Close()
|
||||
|
||||
bob := []byte("bob")
|
||||
prefix := []byte{ModeSession, 0, byte(len(bob))}
|
||||
prefix = append(prefix, bob...)
|
||||
_, err = conn.Write(prefix)
|
||||
require.NoError(t, err)
|
||||
|
||||
performInitiator(t, conn, clientKey, serverPub)
|
||||
writeHeaderTail(t, conn)
|
||||
|
||||
reason := readRFBFailure(t, conn)
|
||||
assert.Contains(t, reason, RejectCodeAuthForbidden)
|
||||
assert.Contains(t, reason, "authorize OS user")
|
||||
}
|
||||
59
client/vnc/server/pseudo_encodings_test.go
Normal file
59
client/vnc/server/pseudo_encodings_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEncodeDesktopSizeBody(t *testing.T) {
|
||||
got := encodeDesktopSizeBody(1920, 1080)
|
||||
if len(got) != 12 {
|
||||
t.Fatalf("DesktopSize body length: want 12, got %d", len(got))
|
||||
}
|
||||
if got[0] != 0 || got[1] != 0 || got[2] != 0 || got[3] != 0 {
|
||||
t.Fatalf("DesktopSize: x and y must be zero; got % x", got[0:4])
|
||||
}
|
||||
if got[4] != 0x07 || got[5] != 0x80 {
|
||||
t.Fatalf("DesktopSize: width should be 1920 (0x0780); got % x", got[4:6])
|
||||
}
|
||||
if got[6] != 0x04 || got[7] != 0x38 {
|
||||
t.Fatalf("DesktopSize: height should be 1080 (0x0438); got % x", got[6:8])
|
||||
}
|
||||
// Encoding = -223 → 0xFFFFFF21 in two's complement big-endian.
|
||||
if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFF || got[11] != 0x21 {
|
||||
t.Fatalf("DesktopSize: encoding bytes wrong: % x", got[8:12])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDesktopNameBody(t *testing.T) {
|
||||
name := "vma@debian3"
|
||||
got := encodeDesktopNameBody(name)
|
||||
if len(got) != 12+4+len(name) {
|
||||
t.Fatalf("DesktopName body length: want %d, got %d", 12+4+len(name), len(got))
|
||||
}
|
||||
// Encoding = -307 → 0xFFFFFECD.
|
||||
if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFE || got[11] != 0xCD {
|
||||
t.Fatalf("DesktopName: encoding bytes wrong: % x", got[8:12])
|
||||
}
|
||||
if got[12] != 0 || got[13] != 0 || got[14] != 0 || got[15] != byte(len(name)) {
|
||||
t.Fatalf("DesktopName: name length prefix wrong: % x", got[12:16])
|
||||
}
|
||||
if string(got[16:]) != name {
|
||||
t.Fatalf("DesktopName: name body wrong: %q", got[16:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeLastRectBody(t *testing.T) {
|
||||
got := encodeLastRectBody()
|
||||
if len(got) != 12 {
|
||||
t.Fatalf("LastRect body length: want 12, got %d", len(got))
|
||||
}
|
||||
for i := 0; i < 8; i++ {
|
||||
if got[i] != 0 {
|
||||
t.Fatalf("LastRect: header bytes 0..7 must be zero; got byte %d = 0x%02x", i, got[i])
|
||||
}
|
||||
}
|
||||
// Encoding = -224 → 0xFFFFFF20.
|
||||
if got[8] != 0xFF || got[9] != 0xFF || got[10] != 0xFF || got[11] != 0x20 {
|
||||
t.Fatalf("LastRect: encoding bytes wrong: % x", got[8:12])
|
||||
}
|
||||
}
|
||||
806
client/vnc/server/rfb.go
Normal file
806
client/vnc/server/rfb.go
Normal file
@@ -0,0 +1,806 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// rect describes a rectangle on the framebuffer in pixels.
|
||||
type rect struct {
|
||||
x, y, w, h int
|
||||
}
|
||||
|
||||
const (
|
||||
rfbProtocolVersion = "RFB 003.008\n"
|
||||
|
||||
secNone = 1
|
||||
|
||||
// Client message types.
|
||||
clientSetPixelFormat = 0
|
||||
clientSetEncodings = 2
|
||||
clientFramebufferUpdateRequest = 3
|
||||
clientKeyEvent = 4
|
||||
clientPointerEvent = 5
|
||||
clientCutText = 6
|
||||
// clientQEMUMessage is the QEMU vendor message wrapper. The subtype
|
||||
// byte that follows selects the actual operation; we only handle the
|
||||
// Extended Key Event (subtype 0) which carries a hardware scancode in
|
||||
// addition to the X11 keysym. Layout-independent key entry.
|
||||
clientQEMUMessage = 255
|
||||
|
||||
// QEMU Extended Key Event subtype carried inside clientQEMUMessage.
|
||||
qemuSubtypeExtendedKeyEvent = 0
|
||||
|
||||
// clientNetbirdTypeText is a NetBird-specific message that asks the
|
||||
// server to synthesize the given text as keystrokes regardless of the
|
||||
// active desktop. Lets a client push host clipboard content into a
|
||||
// Windows secure desktop (Winlogon, UAC), where the OS clipboard is
|
||||
// isolated. Format mirrors clientCutText: 1-byte message type + 3-byte
|
||||
// padding + 4-byte length + text bytes. The opcode is in the
|
||||
// vendor-specific range (>=128).
|
||||
clientNetbirdTypeText = 250
|
||||
|
||||
// clientNetbirdShowRemoteCursor toggles "show remote cursor" mode.
|
||||
// When enabled the encoder composites the server cursor sprite into
|
||||
// the captured framebuffer and suppresses the Cursor pseudo-encoding
|
||||
// so the client sees a single pointer at the remote position.
|
||||
// Wire format: 1-byte msgType + 1-byte enable flag + 6 padding bytes
|
||||
// reserved for future arguments (so the message is fixed-size).
|
||||
clientNetbirdShowRemoteCursor = 251
|
||||
|
||||
// Server message types.
|
||||
serverFramebufferUpdate = 0
|
||||
serverCutText = 3
|
||||
|
||||
// Encoding types.
|
||||
encRaw = 0
|
||||
encCopyRect = 1
|
||||
encHextile = 5
|
||||
encZlib = 6
|
||||
encTight = 7
|
||||
|
||||
// Pseudo-encodings carried over wire as rects with a negative
|
||||
// encoding value. The client advertises supported optional protocol
|
||||
// extensions by listing these in SetEncodings.
|
||||
pseudoEncCursor = -239
|
||||
pseudoEncDesktopSize = -223
|
||||
pseudoEncLastRect = -224
|
||||
pseudoEncQEMUExtendedKeyEvent = -258
|
||||
pseudoEncDesktopName = -307
|
||||
pseudoEncExtendedDesktopSize = -308
|
||||
pseudoEncExtendedMouseButtons = -316
|
||||
|
||||
// Quality/Compression level pseudo-encodings. The client picks one
|
||||
// value from each range to tune JPEG quality and zlib effort. 0 is
|
||||
// lowest quality / fastest, 9 is highest quality / best compression.
|
||||
pseudoEncQualityLevelMin = -32
|
||||
pseudoEncQualityLevelMax = -23
|
||||
pseudoEncCompressLevelMin = -256
|
||||
pseudoEncCompressLevelMax = -247
|
||||
|
||||
// Hextile sub-encoding bits used by the SolidFill fast path.
|
||||
hextileBackgroundSpecified = 0x02
|
||||
hextileSubSize = 16
|
||||
|
||||
// Tight compression-control byte top nibble. Stream-reset bits 0-3
|
||||
// (one per zlib stream) are unused while we run a single stream.
|
||||
tightFillSubenc = 0x80
|
||||
tightJPEGSubenc = 0x90
|
||||
tightBasicFilter = 0x40 // Bit 6 set = explicit filter byte follows.
|
||||
tightFilterCopy = 0x00 // No-op filter, raw pixel stream.
|
||||
|
||||
// JPEG quality used by the Tight encoder. 70 is a reasonable speed/
|
||||
// quality knee; bandwidth roughly halves vs raw RGB while staying
|
||||
// visually clean for typical desktop content. Large rects (e.g. a
|
||||
// fullscreen video region) drop to a lower quality so the encoder
|
||||
// keeps up at 30+ fps; the visual hit is small for moving content.
|
||||
tightJPEGQuality = 70
|
||||
tightJPEGQualityMedium = 55
|
||||
tightJPEGQualityLarge = 40
|
||||
tightJPEGMediumPixels = 800 * 600 // ≈ SVGA, applies medium tier
|
||||
tightJPEGLargePixels = 1280 * 720 // ≈ 720p, applies large tier
|
||||
// Minimum rect area before we consider JPEG. Below this, header
|
||||
// overhead dominates and Basic+zlib wins.
|
||||
tightJPEGMinArea = 4096 // 64×64 ≈ 1 tile
|
||||
// Distinct-colour cap below which we still prefer Basic+zlib (text,
|
||||
// UI). Sampled, not exhaustive: cheap to compute, good enough.
|
||||
tightJPEGMinColors = 64
|
||||
)
|
||||
|
||||
// serverPixelFormat is the pixel format the server advertises and requires:
|
||||
// 32bpp RGBA, little-endian, true-colour, 8 bits per channel at standard
|
||||
// shifts (R=16, G=8, B=0). handleSetPixelFormat rejects any client that
|
||||
// negotiates a different format. Browser-side decoders are little-endian
|
||||
// natively, so advertising little-endian skips a byte-swap on every pixel.
|
||||
var serverPixelFormat = [16]byte{
|
||||
32, // bits-per-pixel
|
||||
24, // depth
|
||||
0, // big-endian-flag
|
||||
1, // true-colour-flag
|
||||
0, 255, // red-max
|
||||
0, 255, // green-max
|
||||
0, 255, // blue-max
|
||||
16, // red-shift
|
||||
8, // green-shift
|
||||
0, // blue-shift
|
||||
0, 0, 0, // padding
|
||||
}
|
||||
|
||||
// clientPixelFormat holds the negotiated pixel format. Only RGB channel
|
||||
// shifts are tracked: every other field is constrained by the server to
|
||||
// the values in serverPixelFormat (32bpp / little-endian / truecolour /
|
||||
// 8-bit channels) and rejected at SetPixelFormat time if the client tries
|
||||
// to negotiate otherwise.
|
||||
type clientPixelFormat struct {
|
||||
rShift uint8
|
||||
gShift uint8
|
||||
bShift uint8
|
||||
}
|
||||
|
||||
func defaultClientPixelFormat() clientPixelFormat {
|
||||
return clientPixelFormat{
|
||||
rShift: serverPixelFormat[10],
|
||||
gShift: serverPixelFormat[11],
|
||||
bShift: serverPixelFormat[12],
|
||||
}
|
||||
}
|
||||
|
||||
// parsePixelFormat returns the negotiated client pixel format, or an error
|
||||
// if the client tried to negotiate an unsupported format. The server only
|
||||
// supports 32bpp truecolour little-endian with 8-bit channels; arbitrary
|
||||
// shifts within that constraint are allowed because they are cheap to honour.
|
||||
func parsePixelFormat(pf []byte) (clientPixelFormat, error) {
|
||||
bpp := pf[0]
|
||||
bigEndian := pf[2]
|
||||
trueColour := pf[3]
|
||||
rMax := binary.BigEndian.Uint16(pf[4:6])
|
||||
gMax := binary.BigEndian.Uint16(pf[6:8])
|
||||
bMax := binary.BigEndian.Uint16(pf[8:10])
|
||||
if bpp != 32 || bigEndian != 0 || trueColour != 1 ||
|
||||
rMax != 255 || gMax != 255 || bMax != 255 {
|
||||
return clientPixelFormat{}, fmt.Errorf(
|
||||
"unsupported pixel format (bpp=%d be=%d tc=%d rgb-max=%d/%d/%d): "+
|
||||
"server only supports 32bpp truecolour little-endian 8-bit channels",
|
||||
bpp, bigEndian, trueColour, rMax, gMax, bMax)
|
||||
}
|
||||
return clientPixelFormat{
|
||||
rShift: pf[10],
|
||||
gShift: pf[11],
|
||||
bShift: pf[12],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// encodeCopyRectBody emits the per-rect payload for a CopyRect rectangle:
|
||||
// the 12-byte rect header (dst position + size + encoding=1) plus a 4-byte
|
||||
// source position. Used inside multi-rect FramebufferUpdate messages, so
|
||||
// the 4-byte FU header is the caller's responsibility.
|
||||
func encodeCopyRectBody(srcX, srcY, dstX, dstY, w, h int) []byte {
|
||||
buf := make([]byte, 12+4)
|
||||
binary.BigEndian.PutUint16(buf[0:2], uint16(dstX))
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(dstY))
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(encCopyRect))
|
||||
binary.BigEndian.PutUint16(buf[12:14], uint16(srcX))
|
||||
binary.BigEndian.PutUint16(buf[14:16], uint16(srcY))
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeDesktopSizeBody emits a DesktopSize pseudo-encoded rectangle. The
|
||||
// "rect" carries no pixel data: x and y are zero, w and h are the new
|
||||
// framebuffer dimensions, and encoding=-223 signals to the client that the
|
||||
// framebuffer was resized. Clients reallocate their backing buffer and
|
||||
// expect a full update at the new size to follow.
|
||||
func encodeDesktopSizeBody(w, h int) []byte {
|
||||
buf := make([]byte, 12)
|
||||
binary.BigEndian.PutUint16(buf[0:2], 0)
|
||||
binary.BigEndian.PutUint16(buf[2:4], 0)
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(h))
|
||||
enc := int32(pseudoEncDesktopSize)
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(enc))
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeDesktopNameBody emits a DesktopName pseudo-encoded rectangle. The
|
||||
// rect header is all zeros and encoding=-307; the body is a 4-byte
|
||||
// big-endian length followed by the UTF-8 name. Clients update their
|
||||
// window title or label without reconnecting.
|
||||
func encodeDesktopNameBody(name string) []byte {
|
||||
nameBytes := []byte(name)
|
||||
buf := make([]byte, 12+4+len(nameBytes))
|
||||
binary.BigEndian.PutUint16(buf[0:2], 0)
|
||||
binary.BigEndian.PutUint16(buf[2:4], 0)
|
||||
binary.BigEndian.PutUint16(buf[4:6], 0)
|
||||
binary.BigEndian.PutUint16(buf[6:8], 0)
|
||||
enc := int32(pseudoEncDesktopName)
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(enc))
|
||||
binary.BigEndian.PutUint32(buf[12:16], uint32(len(nameBytes)))
|
||||
copy(buf[16:], nameBytes)
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeLastRectBody emits a LastRect sentinel. When the server sets
|
||||
// numRects=0xFFFF in the FramebufferUpdate header, the client reads rects
|
||||
// until it sees one with this encoding. Lets us stream rects from a
|
||||
// goroutine without committing to a count up front.
|
||||
func encodeLastRectBody() []byte {
|
||||
buf := make([]byte, 12)
|
||||
// x, y, w, h all zero; encoding = -224.
|
||||
enc := int32(pseudoEncLastRect)
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(enc))
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeRawRect encodes a framebuffer region as a raw RFB rectangle.
|
||||
// The returned buffer includes the FramebufferUpdate header (1 rectangle).
|
||||
func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte {
|
||||
buf := make([]byte, 4+12+w*h*4)
|
||||
|
||||
// FramebufferUpdate header.
|
||||
buf[0] = serverFramebufferUpdate
|
||||
buf[1] = 0 // padding
|
||||
binary.BigEndian.PutUint16(buf[2:4], 1)
|
||||
|
||||
// Rectangle header.
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[8:10], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[10:12], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[12:16], uint32(encRaw))
|
||||
|
||||
writePixels(buf[16:], img, pf, rect{x, y, w, h})
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeZlibRect encodes a framebuffer region using the standalone Zlib
|
||||
// encoding. The zlib stream is continuous for the entire VNC session: the
|
||||
// client keeps a single inflate context and reuses it across rects. The
|
||||
// returned buffer includes the 4-byte FramebufferUpdate header.
|
||||
func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) []byte {
|
||||
zw, zbuf := z.w, z.buf
|
||||
zbuf.Reset()
|
||||
|
||||
rowBytes := w * 4
|
||||
total := rowBytes * h
|
||||
if cap(z.scratch) < total {
|
||||
z.scratch = make([]byte, total)
|
||||
}
|
||||
scratch := z.scratch[:total]
|
||||
writePixels(scratch, img, pf, rect{x, y, w, h})
|
||||
for row := 0; row < h; row++ {
|
||||
if _, err := zw.Write(scratch[row*rowBytes : (row+1)*rowBytes]); err != nil {
|
||||
log.Debugf("zlib write row %d: %v", row, err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := zw.Flush(); err != nil {
|
||||
log.Debugf("zlib flush: %v", err)
|
||||
return nil
|
||||
}
|
||||
compressed := zbuf.Bytes()
|
||||
|
||||
buf := make([]byte, 4+12+4+len(compressed))
|
||||
buf[0] = serverFramebufferUpdate
|
||||
binary.BigEndian.PutUint16(buf[2:4], 1)
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[8:10], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[10:12], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[12:16], uint32(encZlib))
|
||||
binary.BigEndian.PutUint32(buf[16:20], uint32(len(compressed)))
|
||||
copy(buf[20:], compressed)
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeHextileSolidRect emits a Hextile-encoded rectangle whose every
|
||||
// pixel is the same colour. The first sub-tile carries the background
|
||||
// pixel; remaining sub-tiles inherit it via a zero sub-encoding byte,
|
||||
// collapsing a uniform 64×64 tile down to ~20 bytes. The returned buffer
|
||||
// starts with the 12-byte rect header; callers prepend a FramebufferUpdate
|
||||
// header.
|
||||
func encodeHextileSolidRect(r, g, b byte, pf clientPixelFormat, rc rect) []byte {
|
||||
cols := (rc.w + hextileSubSize - 1) / hextileSubSize
|
||||
rows := (rc.h + hextileSubSize - 1) / hextileSubSize
|
||||
subs := cols * rows
|
||||
// One sub-encoding byte plus a 32bpp pixel for the first sub-tile, then
|
||||
// one zero byte per remaining sub-tile to inherit the background.
|
||||
bodySize := 1 + 4 + (subs - 1)
|
||||
buf := make([]byte, 12+bodySize)
|
||||
|
||||
binary.BigEndian.PutUint16(buf[0:2], uint16(rc.x))
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(rc.y))
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(rc.w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(rc.h))
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile))
|
||||
|
||||
buf[12] = hextileBackgroundSpecified
|
||||
pixel := (uint32(r) << pf.rShift) | (uint32(g) << pf.gShift) | (uint32(b) << pf.bShift)
|
||||
binary.LittleEndian.PutUint32(buf[13:17], pixel)
|
||||
return buf
|
||||
}
|
||||
|
||||
// writePixels writes a rectangle of img into dst as 32bpp little-endian
|
||||
// pixels at the negotiated RGB shifts. The pixel format is constrained at
|
||||
// SetPixelFormat time so we can assume 4 bytes per pixel, 8-bit channels,
|
||||
// and little-endian byte order; arbitrary shifts (R/G/B order) are honoured.
|
||||
func writePixels(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect) {
|
||||
stride := img.Stride
|
||||
rShift, gShift, bShift := pf.rShift, pf.gShift, pf.bShift
|
||||
off := 0
|
||||
for row := r.y; row < r.y+r.h; row++ {
|
||||
p := row*stride + r.x*4
|
||||
for col := 0; col < r.w; col++ {
|
||||
pixel := (uint32(img.Pix[p]) << rShift) |
|
||||
(uint32(img.Pix[p+1]) << gShift) |
|
||||
(uint32(img.Pix[p+2]) << bShift)
|
||||
binary.LittleEndian.PutUint32(dst[off:off+4], pixel)
|
||||
p += 4
|
||||
off += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// diffTiles compares two RGBA images and returns a tile-ordered list of
|
||||
// dirty tiles, one entry per tile. Tile order is top-to-bottom, left-to-
|
||||
// right within each row. The caller decides whether to coalesce or hand
|
||||
// the list off to the CopyRect detector first.
|
||||
func diffTiles(prev, cur *image.RGBA, w, h, tileSize int) [][4]int {
|
||||
if prev == nil {
|
||||
return [][4]int{{0, 0, w, h}}
|
||||
}
|
||||
var rects [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := min(tileSize, h-ty)
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := min(tileSize, w-tx)
|
||||
if tileChanged(prev, cur, tx, ty, tw, th) {
|
||||
rects = append(rects, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
}
|
||||
return rects
|
||||
}
|
||||
|
||||
// diffRects is the legacy convenience: diff then coalesce. Used by paths
|
||||
// that don't go through the CopyRect detector and by tests that exercise
|
||||
// the diff-plus-coalesce pipeline as one unit.
|
||||
func diffRects(prev, cur *image.RGBA, w, h, tileSize int) [][4]int {
|
||||
return coalesceRects(diffTiles(prev, cur, w, h, tileSize))
|
||||
}
|
||||
|
||||
// coalesceRects merges adjacent dirty tiles into larger rectangles to cut
|
||||
// per-rect framing overhead. Input must be tile-ordered (top-to-bottom rows,
|
||||
// left-to-right within each row), as produced by diffRects. Two passes:
|
||||
// 1. Horizontal: within a row, merge tiles whose x-extents touch.
|
||||
// 2. Vertical: merge a row's run with the run directly above it when they
|
||||
// share the same [x, x+w] extent and are vertically adjacent.
|
||||
//
|
||||
// Larger merged rects still encode correctly: Hextile-solid and Zlib paths
|
||||
// both work on arbitrary sizes, and uniform-tile detection still fires when
|
||||
// the merged region happens to be a single colour.
|
||||
func coalesceRects(in [][4]int) [][4]int {
|
||||
if len(in) < 2 {
|
||||
return in
|
||||
}
|
||||
c := newRectCoalescer(len(in))
|
||||
c.curY = in[0][1]
|
||||
for _, r := range in {
|
||||
c.consume(r)
|
||||
}
|
||||
c.flushCurrentRow()
|
||||
return c.out
|
||||
}
|
||||
|
||||
// rectCoalescer is the working state for coalesceRects, lifted out so the
|
||||
// algorithm can be split across small methods without long parameter lists
|
||||
// and to keep each method's cognitive complexity below Sonar's threshold.
|
||||
type rectCoalescer struct {
|
||||
out [][4]int
|
||||
prevRowStart, prevRowEnd int
|
||||
curRowStart int
|
||||
curY int
|
||||
}
|
||||
|
||||
func newRectCoalescer(capacity int) *rectCoalescer {
|
||||
return &rectCoalescer{out: make([][4]int, 0, capacity)}
|
||||
}
|
||||
|
||||
// consume processes one rect from the (row-ordered) input.
|
||||
func (c *rectCoalescer) consume(r [4]int) {
|
||||
if r[1] != c.curY {
|
||||
c.flushCurrentRow()
|
||||
c.prevRowEnd = len(c.out)
|
||||
c.curRowStart = len(c.out)
|
||||
c.curY = r[1]
|
||||
}
|
||||
if c.tryHorizontalMerge(r) {
|
||||
return
|
||||
}
|
||||
c.out = append(c.out, r)
|
||||
}
|
||||
|
||||
// tryHorizontalMerge extends the last run in the current row when r is
|
||||
// vertically aligned and horizontally adjacent to it.
|
||||
func (c *rectCoalescer) tryHorizontalMerge(r [4]int) bool {
|
||||
if len(c.out) <= c.curRowStart {
|
||||
return false
|
||||
}
|
||||
last := &c.out[len(c.out)-1]
|
||||
if last[1] == r[1] && last[3] == r[3] && last[0]+last[2] == r[0] {
|
||||
last[2] += r[2]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flushCurrentRow merges each run in the current row with any run from the
|
||||
// previous row that has identical x extent and is vertically adjacent.
|
||||
func (c *rectCoalescer) flushCurrentRow() {
|
||||
i := c.curRowStart
|
||||
for i < len(c.out) {
|
||||
if c.mergeWithPrevRow(i) {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// mergeWithPrevRow tries to extend a previous-row run downward to absorb
|
||||
// out[i]. Returns true and removes out[i] from the slice on success.
|
||||
func (c *rectCoalescer) mergeWithPrevRow(i int) bool {
|
||||
for j := c.prevRowStart; j < c.prevRowEnd; j++ {
|
||||
if c.out[j][0] == c.out[i][0] &&
|
||||
c.out[j][2] == c.out[i][2] &&
|
||||
c.out[j][1]+c.out[j][3] == c.out[i][1] {
|
||||
c.out[j][3] += c.out[i][3]
|
||||
copy(c.out[i:], c.out[i+1:])
|
||||
c.out = c.out[:len(c.out)-1]
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tileChanged(prev, cur *image.RGBA, x, y, w, h int) bool {
|
||||
stride := prev.Stride
|
||||
for row := y; row < y+h; row++ {
|
||||
off := row*stride + x*4
|
||||
end := off + w*4
|
||||
prevRow := prev.Pix[off:end]
|
||||
curRow := cur.Pix[off:end]
|
||||
if !bytes.Equal(prevRow, curRow) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tileIsUniform reports whether every pixel in the given rectangle of img is
|
||||
// the same RGBA value, and returns that pixel packed as 0xRRGGBBAA when so.
|
||||
// Uses uint32 comparisons across rows; returns early on the first mismatch.
|
||||
func tileIsUniform(img *image.RGBA, x, y, w, h int) (uint32, bool) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
stride := img.Stride
|
||||
base := y*stride + x*4
|
||||
first := *(*uint32)(unsafe.Pointer(&img.Pix[base]))
|
||||
rowBytes := w * 4
|
||||
for row := 0; row < h; row++ {
|
||||
p := base + row*stride
|
||||
for col := 0; col < rowBytes; col += 4 {
|
||||
if *(*uint32)(unsafe.Pointer(&img.Pix[p+col])) != first {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return first, true
|
||||
}
|
||||
|
||||
// tightState holds the per-session JPEG scratch buffer and reused encoders
|
||||
// so per-rect encoding stays alloc-free in the steady state.
|
||||
type tightState struct {
|
||||
jpegBuf *bytes.Buffer
|
||||
zlib *zlibState
|
||||
scratch []byte // RGB-packed pixel scratch for JPEG and Basic paths.
|
||||
// colorSeen is reused by sampledColorCount per rect; cleared via the Go
|
||||
// runtime's map-clear fast path to avoid a fresh allocation each call.
|
||||
colorSeen map[uint32]struct{}
|
||||
// jpegQualityOverride forces a fixed JPEG quality on every rect when
|
||||
// non-zero (set from the client's QualityLevel pseudo-encoding). Zero
|
||||
// falls back to the area-based tiers in tightQualityFor.
|
||||
jpegQualityOverride int
|
||||
// qualityLevel and compressLevel are the 0..9 levels currently applied,
|
||||
// or -1 if the client did not express a preference. Used to decide
|
||||
// whether a SetEncodings refresh needs to recreate the tight state.
|
||||
qualityLevel int
|
||||
compressLevel int
|
||||
// pendingZlibReset becomes true when this tightState replaces an
|
||||
// in-use one (e.g. CompressLevel change mid-session). The next Basic
|
||||
// rect we emit ORs the stream-0 reset bit into its sub-encoding byte
|
||||
// so the client's inflater drops its now-stale dictionary; cleared
|
||||
// after one emission.
|
||||
pendingZlibReset bool
|
||||
}
|
||||
|
||||
func newTightState() *tightState {
|
||||
return newTightStateWithLevels(-1, -1)
|
||||
}
|
||||
|
||||
// newTightStateWithLevels builds a tightState whose zlib stream and JPEG
|
||||
// quality reflect the client's QualityLevel / CompressLevel pseudo-encodings.
|
||||
// Pass -1 for either level to keep our defaults (BestSpeed zlib and the
|
||||
// area-tiered JPEG quality in tightQualityFor).
|
||||
func newTightStateWithLevels(qualityLevel, compressLevel int) *tightState {
|
||||
return &tightState{
|
||||
jpegBuf: &bytes.Buffer{},
|
||||
zlib: newZlibStateLevel(zlibLevelFor(compressLevel)),
|
||||
colorSeen: make(map[uint32]struct{}, 64),
|
||||
jpegQualityOverride: jpegQualityForLevel(qualityLevel),
|
||||
qualityLevel: qualityLevel,
|
||||
compressLevel: compressLevel,
|
||||
}
|
||||
}
|
||||
|
||||
// jpegQualityForLevel maps a 0..9 client preference to a JPEG quality value.
|
||||
// Returns 0 when no preference is set (-1), letting the encoder fall back
|
||||
// to the area-based tiers. The encoder lowers this dynamically when the
|
||||
// socket is backpressured, so this routine emits the unclamped, client-
|
||||
// requested value.
|
||||
func jpegQualityForLevel(level int) int {
|
||||
if level < 0 {
|
||||
return 0
|
||||
}
|
||||
if level > 9 {
|
||||
level = 9
|
||||
}
|
||||
return 30 + level*7
|
||||
}
|
||||
|
||||
// zlibLevelFor maps a 0..9 client preference to a zlib compression level.
|
||||
// Level 0 ("no compression") would emit larger output than input on most
|
||||
// rects, so we floor to BestSpeed (1). -1 (no preference) also picks
|
||||
// BestSpeed: matches the historical default before the pseudo-encoding
|
||||
// was honoured.
|
||||
func zlibLevelFor(level int) int {
|
||||
if level < 1 {
|
||||
return zlib.BestSpeed
|
||||
}
|
||||
if level > zlib.BestCompression {
|
||||
return zlib.BestCompression
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// tightMaxLength is the maximum payload size representable in the Tight
|
||||
// compact length prefix (RFB §7.7.6: 22 bits, three 7+7+8 bit groups).
|
||||
// Exceeding this would silently truncate the high byte; callers must fall
|
||||
// back to a different encoding when an attempt would overflow.
|
||||
const tightMaxLength = (1 << 22) - 1
|
||||
|
||||
// encodeTightRect emits a single Tight-encoded rect. Picks Fill for uniform
|
||||
// content, JPEG for photo-like rects above a size and color-count threshold,
|
||||
// and Basic+zlib otherwise. When Tight's 22-bit length cap would be exceeded
|
||||
// (huge full-frame rects under bad compression), falls back to Raw. Returns
|
||||
// the rect header + body (no FramebufferUpdate header).
|
||||
func encodeTightRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, t *tightState) []byte {
|
||||
if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform {
|
||||
return encodeTightFill(x, y, w, h, byte(pixel), byte(pixel>>8), byte(pixel>>16))
|
||||
}
|
||||
if w*h >= tightJPEGMinArea && sampledColorCountInto(t.colorSeen, img, x, y, w, h, tightJPEGMinColors) >= tightJPEGMinColors {
|
||||
if buf, ok := encodeTightJPEG(img, x, y, w, h, t); ok {
|
||||
return buf
|
||||
}
|
||||
}
|
||||
if buf, ok := encodeTightBasic(img, x, y, w, h, t); ok {
|
||||
return buf
|
||||
}
|
||||
// Fall back to Raw rect body (skip the 4-byte FU header that encodeRawRect
|
||||
// prepends, since callers compose their own FU header).
|
||||
return encodeRawRect(img, pf, x, y, w, h)[4:]
|
||||
}
|
||||
|
||||
func writeTightRectHeader(buf []byte, x, y, w, h int) {
|
||||
binary.BigEndian.PutUint16(buf[0:2], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(encTight))
|
||||
}
|
||||
|
||||
// appendTightLength encodes a Tight compact length prefix (1, 2, or 3 bytes
|
||||
// LE-ish, top bit of each byte signals continuation). Lengths exceeding
|
||||
// tightMaxLength would silently truncate the high byte; callers must clamp
|
||||
// or fall back before reaching here.
|
||||
func appendTightLength(buf []byte, n int) []byte {
|
||||
if n < 0 || n > tightMaxLength {
|
||||
panic(fmt.Sprintf("tight length out of range: %d", n))
|
||||
}
|
||||
b0 := byte(n & 0x7f)
|
||||
if n <= 0x7f {
|
||||
return append(buf, b0)
|
||||
}
|
||||
b0 |= 0x80
|
||||
b1 := byte((n >> 7) & 0x7f)
|
||||
if n <= 0x3fff {
|
||||
return append(buf, b0, b1)
|
||||
}
|
||||
b1 |= 0x80
|
||||
// High group is 8 bits per spec, but our cap guarantees the top 2 bits
|
||||
// are zero; mask defensively.
|
||||
b2 := byte((n >> 14) & 0xff)
|
||||
return append(buf, b0, b1, b2)
|
||||
}
|
||||
|
||||
// encodeTightFill emits a uniform rect: 12-byte rect header + 1-byte
|
||||
// subenc (0x80) + 3-byte RGB pixel. Tight Fill always uses 24-bit RGB
|
||||
// regardless of the negotiated pixel format.
|
||||
func encodeTightFill(x, y, w, h int, r, g, b byte) []byte {
|
||||
buf := make([]byte, 12+1+3)
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf[12] = tightFillSubenc
|
||||
buf[13] = r
|
||||
buf[14] = g
|
||||
buf[15] = b
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeTightJPEG compresses the rect as a baseline JPEG. Returns ok=false
|
||||
// if the encoder errors so the caller can fall back to Basic.
|
||||
func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) {
|
||||
t.jpegBuf.Reset()
|
||||
sub := img.SubImage(image.Rect(img.Rect.Min.X+x, img.Rect.Min.Y+y, img.Rect.Min.X+x+w, img.Rect.Min.Y+y+h))
|
||||
q := t.jpegQualityOverride
|
||||
if q == 0 {
|
||||
q = tightQualityFor(w * h)
|
||||
}
|
||||
if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: q}); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
jpegBytes := t.jpegBuf.Bytes()
|
||||
if len(jpegBytes) > tightMaxLength {
|
||||
return nil, false
|
||||
}
|
||||
buf := make([]byte, 0, 12+1+3+len(jpegBytes))
|
||||
buf = buf[:12]
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf = append(buf, tightJPEGSubenc)
|
||||
buf = appendTightLength(buf, len(jpegBytes))
|
||||
buf = append(buf, jpegBytes...)
|
||||
return buf, true
|
||||
}
|
||||
|
||||
// encodeTightBasic emits Basic+zlib with the no-op (CopyFilter) filter.
|
||||
// Pixels are sent as 24-bit RGB ("TPIXEL" format) which most clients
|
||||
// negotiate when the server advertises 32bpp true colour. Streams under
|
||||
// 12 bytes ship uncompressed per RFB Tight spec. Returns ok=false when the
|
||||
// compressed payload would exceed Tight's 22-bit length cap or when zlib
|
||||
// errors, signalling the caller to fall back to Raw.
|
||||
func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) {
|
||||
pixelStream := w * h * 3
|
||||
if cap(t.scratch) < pixelStream {
|
||||
t.scratch = make([]byte, pixelStream)
|
||||
}
|
||||
scratch := t.scratch[:pixelStream]
|
||||
stride := img.Stride
|
||||
off := 0
|
||||
for row := y; row < y+h; row++ {
|
||||
p := row*stride + x*4
|
||||
for col := 0; col < w; col++ {
|
||||
scratch[off+0] = img.Pix[p]
|
||||
scratch[off+1] = img.Pix[p+1]
|
||||
scratch[off+2] = img.Pix[p+2]
|
||||
p += 4
|
||||
off += 3
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-encoding byte: stream 0, basic encoding (top nibble = 0x40 =
|
||||
// explicit filter follows). The low nibble carries per-stream reset
|
||||
// flags; bit 0 here tells the client to reset its stream-0 inflater
|
||||
// when our deflater was just recreated.
|
||||
subenc := byte(tightBasicFilter)
|
||||
if t.pendingZlibReset {
|
||||
subenc |= 0x01
|
||||
t.pendingZlibReset = false
|
||||
}
|
||||
filter := byte(tightFilterCopy)
|
||||
|
||||
if pixelStream < 12 {
|
||||
buf := make([]byte, 0, 12+2+pixelStream)
|
||||
buf = buf[:12]
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf = append(buf, subenc, filter)
|
||||
buf = append(buf, scratch...)
|
||||
return buf, true
|
||||
}
|
||||
|
||||
z := t.zlib
|
||||
z.buf.Reset()
|
||||
if _, err := z.w.Write(scratch); err != nil {
|
||||
log.Debugf("tight zlib write: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
if err := z.w.Flush(); err != nil {
|
||||
log.Debugf("tight zlib flush: %v", err)
|
||||
return nil, false
|
||||
}
|
||||
compressed := z.buf.Bytes()
|
||||
if len(compressed) > tightMaxLength {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, 12+2+5+len(compressed))
|
||||
buf = buf[:12]
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf = append(buf, subenc, filter)
|
||||
buf = appendTightLength(buf, len(compressed))
|
||||
buf = append(buf, compressed...)
|
||||
return buf, true
|
||||
}
|
||||
|
||||
func tightQualityFor(pixels int) int {
|
||||
switch {
|
||||
case pixels >= tightJPEGLargePixels:
|
||||
return tightJPEGQualityLarge
|
||||
case pixels >= tightJPEGMediumPixels:
|
||||
return tightJPEGQualityMedium
|
||||
default:
|
||||
return tightJPEGQuality
|
||||
}
|
||||
}
|
||||
|
||||
// sampledColorCountInto estimates distinct-colour count by checking up to
|
||||
// maxColors samples. The caller-provided `seen` map is cleared and reused so
|
||||
// per-rect Tight encoding stays alloc-free. Cheap O(maxColors) per call.
|
||||
func sampledColorCountInto(seen map[uint32]struct{}, img *image.RGBA, x, y, w, h, maxColors int) int {
|
||||
clear(seen)
|
||||
stride := img.Stride
|
||||
step := max((w*h)/(maxColors*4), 1)
|
||||
var idx int
|
||||
for row := 0; row < h; row++ {
|
||||
p := (y+row)*stride + x*4
|
||||
for col := 0; col < w; col++ {
|
||||
if idx%step == 0 {
|
||||
px := *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4]))
|
||||
seen[px&0x00ffffff] = struct{}{}
|
||||
if len(seen) > maxColors {
|
||||
return len(seen)
|
||||
}
|
||||
}
|
||||
idx++
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// zlibState holds the persistent zlib writer and its output buffer, reused
|
||||
// across rects so steady-state Tight encoding stays alloc-free.
|
||||
type zlibState struct {
|
||||
buf *bytes.Buffer
|
||||
w *zlib.Writer
|
||||
// scratch stages the packed pixel stream for a rect before it is fed
|
||||
// to the deflater. Grown to the largest rect seen in the session and
|
||||
// reused to keep the steady-state encode allocation-free.
|
||||
scratch []byte
|
||||
}
|
||||
|
||||
func newZlibStateLevel(level int) *zlibState {
|
||||
buf := &bytes.Buffer{}
|
||||
w, _ := zlib.NewWriterLevel(buf, level)
|
||||
return &zlibState{buf: buf, w: w}
|
||||
}
|
||||
|
||||
func (z *zlibState) Close() error {
|
||||
return z.w.Close()
|
||||
}
|
||||
364
client/vnc/server/rfb_bench_test.go
Normal file
364
client/vnc/server/rfb_bench_test.go
Normal file
@@ -0,0 +1,364 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Representative frame sizes.
|
||||
var benchRects = []struct {
|
||||
name string
|
||||
w, h int
|
||||
}{
|
||||
{"1080p_full", 1920, 1080},
|
||||
{"720p_full", 1280, 720},
|
||||
{"256x256_tile", 256, 256},
|
||||
{"64x64_tile", 64, 64},
|
||||
}
|
||||
|
||||
func makeBenchImage(w, h int, seed int64) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
_, _ = r.Read(img.Pix)
|
||||
// Force alpha byte so the fast path and slow path produce identical output.
|
||||
for i := 3; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i] = 0xff
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func makeBenchImagePartial(w, h, changedRows int) (*image.RGBA, *image.RGBA) {
|
||||
prev := makeBenchImage(w, h, 1)
|
||||
cur := image.NewRGBA(prev.Rect)
|
||||
copy(cur.Pix, prev.Pix)
|
||||
if changedRows > h {
|
||||
changedRows = h
|
||||
}
|
||||
// Dirty the first `changedRows` rows.
|
||||
r := rand.New(rand.NewSource(2))
|
||||
_, _ = r.Read(cur.Pix[:changedRows*cur.Stride])
|
||||
for i := 3; i < len(cur.Pix); i += 4 {
|
||||
cur.Pix[i] = 0xff
|
||||
}
|
||||
return prev, cur
|
||||
}
|
||||
|
||||
func BenchmarkEncodeRawRect(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = encodeRawRect(img, pf, 0, 0, r.w, r.h)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodeTightRect(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
t := newTightState()
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = encodeTightRect(img, pf, 0, 0, r.w, r.h, t)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWritePixels isolates the per-pixel pack loop from the allocation
|
||||
// and FramebufferUpdate-header overhead.
|
||||
func BenchmarkWritePixels(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
dst := make([]byte, r.w*r.h*4)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
writePixels(dst, img, pf, rect{0, 0, r.w, r.h})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSwizzleBGRAtoRGBA(b *testing.B) {
|
||||
for _, r := range benchRects {
|
||||
size := r.w * r.h * 4
|
||||
src := make([]byte, size)
|
||||
dst := make([]byte, size)
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
_, _ = rng.Read(src)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
swizzleBGRAtoRGBA(dst, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSwizzleBGRAtoRGBANaive is the naive byte-by-byte implementation
|
||||
// that the Linux SHM capturer used before the uint32 rewrite, kept here so
|
||||
// we can compare the cost directly.
|
||||
func BenchmarkSwizzleBGRAtoRGBANaive(b *testing.B) {
|
||||
for _, r := range benchRects {
|
||||
size := r.w * r.h * 4
|
||||
src := make([]byte, size)
|
||||
dst := make([]byte, size)
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
_, _ = rng.Read(src)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j := 0; j < size; j += 4 {
|
||||
dst[j+0] = src[j+2]
|
||||
dst[j+1] = src[j+1]
|
||||
dst[j+2] = src[j+0]
|
||||
dst[j+3] = 0xff
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeUniformTile_TightFill measures the fast path for a uniform
|
||||
// 64×64 tile via Tight's Fill subencoding (16 wire bytes regardless of size).
|
||||
func BenchmarkEncodeUniformTile_TightFill(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
for i := 0; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i+0] = 0x33
|
||||
img.Pix[i+1] = 0x66
|
||||
img.Pix[i+2] = 0x99
|
||||
img.Pix[i+3] = 0xff
|
||||
}
|
||||
t := newTightState()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeTightRect(img, pf, 0, 0, 64, 64, t)
|
||||
bytesOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
}
|
||||
|
||||
func BenchmarkTileIsUniform(b *testing.B) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
for i := 0; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i+3] = 0xff
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = tileIsUniform(img, 0, 0, 64, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeManyTilesVsFullFrame exercises the bandwidth + CPU
|
||||
// trade-off that motivates the full-frame promotion path: encoding a burst
|
||||
// of N dirty 64×64 tiles as separate Tight rects vs emitting one big Tight
|
||||
// rect for the whole frame.
|
||||
func BenchmarkEncodeManyTilesVsFullFrame(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
const w, h = 1920, 1080
|
||||
img := makeBenchImage(w, h, 1)
|
||||
|
||||
// Build the list of every tile in the frame (worst case: entire screen dirty).
|
||||
var tiles [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := tileSize
|
||||
if ty+th > h {
|
||||
th = h - ty
|
||||
}
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
tiles = append(tiles, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
nTiles := len(tiles)
|
||||
|
||||
b.Run("per_tile_tight", func(b *testing.B) {
|
||||
t := newTightState()
|
||||
b.SetBytes(int64(w * h * 4))
|
||||
b.ReportAllocs()
|
||||
var totalOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
totalOut = 0
|
||||
for _, r := range tiles {
|
||||
out := encodeTightRect(img, pf, r[0], r[1], r[2], r[3], t)
|
||||
totalOut += len(out)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(totalOut), "wire_bytes")
|
||||
b.ReportMetric(float64(nTiles), "tiles")
|
||||
})
|
||||
|
||||
b.Run("full_frame_tight", func(b *testing.B) {
|
||||
t := newTightState()
|
||||
b.SetBytes(int64(w * h * 4))
|
||||
b.ReportAllocs()
|
||||
var totalOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeTightRect(img, pf, 0, 0, w, h, t)
|
||||
totalOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(totalOut), "wire_bytes")
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkShouldPromoteToFullFrame verifies the threshold check itself is
|
||||
// cheap. It runs on every frame, so regressions here hit all workloads.
|
||||
func BenchmarkShouldPromoteToFullFrame(b *testing.B) {
|
||||
const w, h = 1920, 1080
|
||||
s := &session{serverW: w, serverH: h}
|
||||
// Build a worst-case rect list (every tile dirty, 510 entries).
|
||||
var rects [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := tileSize
|
||||
if ty+th > h {
|
||||
th = h - ty
|
||||
}
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
rects = append(rects, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = s.shouldPromoteToFullFrame(rects)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeCoalescedVsPerTile compares per-tile encoding vs the
|
||||
// coalesced rect list emitted by diffRects, on a horizontal-band dirty
|
||||
// pattern (e.g. a scrolling status bar) where coalescing pays off.
|
||||
func BenchmarkEncodeCoalescedVsPerTile(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
const w, h = 1920, 1080
|
||||
img := makeBenchImage(w, h, 1)
|
||||
|
||||
// Dirty band: rows 200..264 (one tile-row), full width.
|
||||
var perTile [][4]int
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
perTile = append(perTile, [4]int{tx, 200, tw, tileSize})
|
||||
}
|
||||
coalesced := coalesceRects(append([][4]int(nil), perTile...))
|
||||
|
||||
b.Run("per_tile", func(b *testing.B) {
|
||||
t := newTightState()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
bytesOut = 0
|
||||
for _, r := range perTile {
|
||||
out := encodeTightRect(img, pf, r[0], r[1], r[2], r[3], t)
|
||||
bytesOut += len(out)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
b.ReportMetric(float64(len(perTile)), "rects")
|
||||
})
|
||||
|
||||
b.Run("coalesced", func(b *testing.B) {
|
||||
t := newTightState()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
bytesOut = 0
|
||||
for _, r := range coalesced {
|
||||
out := encodeTightRect(img, pf, r[0], r[1], r[2], r[3], t)
|
||||
bytesOut += len(out)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
b.ReportMetric(float64(len(coalesced)), "rects")
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCoalesceRects(b *testing.B) {
|
||||
const w, h = 1920, 1080
|
||||
// Worst case: every tile dirty.
|
||||
var allTiles [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := tileSize
|
||||
if ty+th > h {
|
||||
th = h - ty
|
||||
}
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
allTiles = append(allTiles, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
in := make([][4]int, len(allTiles))
|
||||
copy(in, allTiles)
|
||||
_ = coalesceRects(in)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeTight_Photo measures Tight on random/photographic content.
|
||||
// The internal sampledColorCount gate routes large many-colour rects to JPEG
|
||||
// at quality 70.
|
||||
func BenchmarkEncodeTight_Photo(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range []struct {
|
||||
name string
|
||||
w, h int
|
||||
}{
|
||||
{"256x256", 256, 256},
|
||||
{"512x512", 512, 512},
|
||||
{"1080p", 1920, 1080},
|
||||
} {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
b.Run(r.name+"/tight", func(b *testing.B) {
|
||||
t := newTightState()
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeTightRect(img, pf, 0, 0, r.w, r.h, t)
|
||||
bytesOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDiffRects(b *testing.B) {
|
||||
for _, r := range benchRects {
|
||||
prev, cur := makeBenchImagePartial(r.w, r.h, 100)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = diffRects(prev, cur, r.w, r.h, tileSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
274
client/vnc/server/scancodes.go
Normal file
274
client/vnc/server/scancodes.go
Normal file
@@ -0,0 +1,274 @@
|
||||
//go:build !js && !ios && !android
|
||||
|
||||
package server
|
||||
|
||||
// QEMU Extended Key Event carries hardware scancodes encoded as PC AT Set 1.
|
||||
// Single-byte codes cover the standard keys; the "extended" prefix 0xE0 is
|
||||
// merged into the high byte (so 0xE048 is the extended-Up arrow). This file
|
||||
// translates those scancodes into the per-platform identifiers each input
|
||||
// backend wants:
|
||||
//
|
||||
// - Linux uinput wants Linux KEY_* codes (defined in
|
||||
// linux/input-event-codes.h). uinput is what we use for virtual Xvfb
|
||||
// sessions on Linux.
|
||||
// - X11 XTest wants XKB keycodes, which on a standard layout equal
|
||||
// Linux KEY_* + 8 (the per-server offset between the Linux event code
|
||||
// and the X server's keycode space).
|
||||
// - Windows SendInput accepts the PC AT scancode directly via
|
||||
// KEYEVENTF_SCANCODE, so no mapping table is needed there; the
|
||||
// extended-key bit is set when the QEMU scancode high byte is 0xE0.
|
||||
// - macOS CGEventCreateKeyboardEvent takes a "virtual keycode" from
|
||||
// Apple's HID set, which is unrelated to PC AT and needs its own
|
||||
// table (see qemuToMacVK in input_darwin.go).
|
||||
//
|
||||
// Linux KEY_* codes. Only the ones we reference, since the full
|
||||
// linux/input-event-codes.h list isn't useful here. Naming mirrors the
|
||||
// existing constants in input_uinput_linux.go (mixed case, no underscores).
|
||||
const (
|
||||
keyEsc = 1
|
||||
key1 = 2
|
||||
key2 = 3
|
||||
key3 = 4
|
||||
key4 = 5
|
||||
key5 = 6
|
||||
key6 = 7
|
||||
key7 = 8
|
||||
key8 = 9
|
||||
key9 = 10
|
||||
key0 = 11
|
||||
keyMinus = 12
|
||||
keyEqual = 13
|
||||
keyBackspace = 14
|
||||
keyTab = 15
|
||||
keyQ = 16
|
||||
keyW = 17
|
||||
keyE = 18
|
||||
keyR = 19
|
||||
keyT = 20
|
||||
keyY = 21
|
||||
keyU = 22
|
||||
keyI = 23
|
||||
keyO = 24
|
||||
keyP = 25
|
||||
keyLeftBracket = 26
|
||||
keyRightBracket = 27
|
||||
keyEnter = 28
|
||||
keyLeftCtrl = 29
|
||||
keyA = 30
|
||||
keyS = 31
|
||||
keyD = 32
|
||||
keyF = 33
|
||||
keyG = 34
|
||||
keyH = 35
|
||||
keyJ = 36
|
||||
keyK = 37
|
||||
keyL = 38
|
||||
keySemicolon = 39
|
||||
keyApostrophe = 40
|
||||
keyGrave = 41
|
||||
keyLeftShift = 42
|
||||
keyBackslash = 43
|
||||
keyZ = 44
|
||||
keyX = 45
|
||||
keyC = 46
|
||||
keyV = 47
|
||||
keyB = 48
|
||||
keyN = 49
|
||||
keyM = 50
|
||||
keyComma = 51
|
||||
keyDot = 52
|
||||
keySlash = 53
|
||||
keyRightShift = 54
|
||||
keyKPAsterisk = 55
|
||||
keyLeftAlt = 56
|
||||
keySpace = 57
|
||||
keyCapsLock = 58
|
||||
keyF1 = 59
|
||||
keyF2 = 60
|
||||
keyF3 = 61
|
||||
keyF4 = 62
|
||||
keyF5 = 63
|
||||
keyF6 = 64
|
||||
keyF7 = 65
|
||||
keyF8 = 66
|
||||
keyF9 = 67
|
||||
keyF10 = 68
|
||||
keyNumLock = 69
|
||||
keyScrollLock = 70
|
||||
keyKP7 = 71
|
||||
keyKP8 = 72
|
||||
keyKP9 = 73
|
||||
keyKPMinus = 74
|
||||
keyKP4 = 75
|
||||
keyKP5 = 76
|
||||
keyKP6 = 77
|
||||
keyKPPlus = 78
|
||||
keyKP1 = 79
|
||||
keyKP2 = 80
|
||||
keyKP3 = 81
|
||||
keyKP0 = 82
|
||||
keyKPDot = 83
|
||||
key102nd = 86
|
||||
keyF11 = 87
|
||||
keyF12 = 88
|
||||
keyKPEnter = 96
|
||||
keyRightCtrl = 97
|
||||
keyKPSlash = 98
|
||||
keySysRq = 99
|
||||
keyRightAlt = 100
|
||||
keyHome = 102
|
||||
keyUp = 103
|
||||
keyPageUp = 104
|
||||
keyLeft = 105
|
||||
keyRight = 106
|
||||
keyEnd = 107
|
||||
keyDown = 108
|
||||
keyPageDown = 109
|
||||
keyInsert = 110
|
||||
keyDelete = 111
|
||||
keyMute = 113
|
||||
keyVolumeDown = 114
|
||||
keyVolumeUp = 115
|
||||
keyLeftMeta = 125
|
||||
keyRightMeta = 126
|
||||
keyCompose = 127
|
||||
)
|
||||
|
||||
// qemuToLinuxKey maps the PC AT Set 1 scancode QEMU sends to a Linux KEY_*
|
||||
// code. The high byte 0xE0 marks "extended" scancodes (arrows, the right-
|
||||
// side modifier keys, keypad enter/divide, browser keys, etc.).
|
||||
//
|
||||
// Keep this table dense so a reviewer sees the whole keyboard at a glance,
|
||||
// and so adding a new key is a single line.
|
||||
var qemuToLinuxKey = map[uint32]int{
|
||||
// Single-byte (non-extended) scancodes.
|
||||
0x01: keyEsc,
|
||||
0x02: key1,
|
||||
0x03: key2,
|
||||
0x04: key3,
|
||||
0x05: key4,
|
||||
0x06: key5,
|
||||
0x07: key6,
|
||||
0x08: key7,
|
||||
0x09: key8,
|
||||
0x0A: key9,
|
||||
0x0B: key0,
|
||||
0x0C: keyMinus,
|
||||
0x0D: keyEqual,
|
||||
0x0E: keyBackspace,
|
||||
0x0F: keyTab,
|
||||
0x10: keyQ,
|
||||
0x11: keyW,
|
||||
0x12: keyE,
|
||||
0x13: keyR,
|
||||
0x14: keyT,
|
||||
0x15: keyY,
|
||||
0x16: keyU,
|
||||
0x17: keyI,
|
||||
0x18: keyO,
|
||||
0x19: keyP,
|
||||
0x1A: keyLeftBracket,
|
||||
0x1B: keyRightBracket,
|
||||
0x1C: keyEnter,
|
||||
0x1D: keyLeftCtrl,
|
||||
0x1E: keyA,
|
||||
0x1F: keyS,
|
||||
0x20: keyD,
|
||||
0x21: keyF,
|
||||
0x22: keyG,
|
||||
0x23: keyH,
|
||||
0x24: keyJ,
|
||||
0x25: keyK,
|
||||
0x26: keyL,
|
||||
0x27: keySemicolon,
|
||||
0x28: keyApostrophe,
|
||||
0x29: keyGrave,
|
||||
0x2A: keyLeftShift,
|
||||
0x2B: keyBackslash,
|
||||
0x2C: keyZ,
|
||||
0x2D: keyX,
|
||||
0x2E: keyC,
|
||||
0x2F: keyV,
|
||||
0x30: keyB,
|
||||
0x31: keyN,
|
||||
0x32: keyM,
|
||||
0x33: keyComma,
|
||||
0x34: keyDot,
|
||||
0x35: keySlash,
|
||||
0x36: keyRightShift,
|
||||
0x37: keyKPAsterisk,
|
||||
0x38: keyLeftAlt,
|
||||
0x39: keySpace,
|
||||
0x3A: keyCapsLock,
|
||||
0x3B: keyF1,
|
||||
0x3C: keyF2,
|
||||
0x3D: keyF3,
|
||||
0x3E: keyF4,
|
||||
0x3F: keyF5,
|
||||
0x40: keyF6,
|
||||
0x41: keyF7,
|
||||
0x42: keyF8,
|
||||
0x43: keyF9,
|
||||
0x44: keyF10,
|
||||
0x45: keyNumLock,
|
||||
0x46: keyScrollLock,
|
||||
0x47: keyKP7,
|
||||
0x48: keyKP8,
|
||||
0x49: keyKP9,
|
||||
0x4A: keyKPMinus,
|
||||
0x4B: keyKP4,
|
||||
0x4C: keyKP5,
|
||||
0x4D: keyKP6,
|
||||
0x4E: keyKPPlus,
|
||||
0x4F: keyKP1,
|
||||
0x50: keyKP2,
|
||||
0x51: keyKP3,
|
||||
0x52: keyKP0,
|
||||
0x53: keyKPDot,
|
||||
0x56: key102nd,
|
||||
0x57: keyF11,
|
||||
0x58: keyF12,
|
||||
|
||||
// Extended (0xE0-prefixed) scancodes.
|
||||
0xE01C: keyKPEnter,
|
||||
0xE01D: keyRightCtrl,
|
||||
0xE020: keyMute,
|
||||
0xE02E: keyVolumeDown,
|
||||
0xE030: keyVolumeUp,
|
||||
0xE035: keyKPSlash,
|
||||
0xE037: keySysRq, // PrintScreen
|
||||
0xE038: keyRightAlt,
|
||||
0xE047: keyHome,
|
||||
0xE048: keyUp,
|
||||
0xE049: keyPageUp,
|
||||
0xE04B: keyLeft,
|
||||
0xE04D: keyRight,
|
||||
0xE04F: keyEnd,
|
||||
0xE050: keyDown,
|
||||
0xE051: keyPageDown,
|
||||
0xE052: keyInsert,
|
||||
0xE053: keyDelete,
|
||||
0xE05B: keyLeftMeta,
|
||||
0xE05C: keyRightMeta,
|
||||
0xE05D: keyCompose,
|
||||
}
|
||||
|
||||
// qemuScancodeToLinuxKey is the lookup the uinput and X11 paths use.
|
||||
// Returns 0 (which Linux treats as KEY_RESERVED) when the scancode has no
|
||||
// mapping, signalling "fall back to the keysym path".
|
||||
func qemuScancodeToLinuxKey(scancode uint32) int {
|
||||
return qemuToLinuxKey[scancode]
|
||||
}
|
||||
|
||||
// qemuScancodeIsExtended reports whether a QEMU scancode is in the
|
||||
// 0xE0-prefixed extended range. Used by Windows SendInput to set the
|
||||
// KEYEVENTF_EXTENDEDKEY flag.
|
||||
func qemuScancodeIsExtended(scancode uint32) bool {
|
||||
return scancode&0xFF00 == 0xE000
|
||||
}
|
||||
|
||||
// qemuScancodeLowByte returns the byte SendInput's wScan field actually
|
||||
// stores: the low byte of the scancode regardless of any extended prefix.
|
||||
func qemuScancodeLowByte(scancode uint32) uint16 {
|
||||
return uint16(scancode & 0xFF)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user