diff --git a/README.md b/README.md index a8b174e..8270904 100644 --- a/README.md +++ b/README.md @@ -24,11 +24,13 @@ The whole tool is written with core Perl modules only, so it runs without extra - A gateway SOCKS port that follows the first alive proxy. - Daemon mode with pidfile, status, and stop commands. - Remote and local teardown that kills leftover gs-netcat processes. -- Offline self-tests that cover config parsing, command building, and the SOCKS handshake. +- Optional SOCKS5 username/password auth on the gateway port. +- Optional SHA-256 pinning of the remote gs-netcat binary. +- Offline self-tests that cover config parsing, command building, secret redaction, the single-instance lock, gateway auth, and the SOCKS handshake. ## Requirements -- Perl 5 with the core modules `Getopt::Long`, `IO::Socket::INET`, `IO::Select`, `POSIX`, `HTTP::Tiny`, `JSON::PP`, `Time::HiRes`, `File::Spec`, `Errno`, and `File::Basename`. +- Perl 5 with the core modules `Getopt::Long`, `IO::Socket::INET`, `IO::Select`, `POSIX`, `HTTP::Tiny`, `JSON::PP`, `Time::HiRes`, `File::Spec`, `Errno`, `File::Basename`, `Fcntl`, and `IO::Handle`. - The `gs-netcat` binary in `PATH`, or a path supplied with `--gs-netcat`. - Network access from the MCP endpoint so the remote bring-up command can download and run gs-netcat. - A reachable MCP endpoint that exposes the `Tools_Shell_Command` tool. @@ -38,6 +40,7 @@ The whole tool is written with core Perl modules only, so it runs without extra - Copy `gsproxy.pl` somewhere on the host and make it executable with `chmod +x gsproxy.pl`. - Make sure `gs-netcat` is installed and on the `PATH`. - Create a `gsproxy.csv` next to the script, or point `--config` at another file. +- The script runs with `umask 0077`; runtime files are created mode 0600. ## Configuration @@ -64,6 +67,8 @@ https://mcp.other.org,topsecret9,20002,beta The `endpoint` may be a base URL or a full MCP URL. If it does not already end in `/gradio_api/mcp` or `/mcp`, the value of `--mcp-path` is appended. +Keep the config file mode 0600; the script warns when it is group/world readable. + ## Usage Run the tool as a daemon: @@ -91,6 +96,7 @@ The script accepts these command line options: - `--config FILE` sets the CSV config path. The default is `gsproxy.csv` next to the script. - `--gateway-port PORT` sets the gateway SOCKS port. The default is `1080`. - `--bind ADDR` sets the address the gateway listens on. The default is `0.0.0.0`. +- `--gateway-auth FILE` requires SOCKS5 username/password auth on the gateway. The first non-empty, non-comment line of FILE is `user:pass`; keep the file mode 0600. - `--interval SEC` sets the monitor interval in seconds. The default is `30`. - `--timeout SEC` sets the per-check timeout in seconds. The default is `10`. - `--mcp-timeout SEC` sets the MCP shell command timeout in seconds. The default is `60`. @@ -98,6 +104,7 @@ The script accepts these command line options: - `--test-port PORT` sets the port probed through each SOCKS proxy. The default is `443`. - `--workdir DIR` sets the remote working directory for the MCP call. The default is `.`. - `--gs-netcat PATH` sets the gs-netcat binary. The default is `gs-netcat` from `PATH`. +- `--target-sha256 HASH` pins the SHA-256 of the remote gs-netcat binary (64 hex chars). The remote bring-up verifies the extracted binary before running it; a mismatch aborts the bring-up. - `--log FILE` sets the log file. The default is `gsproxy.log` next to the script. - `--pidfile FILE` sets the pidfile. The default is `gsproxy.pid` next to the script. - `--state FILE` sets the runtime state file. The default is `gsproxy.state` next to the script. @@ -109,7 +116,7 @@ The script accepts these command line options: - `--once` runs one monitor pass and then exits. - `--list` parses the config, prints the rows, and exits. - `--status` reports whether the daemon is running. -- `--stop` stops a running daemon and tears down proxies. +- `--stop` stops running daemons and tears down proxies. - `--selftest` runs the offline self-tests. - `-h, --help` shows the help text. @@ -136,6 +143,8 @@ On every interval the monitor checks each proxy, logs only status changes, and w The gateway listens, forks per connection, and reads the target port from the state file. It opens a TCP connection to that port and copies bytes in both directions with `IO::Select`, shutting down each direction as its side closes. If the state file has no valid port, the connection is dropped. +Without `--gateway-auth` the gateway is a transparent relay. With `--gateway-auth` the gateway performs the SOCKS5 username/password exchange itself (RFC 1929), then greets the upstream proxy and forwards the client's CONNECT request, so only authenticated clients can route through it. + ### Teardown Stopping the tool cleans up both sides. Locally it scans `/proc` for gs-netcat processes that match the `-D -p PORT` shape and sends them `TERM`. Remotely it runs a shell command over MCP that scans `/proc` for processes whose environment contains `GSOCKET_ARGS=-lSD -s SECRET` and kills them. @@ -148,6 +157,7 @@ Stopping the tool cleans up both sides. Locally it scans `/proc` for gs-netcat p - List config rows: `./gsproxy.pl --list` - One pass, foreground: `./gsproxy.pl --once --no-daemon` - Run self-tests: `./gsproxy.pl --selftest` +- Run with gateway auth: `./gsproxy.pl --gateway-auth /etc/gsproxy.auth` ## Files @@ -155,6 +165,9 @@ Stopping the tool cleans up both sides. Locally it scans `/proc` for gs-netcat p - `gsproxy.log` is the append-only log, one UTC-stamped line per event. - `gsproxy.pid` holds the daemon and gateway process ids. - `gsproxy.state` holds the first alive port on the first line and all alive ports on the second. +- `gsproxy.auth` (with `--gateway-auth`) holds one `user:pass` line. + +All runtime files are created mode 0600. ## Logging @@ -166,27 +179,40 @@ Every event is written with a UTC timestamp in the form `[YYYY-MM-DDTHH:MM:SSZ] - It re-reads the state file on every new connection, so failover is immediate. - A dead or missing target port causes the connection to be dropped without reply. - Each connection is handled by a forked child, keeping the listener responsive. +- With `--gateway-auth`, clients must complete a SOCKS5 username/password login before any relay happens. ## Self-tests -The `--selftest` mode runs offline checks and prints `PASS` or `FAIL` per case. It writes a temporary CSV, a temporary state file, and a temporary log under `/tmp`. It covers config parsing, command string quoting, gs-netcat process discovery, live and dead SOCKS detection, and a full gateway relay against a fake SOCKS server. It exits non-zero if any check fails. +The `--selftest` mode runs offline checks and prints `PASS` or `FAIL` per case. It writes a temporary CSV, a temporary state file, and a temporary log under `/tmp`. It covers config parsing, command string quoting, gs-netcat process discovery, secret redaction, the single-instance lock, zombie detection, gateway auth accept/reject, live and dead SOCKS detection, and a full gateway relay (with and without auth) against a fake SOCKS server. It exits non-zero if any check fails. + +## Hardening + +- **File permissions.** The script runs with `umask 0077`, creates the log, pidfile, state, and auth files with mode 0600, and tightens pre-existing files at startup. It warns when the CSV config or the gateway auth file is group/world readable. +- **Secret redaction.** Every log line passes through a redactor that replaces configured secrets with `**`. This includes MCP tool results that echo the full remote command, so secrets no longer leak into the log. +- **Single instance.** An advisory `flock` on the pidfile plus a `/proc` scan for already-daemonized gsproxy processes prevents the overlapping-instance failure mode. A second instance refuses to start, and `--once`/foreground runs refuse while a daemon is active. +- **Gateway supervision.** The gateway child reports bind success through a pipe before serving. A bind failure aborts startup instead of leaving a half-alive instance, and the monitor restarts a crashed gateway (reaping zombies on every pass). +- **Verified stop.** `--stop` only signals processes that are really gsproxy instances, waits up to five seconds for them to exit, escalates to `KILL` if needed, and also finds daemons that are missing from the pidfile. +- **Gateway auth.** `--gateway-auth FILE` turns the gateway into a SOCKS5 username/password front-end. Without it the gateway is an open relay, and a warning is logged whenever the gateway binds to a non-loopback address. +- **Binary pinning.** `--target-sha256 HASH` verifies the downloaded gs-netcat binary on the remote host before it runs, so a tampered download fails the bring-up. ## Security notes -- Secrets are masked in logs and list output, so only the first two characters appear. +- Secrets are masked in list output and redacted from every log line, including MCP tool results that echo remote commands. +- All runtime files (log, pidfile, state, gateway auth file) are created with mode 0600. - The remote process is deliberately renamed to `python` to hide on the target host. - The `--insecure` flag turns off TLS verification and should be used only when the MCP endpoint is trusted. -- The gateway binds to `0.0.0.0` by default. Restrict `--bind` if the host should not expose the proxy to the network. +- The gateway binds to `0.0.0.0` by default and is an open relay unless `--gateway-auth` is set. Restrict `--bind` to `127.0.0.1` or enable auth if the host should not expose the proxy to the network. - Anyone who can reach the gateway SOCKS port can route through the current proxy, so the port should be firewalled. ## Troubleshooting - If the script reports no usable rows, check that the CSV has valid `endpoint,secret,port` fields and a numeric port. -- If the daemon will not start, run `--status` to see whether it is already running, then use `--stop` first. +- If the daemon will not start, run `--status` to see whether it is already running, then use `--stop` first. A live daemon detected in `/proc` or a locked pidfile also refuses startup. - If MCP bring-up fails, confirm the endpoint exposes `Tools_Shell_Command` and try `--insecure` only if the certificate is the problem. - If no proxy becomes alive, run `--list` to confirm the rows and check that `gs-netcat` is on the `PATH`. - If the gateway has no target, watch the log for `SOCKS ... is dead` lines to see which proxies are failing. +- If the gateway port is already in use, the script aborts at startup with `gateway failed to start` instead of lingering. ## Credits -Created by Luxferre in 2026, released into the public domain with no warranties. +Created by Luxferre in 2026, released into the public domain with no warranties. \ No newline at end of file diff --git a/gsproxy.pl b/gsproxy.pl index d143d4a..1459180 100755 --- a/gsproxy.pl +++ b/gsproxy.pl @@ -9,6 +9,10 @@ # 3. monitors each local SOCKS proxy and logs every status change; # then exposes a gateway SOCKS port that relays to the first alive proxy # +# Hardening: single-instance flock, secret redaction in logs, 0600 files, +# gateway bind verification and supervision, verified --stop, optional +# SOCKS5 user/pass auth on the gateway, optional remote binary pinning. +# # Core modules only, no deps # # Created by Luxferre in 2026, released into the public domain @@ -24,7 +28,11 @@ use JSON::PP; use Time::HiRes qw(sleep); use File::Spec; use Errno qw(EINTR EAGAIN); -use File::Basename qw(dirname); +use File::Basename qw(dirname basename); +use Fcntl qw(:flock); +use IO::Handle; + +umask 0077; my $NL = chr(10); my $SQ = chr(39); @@ -33,66 +41,80 @@ my $BS = chr(92); my $SCRIPT_DIR = dirname(File::Spec->rel2abs($0)); my %opt = ( - config => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.csv'), - log => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.log'), - pidfile => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.pid'), - state => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.state'), - gateway => 1080, - bind => '0.0.0.0', - interval => 30, - timeout => 10, - mcp_timeout => 60, - test_host => 'www.google.com', - test_port => 443, - workdir => '.', - gs_netcat => 'gs-netcat', - mcp_path => '/gradio_api/mcp/', - insecure => 0, - verbose => 0, - no_mcp => 0, - no_local => 0, - no_bringup => 0, - list => 0, - daemon => 1, - once => 0, - stop => 0, - status => 0, - selftest => 0, - help => 0, + config => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.csv'), + log => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.log'), + pidfile => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.pid'), + state => File::Spec->catfile($SCRIPT_DIR, 'gsproxy.state'), + gateway => 1080, + bind => '0.0.0.0', + interval => 30, + timeout => 10, + mcp_timeout => 60, + test_host => 'www.google.com', + test_port => 443, + workdir => '.', + gs_netcat => 'gs-netcat', + mcp_path => '/gradio_api/mcp/', + insecure => 0, + verbose => 0, + no_mcp => 0, + no_local => 0, + no_bringup => 0, + list => 0, + daemon => 1, + once => 0, + stop => 0, + status => 0, + selftest => 0, + help => 0, + gateway_auth => undef, + target_sha256 => undef, ); +my @secrets; +my $lock_fh; +my $gwpid; +my $last_gw_attempt = 0; +my ($gw_auth_user, $gw_auth_pass); + GetOptions( - 'config=s' => \$opt{config}, - 'log=s' => \$opt{log}, - 'pidfile=s' => \$opt{pidfile}, - 'state=s' => \$opt{state}, - 'gateway-port=i' => \$opt{gateway}, - 'bind=s' => \$opt{bind}, - 'interval=i' => \$opt{interval}, - 'timeout=i' => \$opt{timeout}, - 'mcp-timeout=i' => \$opt{mcp_timeout}, - 'test-host=s' => \$opt{test_host}, - 'test-port=i' => \$opt{test_port}, - 'workdir=s' => \$opt{workdir}, - 'gs-netcat=s' => \$opt{gs_netcat}, - 'insecure' => \$opt{insecure}, - 'verbose|v' => \$opt{verbose}, - 'no-mcp' => \$opt{no_mcp}, - 'no-local' => \$opt{no_local}, - 'no-bringup' => \$opt{no_bringup}, - 'list' => \$opt{list}, - 'daemon!' => \$opt{daemon}, - 'once' => \$opt{once}, - 'stop' => \$opt{stop}, - 'status' => \$opt{status}, - 'selftest' => \$opt{selftest}, - 'help|h' => \$opt{help}, + 'config=s' => \$opt{config}, + 'log=s' => \$opt{log}, + 'pidfile=s' => \$opt{pidfile}, + 'state=s' => \$opt{state}, + 'gateway-port=i' => \$opt{gateway}, + 'bind=s' => \$opt{bind}, + 'interval=i' => \$opt{interval}, + 'timeout=i' => \$opt{timeout}, + 'mcp-timeout=i' => \$opt{mcp_timeout}, + 'test-host=s' => \$opt{test_host}, + 'test-port=i' => \$opt{test_port}, + 'workdir=s' => \$opt{workdir}, + 'gs-netcat=s' => \$opt{gs_netcat}, + 'insecure' => \$opt{insecure}, + 'verbose|v' => \$opt{verbose}, + 'no-mcp' => \$opt{no_mcp}, + 'no-local' => \$opt{no_local}, + 'no-bringup' => \$opt{no_bringup}, + 'list' => \$opt{list}, + 'daemon!' => \$opt{daemon}, + 'once' => \$opt{once}, + 'stop' => \$opt{stop}, + 'status' => \$opt{status}, + 'selftest' => \$opt{selftest}, + 'gateway-auth=s' => \$opt{gateway_auth}, + 'target-sha256=s' => \$opt{target_sha256}, + 'help|h' => \$opt{help}, ) or usage(2); usage(0) if $opt{help}; +harden_file_perms(); selftest() if $opt{selftest}; if ($opt{stop}) { stop_daemon(); exit 0; } if ($opt{status}) { exit(do_status()); } +if (defined $opt{target_sha256} && $opt{target_sha256} !~ /^[0-9a-fA-F]{64}$/) { + die 'invalid --target-sha256: expected 64 hex characters' . $NL; +} sub usage { my ($code) = @_; @@ -106,6 +128,7 @@ Options: --config FILE CSV config: endpoint,secret,port[,label] (default ./gsproxy.csv) --gateway-port PORT Gateway SOCKS port mapped to first alive SOCKS (default 1080) --bind ADDR Address the gateway SOCKS listens on (default 0.0.0.0) + --gateway-auth FILE Require SOCKS5 user/pass auth on the gateway (FILE: user:pass) --interval SEC Monitor interval seconds (default 30) --timeout SEC Per-check timeout seconds (default 10) --mcp-timeout SEC MCP Tools_Shell_Command timeout (default 60) @@ -113,6 +136,7 @@ Options: --test-port PORT Port probed through each SOCKS (default 443) --workdir DIR Remote workdir for the MCP call (default .) --gs-netcat PATH gs-netcat binary (default: from PATH) + --target-sha256 HASH Verify remote gs-netcat SHA-256 before running (64 hex) --log FILE Log file (default ./gsproxy.log) --pidfile FILE Pidfile (default ./gsproxy.pid) --state FILE Runtime state file (default ./gsproxy.state) @@ -140,16 +164,41 @@ sub strip_eol { sub now_utc { return strftime('%Y-%m-%dT%H:%M:%SZ', gmtime(time())); } +sub mask { + my ($s) = @_; + $s = '' unless defined $s; + return '***' if length($s) <= 2; + return substr($s, 0, 2) . '***'; +} + +sub register_secrets { + push @secrets, @_; +} + +sub redact { + my ($s) = @_; + $s = '' unless defined $s; + for my $sec (@secrets) { + next unless defined $sec && length($sec) >= 3; + my $m = mask($sec); + $s =~ s/\Q$sec\E/$m/g; + } + return $s; +} + sub logmsg { my ($msg) = @_; - my $line = '[' . now_utc() . '] ' . $msg . $NL; - if (open my $fh, '>>', $opt{log}) { print $fh $line; close $fh; } + my $line = '[' . now_utc() . '] ' . redact($msg) . $NL; + if (open my $fh, '>>', $opt{log}) { + chmod 0600, $opt{log}; + print $fh $line; + close $fh; + } print STDERR $line if $opt{verbose}; } sub shq { - my ($s) = @_ - ; + my ($s) = @_; $s = '' unless defined $s; my $out = $SQ; for my $ch (split //, $s) { @@ -170,16 +219,9 @@ sub which { return undef; } -sub mask { - my ($s) = @_; - $s = '' unless defined $s; - return '***' if length($s) <= 2; - return substr($s, 0, 2) . '***'; -} - sub one_line { my ($s) = @_; - $s = '' unless defined $s; + $s = redact($s); $s =~ s/[[:space:]]+/ /g; return substr($s, 0, 300); } @@ -360,7 +402,12 @@ sub target_cmd { my ($secret) = @_; my $url = 'https://github.com/hackerschoice/binary/raw/refs/heads/main/gsocket/bin/gs-netcat_$(uname -m)-alpine.tar.gz'; my $args = shq('-lSD -s ' . $secret); - return qq{curl -fsSL $url | tar xz && GSOCKET_ARGS=$args bash -c 'exec -a python ./gs-netcat' && rm -f gs-netcat}; + my $cmd = qq{curl -fsSL $url | tar xz && }; + if ($opt{target_sha256}) { + $cmd .= qq{echo '$opt{target_sha256} gs-netcat' | sha256sum -c - && }; + } + $cmd .= qq{GSOCKET_ARGS=$args bash -c 'exec -a python ./gs-netcat' && rm -f gs-netcat}; + return $cmd; } sub teardown_cmd { @@ -381,6 +428,45 @@ sub read_proc_file { return $c; } +sub proc_state { + my ($pid) = @_; + my $raw = read_proc_file('/proc/' . $pid . '/stat'); + return undef unless defined $raw; + my $i = rindex($raw, ')'); + return undef if $i < 0; + my @f = split ' ', substr($raw, $i + 1); + return $f[0]; +} + +sub proc_is_gsproxy { + my ($pid) = @_; + my $raw = read_proc_file('/proc/' . $pid . '/cmdline'); + return 0 unless defined $raw && length $raw; + my $flat = join(' ', split /[[:cntrl:]]/, $raw); + my $base = basename($0); + return $flat =~ /\Q$base\E/ ? 1 : 0; +} + +sub find_other_daemons { + my @out; + opendir my $dh, '/proc' or return @out; + for my $d (readdir $dh) { + next unless $d =~ /^[0-9]+$/; + next if $d == $$; + next unless proc_is_gsproxy($d); + my $raw = read_proc_file('/proc/' . $d . '/stat'); + next unless defined $raw; + my $i = rindex($raw, ')'); + next if $i < 0; + my @f = split ' ', substr($raw, $i + 1); + next if (defined $f[0] ? $f[0] : '') eq 'Z'; + next unless defined $f[1] && $f[1] == 1; + push @out, $d; + } + closedir $dh; + return @out; +} + sub find_local_pids { my %ports = map { $_ => 1 } @_; my @pids; @@ -430,7 +516,7 @@ sub start_local { my $gn = which($opt{gs_netcat}); return (0, 'gs-netcat not found: ' . $opt{gs_netcat}) unless defined $gn; my $port = $e->{port}; - my $cmd = $gn . ' -D -p ' . $port . ' -s ' . shq($e->{secret}); + my $cmd = shq($gn) . ' -D -p ' . $port . ' -s ' . shq($e->{secret}); system($cmd . q{ >/dev/null 2>&1 &}); return (1, 'started local gs-netcat on 127.0.0.1:' . $port); } @@ -454,6 +540,7 @@ sub bring_up { sub write_state { my ($alive) = @_; open my $fh, '>', $opt{state} or return; + chmod 0600, $opt{state}; my $first = @$alive ? $alive->[0] : ''; print $fh $first, $NL; print $fh join(' ', @$alive), $NL; @@ -513,6 +600,63 @@ sub pump { } } +sub socks_auth_client { + my ($c) = @_; + return 1 unless defined $gw_auth_user; + my $g = read_exact($c, 2, 10); + return 0 unless defined $g && ord(substr($g, 0, 1)) == 5; + my $nm = ord(substr($g, 1, 1)); + return 0 if $nm == 0 || $nm > 255; + my $methods = read_exact($c, $nm, 10); + return 0 unless defined $methods && length($methods) == $nm; + return 0 unless index($methods, chr(2)) >= 0; + return 0 unless syswrite($c, pack('C*', 5, 2)) == 2; + my $h = read_exact($c, 2, 10); + return 0 unless defined $h; + return 0 unless ord(substr($h, 0, 1)) == 1; + my $ulen = ord(substr($h, 1, 1)); + return 0 if $ulen == 0 || $ulen > 255; + my $u = read_exact($c, $ulen, 10); + return 0 unless defined $u; + my $pb = read_exact($c, 1, 10); + return 0 unless defined $pb; + my $plen = ord($pb); + return 0 if $plen == 0 || $plen > 255; + my $p = read_exact($c, $plen, 10); + return 0 unless defined $p; + if ($u eq $gw_auth_user && $p eq $gw_auth_pass) { + return 0 unless syswrite($c, pack('C*', 1, 0)) == 2; + return 1; + } + syswrite($c, pack('C*', 1, 1)); + return 0; +} + +sub read_socks_request { + my ($c) = @_; + my $buf = read_exact($c, 4, 10); + return undef unless defined $buf; + my $atyp = ord(substr($buf, 3, 1)); + my $extra; + if ($atyp == 1) { + $extra = read_exact($c, 4, 10); + } elsif ($atyp == 3) { + my $l = read_exact($c, 1, 10); + return undef unless defined $l; + my $d = read_exact($c, ord($l), 10); + return undef unless defined $d; + $extra = $l . $d; + } elsif ($atyp == 4) { + $extra = read_exact($c, 16, 10); + } else { + return undef; + } + return undef unless defined $extra; + my $port = read_exact($c, 2, 10); + return undef unless defined $port; + return $buf . $extra . $port; +} + sub handle_gateway_conn { my ($c) = @_; my $up = read_state_port(); @@ -523,7 +667,19 @@ sub handle_gateway_conn { Proto => 'tcp', Timeout => 10, ) or return; - eval { pump($c, $u) }; + if (defined $gw_auth_user) { + return unless socks_auth_client($c); + my $req = read_socks_request($c); + return unless defined $req; + my $greet = pack('C*', 5, 1, 0); + return unless syswrite($u, $greet) == length($greet); + my $g = read_exact($u, 2, 10); + return unless defined $g && $g eq pack('C*', 5, 0); + return unless syswrite($u, $req) == length($req); + eval { pump($c, $u) }; + } else { + eval { pump($c, $u) }; + } close $u; close $c; } @@ -531,29 +687,62 @@ sub handle_gateway_conn { sub start_gateway { my $gw = $opt{gateway}; my $bind = $opt{bind}; + my ($r, $w); + pipe($r, $w) or return undef; my $pid = fork(); - return $pid if $pid; - $SIG{TERM} = sub { exit 0 }; - $SIG{INT} = sub { exit 0 }; - my $lsn = IO::Socket::INET->new( - LocalAddr => $bind, - LocalPort => $gw, - Proto => 'tcp', - Listen => 128, - ReuseAddr => 1, - ) or do { logmsg('gateway: cannot listen on ' . $bind . ':' . $gw . ': ' . $!); exit 1; }; - logmsg('gateway listening on ' . $bind . ':' . $gw . ' -> first alive SOCKS'); - while (my $c = $lsn->accept) { - my $p = fork(); - if (defined $p && $p == 0) { - close $lsn; - handle_gateway_conn($c); - exit 0; + return undef unless defined $pid; + if ($pid == 0) { + close $r; + $SIG{TERM} = sub { exit 0 }; + $SIG{INT} = sub { exit 0 }; + $SIG{CHLD} = sub { 1 while waitpid(-1, WNOHANG) > 0 }; + my $lsn = IO::Socket::INET->new( + LocalAddr => $bind, + LocalPort => $gw, + Proto => 'tcp', + Listen => 128, + ReuseAddr => 1, + ); + if (!$lsn) { + logmsg('gateway: cannot listen on ' . $bind . ':' . $gw . ': ' . $!); + print {$w} 'ERR' . $NL; + close $w; + exit 1; } - close $c; - 1 while waitpid(-1, WNOHANG) > 0; + print {$w} 'OK' . $NL; + close $w; + logmsg('gateway listening on ' . $bind . ':' . $gw . ' -> first alive SOCKS'); + while (1) { + my $c = $lsn->accept; + if (!defined $c) { + next if $! == EINTR; + logmsg('gateway: accept failed: ' . $!); + exit 1; + } + my $p = fork(); + if (defined $p && $p == 0) { + close $lsn; + handle_gateway_conn($c); + exit 0; + } + close $c; + } + exit 0; } - exit 0; + close $w; + my $sel = IO::Select->new($r); + my $ok = 0; + if ($sel->can_read(5)) { + my $line = <$r>; + $ok = 1 if defined $line && $line =~ /^OK/; + } + close $r; + unless ($ok) { + kill('TERM', $pid); + waitpid($pid, 0); + return undef; + } + return $pid; } my %async_jobs; @@ -574,18 +763,33 @@ sub spawn_bringup { return $pid; } -sub reap_jobs { - for my $pid (keys %async_jobs) { - my $r = waitpid($pid, WNOHANG); - next if $r == 0; - my $e = delete $async_jobs{$pid}; - $e->{bringing_up} = 0 if $e; +sub reap_children { + my $reaped = 0; + while (1) { + my $r = waitpid(-1, WNOHANG); + last if $r <= 0; + $reaped++; + if (exists $async_jobs{$r}) { + my $e = delete $async_jobs{$r}; + $e->{bringing_up} = 0 if $e; + } + if (defined $gwpid && $r == $gwpid) { + logmsg('gateway process ' . $r . ' exited'); + $gwpid = undef; + } } + return $reaped; } sub monitor_once { my ($rows) = @_; - reap_jobs() unless $opt{once}; + reap_children() unless $opt{once}; + if (!$opt{once} && !defined $gwpid && time() - $last_gw_attempt >= 30) { + $last_gw_attempt = time(); + logmsg('gateway not running; restarting'); + $gwpid = start_gateway(); + write_pidfile($$, $gwpid) if defined $gwpid; + } my @alive; for my $e (@$rows) { my $label = $e->{label}; @@ -612,7 +816,14 @@ sub monitor_once { sub write_pidfile { my @pids = @_; + if ($lock_fh) { + seek($lock_fh, 0, 0); + truncate($lock_fh, 0); + print {$lock_fh} join($NL, @pids), $NL; + return; + } open my $fh, '>', $opt{pidfile} or return; + chmod 0600, $opt{pidfile}; print $fh join($NL, @pids), $NL; close $fh; } @@ -629,11 +840,65 @@ sub read_pidfile { return @out; } +sub acquire_lock { + return 1 if $lock_fh; + open my $fh, '>>', $opt{pidfile} or return 0; + unless (flock($fh, LOCK_EX | LOCK_NB)) { + close $fh; + return 0; + } + chmod 0600, $opt{pidfile}; + $fh->autoflush(1); + $lock_fh = $fh; + return 1; +} + +sub release_lock { + if ($lock_fh) { + close $lock_fh; + $lock_fh = undef; + } +} + +sub guard_start { + my @others = find_other_daemons(); + if (@others) { + print 'gsproxy daemon already running (pid ' . join(',', @others) . '). Use --stop first.' . $NL; + exit 1; + } + unless (acquire_lock()) { + print 'gsproxy already running (pidfile locked). Use --stop first.' . $NL; + exit 1; + } +} + sub stop_daemon { - my @pids = read_pidfile(); + my %pids = map { $_ => 1 } read_pidfile(); + $pids{$_} = 1 for find_other_daemons(); + my @pids = sort { $a <=> $b } keys %pids; my $any = 0; + my @victims; for my $p (@pids) { - if (kill(0, $p)) { kill('TERM', $p); logmsg('stopped pid ' . $p); $any = 1; } + next unless proc_is_gsproxy($p); + if (kill(0, $p)) { + push @victims, $p; + kill('TERM', $p); + logmsg('stopped pid ' . $p); + $any = 1; + } + } + if (@victims) { + my $deadline = time() + 5; + for my $p (@victims) { + while (time() < $deadline && kill(0, $p)) { + last if (proc_state($p) // '') eq 'Z'; + sleep 0.2; + } + if (kill(0, $p) && (proc_state($p) // '') ne 'Z') { + logmsg('pid ' . $p . ' did not exit; sending KILL'); + kill('KILL', $p); + } + } } if (@pids) { my @now = read_pidfile(); @@ -652,16 +917,22 @@ sub stop_daemon { sub do_status { my @pids = read_pidfile(); - my @live = grep { kill(0, $_) } @pids; + my @live; + for my $p (@pids) { + next unless proc_is_gsproxy($p); + next if (proc_state($p) // '') eq 'Z'; + push @live, $p if kill(0, $p); + } if (@live) { print 'gsproxy running (pid ' . join(',', @live) . ')' . $NL; return 0; } print 'gsproxy not running' . $NL; return 1; } sub cleanup { - my ($gwpid, $rows) = @_; + my ($rows) = @_; kill('TERM', $gwpid) if $gwpid; unlink $opt{pidfile}; + release_lock(); if ($rows) { my $nl = teardown_local($rows); logmsg('teardown: local gs-netcat stopped=' . $nl); @@ -672,12 +943,21 @@ sub cleanup { sub run_foreground { my ($rows) = @_; - my $gwpid; if (!$opt{once}) { + unless (is_loopback($opt{bind})) { + logmsg('warning: gateway bound to ' . $opt{bind} . ':' . $opt{gateway} + . ' is exposed to the network; restrict with --bind or --gateway-auth'); + } $gwpid = start_gateway(); + if (!defined $gwpid) { + logmsg('gateway failed to start; aborting'); + release_lock(); + exit 1; + } + $last_gw_attempt = time(); write_pidfile($$, $gwpid); - $SIG{TERM} = sub { cleanup($gwpid, $rows); exit 0 }; - $SIG{INT} = sub { cleanup($gwpid, $rows); exit 0 }; + $SIG{TERM} = sub { cleanup($rows); exit 0 }; + $SIG{INT} = sub { cleanup($rows); exit 0 }; logmsg('gsproxy started: gateway ' . $opt{bind} . ':' . $opt{gateway} . ', ' . scalar(@$rows) . ' proxies, interval ' . $opt{interval} . 's'); } @@ -686,7 +966,53 @@ sub run_foreground { last if $opt{once}; sleep $opt{interval}; } - cleanup($gwpid, $rows) if $gwpid; + cleanup($rows) if $gwpid; + release_lock(); +} + +sub harden_file_perms { + for my $f ($opt{log}, $opt{pidfile}, $opt{state}) { + next unless -e $f; + chmod 0600, $f; + } +} + +sub check_config_perms { + my ($file) = @_; + return unless -e $file; + my @st = stat($file); + return unless @st; + if ($st[2] & 0077) { + logmsg('warning: config ' . $file . ' is group/world readable; chmod 600 to protect secrets'); + } +} + +sub is_loopback { + my ($a) = @_; + return 1 if lc($a) eq 'localhost' || $a eq '::1' || $a eq '127.0.0.1'; + return 1 if $a =~ /^127\./; + return 0; +} + +sub load_gateway_auth { + return (undef, undef) unless defined $opt{gateway_auth}; + open my $fh, '<', $opt{gateway_auth} or do { + logmsg('gateway auth: cannot open ' . $opt{gateway_auth} . ': ' . $!); + return (undef, undef); + }; + my ($u, $p); + while (my $line = <$fh>) { + $line = strip_eol($line); + next if $line =~ /^[[:space:]]*$/ || $line =~ /^[[:space:]]*#/; + my ($a, $b) = split /:/, $line, 2; + if (defined $a && defined $b && length $a && length $b) { + $u = $a; + $p = $b; + last; + } + } + close $fh; + return ($u, $p); } sub fake_socks_server { @@ -721,6 +1047,42 @@ sub fake_socks_server { } } +sub auth_test { + my ($u, $p) = @_; + my $lsn = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', LocalPort => 0, Proto => 'tcp', + Listen => 1, ReuseAddr => 1, + ) or return (0, 'no listen'); + my $port = $lsn->sockport; + my $cli = IO::Socket::INET->new( + PeerAddr => '127.0.0.1', PeerPort => $port, Proto => 'tcp', Timeout => 3, + ) or return (0, 'no connect'); + my $srv = $lsn->accept; + close $lsn; + my $pid = fork(); + return (0, 'no fork') unless defined $pid; + if ($pid == 0) { + my $r = socks_auth_client($srv); + exit($r ? 0 : 1); + } + $cli->autoflush(1); + syswrite($cli, pack('C*', 5, 1, 2)); + my $m = read_exact($cli, 2, 3); + my $ar; + if (defined $m && ord(substr($m, 1, 1)) == 2) { + syswrite($cli, pack('C*', 1, length($u)) . $u . pack('C', length($p)) . $p); + $ar = read_exact($cli, 2, 3); + } + waitpid($pid, 0); + my $rc = $? >> 8; + close $cli; + close $srv; + my $auth_ok = defined $ar && ord(substr($ar, 0, 1)) == 1 && ord(substr($ar, 1, 1)) == 0; + my $detail = 'rc=' . $rc . ' auth=' . ($auth_ok ? 'ok' : 'bad') + . ' method=' . (defined $m ? ord(substr($m, 1, 1)) : 'none'); + return ((($rc == 0) == $auth_ok) ? 1 : 0, $detail); +} + sub selftest { my $fails = 0; my $ok = sub { @@ -763,6 +1125,46 @@ sub selftest { $ok->(scalar(find_local_pids()) == 0, 'find_local_pids empty'); + $ok->(proc_is_gsproxy($$), 'proc_is_gsproxy self'); + $ok->(!proc_is_gsproxy(1), 'proc_is_gsproxy init'); + my $zpid = fork(); + if (defined $zpid && $zpid == 0) { exit 0; } + sleep 0.2; + $ok->((proc_state($zpid) // '') eq 'Z', 'proc_state zombie'); + waitpid($zpid, 0); + + register_secrets('sekret123'); + $ok->(redact('x sekret123 y sekret123') eq 'x se*** y se***', 'redact secret'); + $ok->(one_line('a sekret123 b') eq 'a se*** b', 'one_line redacts'); + + $opt{target_sha256} = 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef'; + my $tc2 = target_cmd('sekret123'); + $ok->(index($tc2, "echo 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef gs-netcat' | sha256sum -c -") >= 0, 'target_cmd sha256 pin'); + $opt{target_sha256} = undef; + + $opt{pidfile} = $tmp . '/gsproxy_selftest.pid'; + $ok->(acquire_lock(), 'lock acquire'); + my $lpid = fork(); + if (defined $lpid && $lpid == 0) { + $lock_fh = undef; + my $got = acquire_lock(); + exit($got ? 0 : 1); + } + waitpid($lpid, 0); + $ok->(($? >> 8) == 1, 'lock exclusive across processes'); + release_lock(); + $ok->(acquire_lock(), 'lock reacquire after release'); + release_lock(); + + $gw_auth_user = 'bob'; + $gw_auth_pass = 'pw'; + my ($ar1, $d1) = auth_test('bob', 'pw'); + $ok->($ar1, 'gateway auth accept (' . $d1 . ')'); + my ($ar2, $d2) = auth_test('bob', 'wrong'); + $ok->($ar2, 'gateway auth reject (' . $d2 . ')'); + $gw_auth_user = undef; + $gw_auth_pass = undef; + $opt{timeout} = 3; my $lsn = IO::Socket::INET->new( @@ -808,9 +1210,45 @@ sub selftest { $ok->(0, 'gateway connect'); } + $gw_auth_user = 'bob'; + $gw_auth_pass = 'pw'; + my $free2 = IO::Socket::INET->new(LocalAddr => '127.0.0.1', LocalPort => 0, Proto => 'tcp', Listen => 1, ReuseAddr => 1); + my $gwport2 = $free2->sockport; + close $free2; + $opt{gateway} = $gwport2; + my $gwpid2 = start_gateway(); + sleep 0.3; + my $auth_relay_ok = 0; + my $cli2 = IO::Socket::INET->new(PeerAddr => '127.0.0.1', PeerPort => $gwport2, Proto => 'tcp', Timeout => 3); + if ($cli2) { + $cli2->autoflush(1); + syswrite($cli2, pack('C*', 5, 1, 2)); + my $m2 = read_exact($cli2, 2, 3); + if (defined $m2 && ord(substr($m2, 1, 1)) == 2) { + syswrite($cli2, pack('C*', 1, 3) . 'bob' . pack('C', 2) . 'pw'); + my $ar2 = read_exact($cli2, 2, 3); + if (defined $ar2 && ord(substr($ar2, 1, 1)) == 0) { + my $host = 'example.com'; + syswrite($cli2, pack('C*', 5, 1, 0, 3) . pack('C', length($host)) . $host . pack('n', 443)); + my $rep2 = read_exact($cli2, 10, 3); + if (defined $rep2 && ord(substr($rep2, 1, 1)) == 0) { + syswrite($cli2, 'ping'); + my $echo = read_exact($cli2, 4, 3); + $auth_relay_ok = 1 if defined $echo && $echo eq 'ping'; + } + } + } + close $cli2; + } + $ok->($auth_relay_ok, 'gateway auth relay end-to-end'); + $gw_auth_user = undef; + $gw_auth_pass = undef; + kill('TERM', $gwpid) if $gwpid; + kill('TERM', $gwpid2) if $gwpid2; kill('TERM', $spid) if $spid; waitpid($gwpid, 0) if $gwpid; + waitpid($gwpid2, 0) if $gwpid2; waitpid($spid, 0) if $spid; print($fails ? ('SELFTEST FAILED (' . $fails . ')' . $NL) : ('SELFTEST OK' . $NL)); @@ -829,24 +1267,45 @@ if ($opt{list}) { die 'no usable rows in ' . $opt{config} . $NL unless @$rows; +register_secrets(map { $_->{secret} } @$rows); + +my %port_label; for my $e (@$rows) { if ($e->{port} == $opt{gateway}) { logmsg('warning: gateway port ' . $opt{gateway} . ' equals local port of ' . $e->{label}); } + if (defined $port_label{$e->{port}}) { + logmsg('warning: local port ' . $e->{port} . ' is used by both ' . $port_label{$e->{port}} . ' and ' . $e->{label}); + } else { + $port_label{$e->{port}} = $e->{label}; + } + if (length($e->{secret}) < 8) { + logmsg('warning: secret for ' . $e->{label} . ' is only ' . length($e->{secret}) . ' characters'); + } +} + +check_config_perms($opt{config}); + +if (defined $opt{gateway_auth}) { + ($gw_auth_user, $gw_auth_pass) = load_gateway_auth(); + unless (defined $gw_auth_user) { + print 'gateway auth file ' . $opt{gateway_auth} . ' has no user:pass line' . $NL; + exit 1; + } + my @st = stat($opt{gateway_auth}); + if (@st && ($st[2] & 0077)) { + logmsg('warning: gateway auth file ' . $opt{gateway_auth} . ' is group/world readable; chmod 600'); + } } if ($opt{once} || !$opt{daemon}) { + guard_start(); run_foreground($rows); exit 0; } if (!$ENV{GSPROXY_DAEMON}) { - my @old = read_pidfile(); - if (grep { kill(0, $_) } @old) { - print 'gsproxy already running. Use --stop first.' . $NL; - exit 1; - } - unlink $opt{pidfile}; + guard_start(); $ENV{GSPROXY_DAEMON} = 1; my $pid = fork(); if ($pid) { @@ -860,4 +1319,4 @@ if (!$ENV{GSPROXY_DAEMON}) { } run_foreground($rows); -exit 0; +exit 0; \ No newline at end of file