mirror of
https://github.com/webmin/webmin.git
synced 2026-06-23 20:40:32 +01:00
Add support to proxy linked-server WebSockets
This PR adds general WebSocket proxying for linked Webmin servers, allowing modules such as `xterm` to work when opened through `servers/link.cgi`. As requested in https://github.com/webmin/webmin/issues/1866.
This commit is contained in:
File diff suppressed because one or more lines are too long
252
miniserv.pl
252
miniserv.pl
@@ -1509,6 +1509,17 @@ while(1) {
|
||||
}
|
||||
}
|
||||
alarm(0);
|
||||
my $websocket_upgrade_request = lc($header{'connection'}) =~ /upgrade/ &&
|
||||
lc($header{'upgrade'}) eq 'websocket';
|
||||
my $websocket_configured_request;
|
||||
if ($websocket_upgrade_request) {
|
||||
# Check the configured websocket paths before auth, so Basic auth can
|
||||
# remain disabled for normal session-mode requests.
|
||||
my $wsbogus;
|
||||
my $ws_simple = &simplify_path($page, $wsbogus);
|
||||
$websocket_configured_request = !$wsbogus &&
|
||||
&find_websocket_config($ws_simple);
|
||||
}
|
||||
|
||||
# If a remote IP is given in a header (such as via a proxy), only use it
|
||||
# for logging unless trust_real_ip is set
|
||||
@@ -1829,8 +1840,11 @@ if (!$validated && !$deny_authentication) {
|
||||
}
|
||||
}
|
||||
|
||||
# Check for normal HTTP authentication
|
||||
if (!$validated && !$deny_authentication && !$config{'session'} &&
|
||||
# Keep Basic auth disabled in session mode except for configured websocket
|
||||
# proxy paths. Linked-server websocket hops need it, and token/user checks
|
||||
# still run before the backend connection is opened.
|
||||
if (!$validated && !$deny_authentication &&
|
||||
(!$config{'session'} || $websocket_configured_request) &&
|
||||
$header{authorization} =~ /^basic\s+(\S+)$/i) {
|
||||
# authorization given..
|
||||
($authuser, $authpass) = split(/:/, &b64decode($1), 2);
|
||||
@@ -2335,21 +2349,9 @@ if ($davpath) {
|
||||
}
|
||||
|
||||
# Check for a websockets request
|
||||
if (lc($header{'connection'}) =~ /upgrade/ &&
|
||||
lc($header{'upgrade'}) eq 'websocket' &&
|
||||
$baseauthuser) {
|
||||
if ($websocket_upgrade_request && $baseauthuser) {
|
||||
print DEBUG "websockets request to $simple\n";
|
||||
my $ws_simple = $simple;
|
||||
my ($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths;
|
||||
if (!$ws && $config{'redirect_prefix'}) {
|
||||
my $prefix = $config{'redirect_prefix'};
|
||||
$prefix =~ s/[\/]+$//g;
|
||||
if ($prefix && $ws_simple =~ s/^\Q$prefix\E(?=\/|$)//) {
|
||||
$ws_simple ||= "/";
|
||||
print DEBUG "websockets retry without prefix $prefix as $ws_simple\n";
|
||||
($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths;
|
||||
}
|
||||
}
|
||||
my ($ws, $ws_simple) = &find_websocket_config($simple);
|
||||
if (!$ws) {
|
||||
&http_error(400, "Unknown websocket path");
|
||||
return 0;
|
||||
@@ -5033,6 +5035,27 @@ foreach my $c (keys %config) {
|
||||
}
|
||||
}
|
||||
|
||||
# find_websocket_config(path)
|
||||
# Returns the websocket config and path, after dropping any query string and
|
||||
# redirect prefix from the request path.
|
||||
sub find_websocket_config
|
||||
{
|
||||
my ($simple) = @_;
|
||||
my $ws_simple = $simple;
|
||||
$ws_simple =~ s/\?.*$//;
|
||||
my ($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths;
|
||||
if (!$ws && $config{'redirect_prefix'}) {
|
||||
my $prefix = $config{'redirect_prefix'};
|
||||
$prefix =~ s/[\/]+$//g;
|
||||
if ($prefix && $ws_simple =~ s/^\Q$prefix\E(?=\/|$)//) {
|
||||
$ws_simple ||= "/";
|
||||
print DEBUG "websockets retry without prefix $prefix as $ws_simple\n";
|
||||
($ws) = grep { $_->{'path'} eq $ws_simple } @websocket_paths;
|
||||
}
|
||||
}
|
||||
return wantarray ? ($ws, $ws_simple) : $ws;
|
||||
}
|
||||
|
||||
# reload_config_file()
|
||||
# Re-read %config, and call post-config actions
|
||||
sub reload_config_file
|
||||
@@ -6081,10 +6104,57 @@ print DEBUG "websockets protos ",join(" ", @protos),"\n";
|
||||
|
||||
# Connect to the configured backend
|
||||
my $fh = "WEBSOCKET";
|
||||
my ($backend_ssl, $backend_ssl_ctx);
|
||||
if ($ws->{'host'}) {
|
||||
# Backend is a TCP port
|
||||
my $err = &open_socket($ws->{'host'}, $ws->{'port'}, $fh);
|
||||
&http_error(500, "Websockets connection failed : $err") if ($err);
|
||||
if ($ws->{'ssl'}) {
|
||||
eval "use Net::SSLeay";
|
||||
if ($@) {
|
||||
&http_error(500, "Missing Net::SSLeay perl module");
|
||||
return 0;
|
||||
}
|
||||
$backend_ssl_ctx = Net::SSLeay::CTX_new();
|
||||
if (!$backend_ssl_ctx) {
|
||||
&http_error(500, "Failed to create SSL context");
|
||||
return 0;
|
||||
}
|
||||
$backend_ssl = Net::SSLeay::new($backend_ssl_ctx);
|
||||
if (!$backend_ssl) {
|
||||
&http_error(500, "Failed to create SSL connection");
|
||||
return 0;
|
||||
}
|
||||
Net::SSLeay::set_fd($backend_ssl, fileno($fh));
|
||||
my $sslhost = $ws->{'hostheader'} || $ws->{'host'};
|
||||
if (defined(&Net::SSLeay::set_tlsext_host_name)) {
|
||||
# Linked websocket routes may connect to an IP while the
|
||||
# certificate belongs to the configured linked-server
|
||||
# host.
|
||||
my $snihost = $sslhost;
|
||||
if ($snihost =~ /^\[([^\]]+)\](?::\d+)?$/) {
|
||||
$snihost = $1;
|
||||
}
|
||||
elsif ($snihost =~ /^([^:]+):\d+$/) {
|
||||
$snihost = $1;
|
||||
}
|
||||
Net::SSLeay::set_tlsext_host_name($backend_ssl, $snihost);
|
||||
}
|
||||
if (!Net::SSLeay::connect($backend_ssl)) {
|
||||
&http_error(500, "SSL connect to websockets backend failed");
|
||||
return 0;
|
||||
}
|
||||
if ($ws->{'checkssl'}) {
|
||||
my $err = &check_websocket_backend_ssl(
|
||||
$backend_ssl, $sslhost);
|
||||
if ($err) {
|
||||
&http_error(500,
|
||||
"Invalid SSL certificate from websockets ".
|
||||
"backend : $err");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
print DEBUG "websockets host $ws->{'host'}:$ws->{'port'}\n";
|
||||
}
|
||||
elsif ($ws->{'pipe'}) {
|
||||
@@ -6096,8 +6166,35 @@ elsif ($ws->{'pipe'}) {
|
||||
else {
|
||||
&http_error(500, "Invalid Webmin websockets config");
|
||||
}
|
||||
# Keep the rest of the websocket proxy code independent of whether the
|
||||
# backend hop is plain TCP or wrapped in TLS.
|
||||
my $backend_write = sub {
|
||||
my ($buf) = @_;
|
||||
return $backend_ssl ? Net::SSLeay::write($backend_ssl, $buf)
|
||||
: syswrite($fh, $buf, length($buf));
|
||||
};
|
||||
my $backend_read = sub {
|
||||
my ($size) = @_;
|
||||
if ($backend_ssl) {
|
||||
return Net::SSLeay::read($backend_ssl, $size);
|
||||
}
|
||||
my $buf;
|
||||
my $rv = sysread($fh, $buf, $size);
|
||||
return $rv ? $buf : undef;
|
||||
};
|
||||
my $backend_readline = sub {
|
||||
my $line = "";
|
||||
while(1) {
|
||||
my $c = $backend_read->(1);
|
||||
return undef if (!defined($c) || $c eq "");
|
||||
$line .= $c;
|
||||
last if ($c eq "\n");
|
||||
}
|
||||
return $line;
|
||||
};
|
||||
|
||||
# Send successful connection headers
|
||||
# Prepare successful connection headers, but don't send them to the browser
|
||||
# until the backend websocket handshake has succeeded.
|
||||
eval "use Digest::SHA";
|
||||
if ($@) {
|
||||
&http_error(500, "Missing Digest::SHA perl module");
|
||||
@@ -6107,40 +6204,46 @@ my $sha1 = Digest::SHA->new;
|
||||
$sha1->add($rkey);
|
||||
my $digest = $sha1->digest;
|
||||
$digest = &b64encode($digest);
|
||||
&write_data("HTTP/1.1 101 Switching Protocols\r\n");
|
||||
&write_data("Upgrade: websocket\r\n");
|
||||
&write_data("Connection: Upgrade\r\n");
|
||||
&write_data("Sec-Websocket-Accept: $digest\r\n");
|
||||
if (@protos) {
|
||||
&write_data("Sec-Websocket-Protocol: $protos[0]\r\n");
|
||||
}
|
||||
&write_data("\r\n");
|
||||
|
||||
# Send a websockets request to the backend
|
||||
my $path = $ws->{'wspath'} || $simple;
|
||||
my $bsession_id = &b64encode($session_id);
|
||||
# Normal websocket proxies use the browser session id in the backend
|
||||
# Sec-WebSocket-Key. Linked xterm routes can instead provide a one-time
|
||||
# backend session key because the backend request is authenticated with Basic.
|
||||
my $backend_session = $ws->{'backend_session'} || $session_id;
|
||||
my $bsession_id = &b64encode($backend_session);
|
||||
print DEBUG "send request to $path to websockets backend\n";
|
||||
print $fh "GET $path HTTP/1.1\r\n";
|
||||
if ($ws->{'host'}) {
|
||||
print $fh "Host: $ws->{'host'}\r\n";
|
||||
$backend_write->("GET $path HTTP/1.1\r\n");
|
||||
if ($ws->{'host'} || $ws->{'hostheader'}) {
|
||||
$backend_write->("Host: ".($ws->{'hostheader'} || $ws->{'host'})."\r\n");
|
||||
}
|
||||
print $fh "Upgrade: websocket\r\n";
|
||||
print $fh "Connection: Upgrade\r\n";
|
||||
$backend_write->("Upgrade: websocket\r\n");
|
||||
$backend_write->("Connection: Upgrade\r\n");
|
||||
if ($ws->{'nokey'}) {
|
||||
print $fh "Sec-WebSocket-Key: $key\r\n";
|
||||
$backend_write->("Sec-WebSocket-Key: $key\r\n");
|
||||
}
|
||||
else {
|
||||
print DEBUG "Sending key $bsession_id\n";
|
||||
print $fh "Sec-WebSocket-Key: $bsession_id\r\n";
|
||||
$backend_write->("Sec-WebSocket-Key: $bsession_id\r\n");
|
||||
}
|
||||
if (@protos) {
|
||||
print $fh "Sec-WebSocket-Protocol: ",join(" ", @protos),"\r\n";
|
||||
$backend_write->("Sec-WebSocket-Protocol: ".join(" ", @protos)."\r\n");
|
||||
}
|
||||
print $fh "Sec-WebSocket-Version: $header{'sec-websocket-version'}\r\n";
|
||||
print $fh "\r\n";
|
||||
if ($ws->{'origin'}) {
|
||||
$backend_write->("Origin: $ws->{'origin'}\r\n");
|
||||
}
|
||||
if ($ws->{'auth'} && $ws->{'auth'} =~ /^basic:(\S+)$/) {
|
||||
$backend_write->("Authorization: Basic $1\r\n");
|
||||
}
|
||||
$backend_write->("Sec-WebSocket-Version: $header{'sec-websocket-version'}\r\n");
|
||||
$backend_write->("\r\n");
|
||||
|
||||
# Read back the reply
|
||||
my $rh = <$fh>;
|
||||
my $rh = $backend_readline->();
|
||||
if (!defined($rh)) {
|
||||
&http_error(500, "No response from websockets backend");
|
||||
return 0;
|
||||
}
|
||||
$rh =~ s/\r|\n//g;
|
||||
print DEBUG "got $rh from websockets backend\n";
|
||||
$rh =~ /^HTTP\/1\.1\s+(\d+)/ ||
|
||||
@@ -6150,7 +6253,11 @@ my $code = $1;
|
||||
my %rheader;
|
||||
my $lastheader;
|
||||
while(1) {
|
||||
$rh = <$fh>;
|
||||
$rh = $backend_readline->();
|
||||
if (!defined($rh)) {
|
||||
&http_error(500, "Unexpected EOF from websockets backend");
|
||||
return 0;
|
||||
}
|
||||
$rh =~ s/\r|\n//g;
|
||||
last if ($rh eq "");
|
||||
if ($rh =~ /^(\S+):\s*(.*)$/) {
|
||||
@@ -6190,6 +6297,16 @@ print DEBUG "expecting digest $bdigest\n";
|
||||
lc($rheader{'sec-websocket-accept'}) eq lc($bdigest) ||
|
||||
&http_error(500, "Incorrect digest header from websockets backend");
|
||||
|
||||
# Send successful connection headers
|
||||
&write_data("HTTP/1.1 101 Switching Protocols\r\n");
|
||||
&write_data("Upgrade: websocket\r\n");
|
||||
&write_data("Connection: Upgrade\r\n");
|
||||
&write_data("Sec-Websocket-Accept: $digest\r\n");
|
||||
if (@protos) {
|
||||
&write_data("Sec-Websocket-Protocol: $protos[0]\r\n");
|
||||
}
|
||||
&write_data("\r\n");
|
||||
|
||||
# Log now
|
||||
&log_request($loghost, $authuser, $reqline, "101", 0);
|
||||
|
||||
@@ -6197,6 +6314,10 @@ lc($rheader{'sec-websocket-accept'}) eq lc($bdigest) ||
|
||||
seek(DEBUG, 0, 2);
|
||||
print DEBUG "in websockets loop\n";
|
||||
my $last_session_check_time = time();
|
||||
# Frontend browser sockets have a session id and should be revalidated while
|
||||
# open. Backend hops authenticated without a browser session must not be closed
|
||||
# by the periodic session check.
|
||||
my $verify_session = defined($session_id) && $session_id ne "";
|
||||
while(1) {
|
||||
my $rmask = undef;
|
||||
vec($rmask, fileno($fh), 1) = 1;
|
||||
@@ -6206,8 +6327,8 @@ while(1) {
|
||||
my $uptime = 0;
|
||||
if (vec($rmask, fileno($fh), 1)) {
|
||||
# Got something from the websockets backend
|
||||
$ok = sysread($fh, $buf, 1024);
|
||||
last if ($ok <= 0); # Backend has closed
|
||||
$buf = $backend_read->(1024);
|
||||
last if (!defined($buf) || length($buf) == 0);
|
||||
&write_data($buf);
|
||||
$uptime = 1;
|
||||
}
|
||||
@@ -6215,11 +6336,11 @@ while(1) {
|
||||
# Got something from the browser
|
||||
$buf = &read_data(1024);
|
||||
last if (!defined($buf) || length($buf) == 0);
|
||||
syswrite($fh, $buf, length($buf)) || last;
|
||||
$backend_write->($buf) || last;
|
||||
$uptime = 1;
|
||||
}
|
||||
my $now = time();
|
||||
if ($now - $last_session_check_time > 10) {
|
||||
if ($verify_session && $now - $last_session_check_time > 10) {
|
||||
# Re-validate the browser session every 10 seconds
|
||||
print DEBUG "verifying websockets session $session_id\n";
|
||||
print $PASSINw "verify $session_id $acptip $uptime\n";
|
||||
@@ -6231,8 +6352,21 @@ while(1) {
|
||||
$last_session_check_time = $now;
|
||||
}
|
||||
}
|
||||
Net::SSLeay::free($backend_ssl) if ($backend_ssl);
|
||||
Net::SSLeay::CTX_free($backend_ssl_ctx) if ($backend_ssl_ctx);
|
||||
close($fh);
|
||||
close(SOCK);
|
||||
if ($ws->{'path'} =~ /\/ws-link-/) {
|
||||
# Linked-server websocket routes are single-use routes registered by
|
||||
# link.cgi, so remove them as soon as the tunnel ends.
|
||||
&lock_file($config_file);
|
||||
my %miniserv = &read_config_file($config_file);
|
||||
if (delete($miniserv{"websockets_$ws->{'path'}"})) {
|
||||
&write_file($config_file, \%miniserv);
|
||||
}
|
||||
&unlock_file($config_file);
|
||||
&reload_miniserv();
|
||||
}
|
||||
print DEBUG "done websockets loop\n";
|
||||
|
||||
return 0;
|
||||
@@ -7527,3 +7661,37 @@ foreach my $p (@$hosts) {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
# check_websocket_backend_ssl(ssl-handle, host)
|
||||
# Returns an error if the websocket backend certificate is not for host.
|
||||
sub check_websocket_backend_ssl
|
||||
{
|
||||
my ($ssl, $host) = @_;
|
||||
if ($host =~ /^\[([^\]]+)\](?::\d+)?$/) {
|
||||
$host = $1;
|
||||
}
|
||||
elsif ($host =~ /^([^:]+):\d+$/) {
|
||||
$host = $1;
|
||||
}
|
||||
my $cert = Net::SSLeay::get_peer_certificate($ssl);
|
||||
return "Could not fetch peer certificate" if (!$cert);
|
||||
my @hosts;
|
||||
my $subject = Net::SSLeay::X509_get_subject_name($cert);
|
||||
if ($subject) {
|
||||
my $cn = Net::SSLeay::X509_NAME_get_text_by_NID($subject, 13);
|
||||
push(@hosts, $cn) if (defined($cn) && $cn ne "" && $cn ne "-1");
|
||||
}
|
||||
my @alts = Net::SSLeay::X509_get_subjectAltNames($cert);
|
||||
while(my ($type, $val) = splice(@alts, 0, 2)) {
|
||||
push(@hosts, $val) if ($type == 2 || $type == 7);
|
||||
}
|
||||
Net::SSLeay::X509_free($cert);
|
||||
if (&check_ipaddress($host) || &check_ip6address($host)) {
|
||||
return undef if (grep { lc($_) eq lc($host) } @hosts);
|
||||
return @hosts ? "Certificate is for ".join(", ", @hosts).", not $host"
|
||||
: "No certificate names found";
|
||||
}
|
||||
return undef if (@hosts && &ssl_hostname_match($host, \@hosts));
|
||||
return @hosts ? "Certificate is for ".join(", ", @hosts).", not $host"
|
||||
: "No certificate names found";
|
||||
}
|
||||
|
||||
137
servers/link.cgi
137
servers/link.cgi
@@ -191,29 +191,40 @@ else {
|
||||
|
||||
# read back the rest of the page
|
||||
if ($header->{'content-type'} &&
|
||||
$header->{'content-type'} =~ /text\/html/ &&
|
||||
!$header->{'x-no-links'}) {
|
||||
# Fix up HTML
|
||||
$header->{'content-type'} =~ /text\/html/) {
|
||||
# Fix up HTML. Websocket URLs must always be proxied via a local
|
||||
# miniserv ws-link route, even when the linked server sets x-no-links
|
||||
# (as themes do for AJAX page loads), because a websocket connection
|
||||
# cannot be tunnelled through link.cgi itself.
|
||||
my $dolinks = !$header->{'x-no-links'};
|
||||
&cleanup_link_websockets();
|
||||
while($_ = &read_http_connection($con)) {
|
||||
s/src='(\/[^']*)'/src='$url$1'/gi;
|
||||
s/src="(\/[^"]*)"/src="$url$1"/gi;
|
||||
s/src=(\/[^ "'>]*)/src=$url$1/gi;
|
||||
s/href='(\/[^']*)'/href='$url$1'/gi;
|
||||
s/href="(\/[^"]*)"/href="$url$1"/gi;
|
||||
s/href=(\/[^ >"']*)/href=$url$1/gi;
|
||||
s/action='(\/[^']*)'/action='$url$1'/gi;
|
||||
s/action="(\/[^"]*)"/action="$url$1"/gi;
|
||||
s/action=(\/[^ "'>]*)/action=$url$1/gi;
|
||||
s/\.location\s*=\s*'(\/[^']*)'/.location='$url$1'/gi;
|
||||
s/\.location\s*=\s*"(\/[^']*)"/.location="$url$1"/gi;
|
||||
s/window.open\("(\/[^"]*)"/window.open\("$url$1"/gi;
|
||||
s/name=return\s+value="(\/[^"]*)"/name=return value="$url$1"/gi;
|
||||
s/param\s+name=config\s+value='(\/[^']*)'/param name=config value='$url$1'/gi;
|
||||
s/param\s+name=config\s+value="(\/[^']*)"/param name=config value="$url$1"/gi;
|
||||
s/param\s+name=config\s+value=(\/[^']*)/param name=config value=$url$1/gi;
|
||||
# Websocket URLs can appear in JavaScript strings or JSON values,
|
||||
# where slashes are escaped. Ordinary HTML rewrites remain gated
|
||||
# below by x-no-links, but websocket URLs always need local routes.
|
||||
s#(['"])(wss?://[^'"]+)#"$1".®ister_link_websocket($2, $s, $auth)#egi;
|
||||
s#(['"])(wss?:\\/\\/[^'"]+)#"$1".®ister_link_websocket($2, $s, $auth)#egi;
|
||||
if ($dolinks) {
|
||||
s/src='(\/[^']*)'/src='$url$1'/gi;
|
||||
s/src="(\/[^"]*)"/src="$url$1"/gi;
|
||||
s/src=(\/[^ "'>]*)/src=$url$1/gi;
|
||||
s/href='(\/[^']*)'/href='$url$1'/gi;
|
||||
s/href="(\/[^"]*)"/href="$url$1"/gi;
|
||||
s/href=(\/[^ >"']*)/href=$url$1/gi;
|
||||
s/action='(\/[^']*)'/action='$url$1'/gi;
|
||||
s/action="(\/[^"]*)"/action="$url$1"/gi;
|
||||
s/action=(\/[^ "'>]*)/action=$url$1/gi;
|
||||
s/\.location\s*=\s*'(\/[^']*)'/.location='$url$1'/gi;
|
||||
s/\.location\s*=\s*"(\/[^']*)"/.location="$url$1"/gi;
|
||||
s/window.open\("(\/[^"]*)"/window.open\("$url$1"/gi;
|
||||
s/name=return\s+value="(\/[^"]*)"/name=return value="$url$1"/gi;
|
||||
s/param\s+name=config\s+value='(\/[^']*)'/param name=config value='$url$1'/gi;
|
||||
s/param\s+name=config\s+value="(\/[^']*)"/param name=config value="$url$1"/gi;
|
||||
s/param\s+name=config\s+value=(\/[^']*)/param name=config value=$url$1/gi;
|
||||
}
|
||||
print;
|
||||
if (/<applet.*archive=file.jar.*>/) {
|
||||
# Remote webmin file manager applet - give it the
|
||||
if ($dolinks && /<applet.*archive=file.jar.*>/) {
|
||||
# Remote webmin file manager applet - give it the
|
||||
# session ID on *this* system
|
||||
print "<param name=session value=\"$main::session_id\">\n";
|
||||
}
|
||||
@@ -237,3 +248,87 @@ else {
|
||||
}
|
||||
&close_http_connection($con);
|
||||
|
||||
# register_link_websocket(url, &server, auth)
|
||||
# Registers a local miniserv websocket proxy for a websocket URL generated by
|
||||
# the linked Webmin server, and returns the local URL for the browser to use.
|
||||
sub register_link_websocket
|
||||
{
|
||||
my ($wsurl, $s, $auth) = @_;
|
||||
# JSON script responses can encode wss:// as wss:\/\/. Normalize before
|
||||
# matching, and restore the escaping style for the returned URL below.
|
||||
my $escaped_slashes = $wsurl =~ /\\\//;
|
||||
$wsurl =~ s#\\/#/#g;
|
||||
my $host = $s->{'host'};
|
||||
my $port = $s->{'port'};
|
||||
my $ssl = $s->{'ssl'};
|
||||
my $proto = $ssl ? "wss" : "ws";
|
||||
my ($url_proto, $url_host, $url_port, $remote_path) =
|
||||
$wsurl =~ /^(wss?):\/\/(\[[^\]]+\]|[^:\/]+)(?::(\d+))?(\/[^'"]*)$/i;
|
||||
return $wsurl if (!$remote_path || lc($url_proto) ne $proto);
|
||||
return $wsurl if ($url_host =~ /[\s\x00-\x1f\x7f]/ ||
|
||||
$remote_path =~ /[\s\x00-\x1f\x7f]/);
|
||||
my $url_host_cmp = lc($url_host);
|
||||
$url_host_cmp =~ s/^\[|\]$//g;
|
||||
my @valid_hosts = grep { defined($_) && $_ ne "" } ($host, $s->{'ip'});
|
||||
my $link_prefix = &get_webprefix()."/$module_name/link.cgi/$s->{'id'}";
|
||||
my $from_link_path = $remote_path =~ s/^\Q$link_prefix\E//;
|
||||
# Only websocket URLs owned by the linked server are proxied. Some themes
|
||||
# build them with the link.cgi prefix already present; strip that prefix
|
||||
# before registering the backend path.
|
||||
return $wsurl if (!$from_link_path &&
|
||||
!grep { lc($_) eq $url_host_cmp } @valid_hosts);
|
||||
my ($remote_port) = $remote_path =~ /\/ws-(\d+)(?:\?|$)/;
|
||||
return $wsurl if (!$remote_port);
|
||||
|
||||
my $token = &generate_miniserv_websocket_token();
|
||||
my $wspath = "/$module_name/ws-link-$s->{'id'}-$remote_port";
|
||||
my $now = time();
|
||||
my $backend_host = $s->{'ip'} || $host;
|
||||
my $defport = $ssl ? 443 : 80;
|
||||
my $hostheader = $url_host;
|
||||
$hostheader .= ":".(defined($url_port) ? $url_port : $port)
|
||||
if ((defined($url_port) ? $url_port : $port) != $defport);
|
||||
my $origin = ($ssl ? "https" : "http")."://".$hostheader;
|
||||
my $checkssl = $s->{'checkssl'} ? 1 : 0;
|
||||
my %miniserv;
|
||||
&lock_file(&get_miniserv_config_file());
|
||||
&get_miniserv_config(\%miniserv);
|
||||
$miniserv{"websockets_$wspath"} =
|
||||
"host=$backend_host port=$port ssl=$ssl wspath=$remote_path ".
|
||||
"hostheader=$hostheader origin=$origin auth=basic:$auth ".
|
||||
"checkssl=$checkssl nokey=1 user=$main::remote_user ".
|
||||
"token=$token time=$now";
|
||||
&put_miniserv_config(\%miniserv);
|
||||
&unlock_file(&get_miniserv_config_file());
|
||||
&reload_miniserv();
|
||||
|
||||
# Pass the fresh token directly. Re-reading miniserv.conf here can race the
|
||||
# config cache and return a stale token for the same ws-link path.
|
||||
my $rv = &get_miniserv_websocket_url(
|
||||
undef, undef, $module_name, $wspath, $token);
|
||||
$rv =~ s#/#\\/#g if ($escaped_slashes);
|
||||
return $rv;
|
||||
}
|
||||
|
||||
# cleanup_link_websockets()
|
||||
# Removes abandoned websocket proxy routes created for linked Webmin servers.
|
||||
# Active routes are removed by miniserv when their websocket tunnel closes.
|
||||
sub cleanup_link_websockets
|
||||
{
|
||||
my %miniserv;
|
||||
my $now = time();
|
||||
my $changed = 0;
|
||||
&lock_file(&get_miniserv_config_file());
|
||||
&get_miniserv_config(\%miniserv);
|
||||
foreach my $k (keys %miniserv) {
|
||||
next if ($k !~ /^websockets_\/\Q$module_name\E\/ws-link-/);
|
||||
my ($time) = $miniserv{$k} =~ /\btime=(\d+)/;
|
||||
if (!$time || $now - $time > 24*60*60) {
|
||||
delete($miniserv{$k});
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
&put_miniserv_config(\%miniserv) if ($changed);
|
||||
&unlock_file(&get_miniserv_config_file());
|
||||
&reload_miniserv() if ($changed);
|
||||
}
|
||||
|
||||
22
t/miniserv.t
22
t/miniserv.t
@@ -1287,7 +1287,7 @@ EOF
|
||||
subtest 'parse_websockets_config' => sub {
|
||||
no warnings 'once';
|
||||
local %miniserv::config = (
|
||||
'websockets_/chat' => 'host=back.example.com port=9000 proto=ws',
|
||||
'websockets_/chat' => 'host=back.example.com port=9000 proto=ws ssl=1 checkssl=1 backend_session=abc123',
|
||||
'unrelated_key' => 'ignored',
|
||||
);
|
||||
local @miniserv::websocket_paths = ();
|
||||
@@ -1299,6 +1299,26 @@ subtest 'parse_websockets_config' => sub {
|
||||
is($ws->{host}, 'back.example.com', 'host kv parsed');
|
||||
is($ws->{port}, '9000', 'port kv parsed');
|
||||
is($ws->{proto}, 'ws', 'proto kv parsed');
|
||||
is($ws->{ssl}, '1', 'ssl kv parsed');
|
||||
is($ws->{checkssl}, '1', 'SSL check kv parsed');
|
||||
is($ws->{backend_session}, 'abc123', 'backend session kv parsed');
|
||||
};
|
||||
|
||||
# find_websocket_config
|
||||
subtest 'find_websocket_config' => sub {
|
||||
no warnings 'once';
|
||||
local %miniserv::config = ( 'redirect_prefix' => '/prefix' );
|
||||
local @miniserv::websocket_paths = (
|
||||
{ 'path' => '/chat' },
|
||||
);
|
||||
|
||||
my ($ws, $simple) = miniserv::find_websocket_config('/prefix/chat');
|
||||
is($ws->{path}, '/chat', 'matches after redirect prefix stripping');
|
||||
is($simple, '/chat', 'returns canonical websocket path');
|
||||
($ws, $simple) = miniserv::find_websocket_config('/prefix/chat?token=abc');
|
||||
is($ws->{path}, '/chat', 'matches request path with query string');
|
||||
is($simple, '/chat', 'drops query string from canonical path');
|
||||
ok(!miniserv::find_websocket_config('/missing'), 'unknown path rejected');
|
||||
};
|
||||
|
||||
# get_user_details — local-files branch (skips the userdb code path entirely)
|
||||
|
||||
@@ -14294,11 +14294,11 @@ if (!$token) {
|
||||
return $token;
|
||||
}
|
||||
|
||||
# allocate_miniserv_websocket([module], [base-remote-user])
|
||||
# allocate_miniserv_websocket([module], [base-remote-user], [backend-session])
|
||||
# Allocate a new websocket and stores it miniserv.conf file
|
||||
sub allocate_miniserv_websocket
|
||||
{
|
||||
my ($module, $buser) = @_;
|
||||
my ($module, $buser, $backend_session) = @_;
|
||||
$module ||= $module_name;
|
||||
# Find ports already in use
|
||||
&lock_file(&get_miniserv_config_file());
|
||||
@@ -14326,40 +14326,65 @@ my $now = time();
|
||||
my $token = &generate_miniserv_websocket_token();
|
||||
my $opt_buser = "";
|
||||
$opt_buser = " buser=$buser" if (defined($buser) && $buser eq $base_remote_user);
|
||||
my $opt_backend = "";
|
||||
# Some websocket backends are reached with Basic auth and no browser session
|
||||
# cookie. Store the one-time backend session key that the child server expects.
|
||||
$opt_backend = " backend_session=$backend_session"
|
||||
if (defined($backend_session) && $backend_session =~ /^\S+$/);
|
||||
$miniserv{"websockets_$wspath"} = "host=127.0.0.1 port=$port wspath=/ ".
|
||||
"user=$remote_user$opt_buser token=$token time=$now";
|
||||
"user=$remote_user$opt_buser$opt_backend token=$token time=$now";
|
||||
&put_miniserv_config(\%miniserv);
|
||||
&unlock_file(&get_miniserv_config_file());
|
||||
&reload_miniserv();
|
||||
return $port;
|
||||
}
|
||||
|
||||
# get_miniserv_websocket_url(port, [host], [module])
|
||||
# Returns the URL for a websocket
|
||||
# get_miniserv_websocket_url(port, [host], [module], [path], [token])
|
||||
# Returns the browser-visible URL for a websocket. The optional path/token
|
||||
# arguments are used by linked-server websocket proxy routes, whose path does
|
||||
# not follow the normal /module/ws-port form.
|
||||
sub get_miniserv_websocket_url
|
||||
{
|
||||
my ($port, $host, $module) = @_;
|
||||
my ($port, $host, $module, $path, $wstoken) = @_;
|
||||
$module ||= $module_name;
|
||||
my $ws_proto = lc($ENV{'HTTPS'}) eq 'on' ? 'wss' : 'ws';
|
||||
my %miniserv;
|
||||
my $webprefix = &get_webprefix();
|
||||
&get_miniserv_config(\%miniserv);
|
||||
my $trust_proxy = $miniserv{'trust_real_ip'};
|
||||
my $wspath = "/$module/ws-".$port;
|
||||
my $wstoken;
|
||||
if ($miniserv{'websockets_'.$wspath} &&
|
||||
my $default_ws_proto = lc($ENV{'HTTPS'}) eq 'on' ? 'wss' : 'ws';
|
||||
my $ws_proto;
|
||||
# Match the public browser scheme when Webmin is behind a trusted reverse
|
||||
# proxy, but only allow websocket schemes into the returned URL.
|
||||
if ($trust_proxy && $ENV{'HTTP_X_FORWARDED_PROTO'}) {
|
||||
$ws_proto = (split(/\s*,\s*/, $ENV{'HTTP_X_FORWARDED_PROTO'}))[0];
|
||||
$ws_proto =~ s/^\s+|\s+$//g;
|
||||
}
|
||||
if (!$ws_proto && $trust_proxy && $ENV{'HTTP_FORWARDED'} &&
|
||||
(split(/\s*,\s*/, $ENV{'HTTP_FORWARDED'}))[0] =~
|
||||
/(?:^|;)\s*proto="?([^";]+)"?/i) {
|
||||
$ws_proto = $1;
|
||||
}
|
||||
$ws_proto ||= $default_ws_proto;
|
||||
$ws_proto = lc($ws_proto);
|
||||
$ws_proto = 'wss' if ($ws_proto eq 'https');
|
||||
$ws_proto = 'ws' if ($ws_proto eq 'http');
|
||||
$ws_proto = $default_ws_proto if ($ws_proto ne 'wss' && $ws_proto ne 'ws');
|
||||
my $wspath = $path || "/$module/ws-".$port;
|
||||
# If the caller already generated the token, use it directly; otherwise fall
|
||||
# back to reading the token stored for the normal allocated websocket route.
|
||||
if (!defined($wstoken) && $miniserv{'websockets_'.$wspath} &&
|
||||
$miniserv{'websockets_'.$wspath} =~ /\btoken=(\S+)/) {
|
||||
$wstoken = $1;
|
||||
}
|
||||
my $http_host_conf = &trim($miniserv{'websocket_host'} || $host);
|
||||
# Pass as defined
|
||||
# Prefer the explicit websocket host when configured
|
||||
if ($http_host_conf) {
|
||||
if ($http_host_conf !~ /^wss?:\/\//) {
|
||||
$http_host_conf = "$ws_proto://$http_host_conf";
|
||||
}
|
||||
$http_host_conf =~ s/[\/]+$//g;
|
||||
}
|
||||
# Try to rely on the proxy
|
||||
# Otherwise use trusted proxy headers when available
|
||||
if ($trust_proxy && !defined($http_host_conf)) {
|
||||
my $forwarded_host = $ENV{'HTTP_X_FORWARDED_HOST'};
|
||||
if ($forwarded_host) {
|
||||
|
||||
@@ -11,8 +11,6 @@ our (%in, %text, %config, %gconfig, %access, %module_info,
|
||||
$remote_user);
|
||||
ReadParse();
|
||||
|
||||
$ENV{'HTTP_WEBMIN_PATH'} && error($text{'index_eproxy'});
|
||||
|
||||
# Check for needed modules
|
||||
my @modload = (
|
||||
['Digest::SHA', sub { eval { require Digest::SHA; Digest::SHA->import; 1 } }],
|
||||
@@ -181,8 +179,12 @@ ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef,
|
||||
# Print main container
|
||||
print "<div data-label=\"$text{'index_connecting'}\" id=\"terminal\"></div>\n";
|
||||
|
||||
# Get a free port that can be used for the socket
|
||||
my $port = allocate_miniserv_websocket($module_name);
|
||||
# Get a free port that can be used for the socket. Proxied or Basic-auth
|
||||
# requests may not have a Webmin session cookie, so give the backend its own
|
||||
# one-time handshake secret.
|
||||
my $websocket_session_id = $main::session_id || generate_miniserv_websocket_token();
|
||||
my $port = allocate_miniserv_websocket(
|
||||
$module_name, undef, $websocket_session_id);
|
||||
|
||||
# Decide which Unix account the terminal will run as
|
||||
my $user = resolve_shell_user(\%access, $remote_user, \%in, \%config);
|
||||
@@ -197,7 +199,9 @@ my $shellserver_cmd = "$module_config_directory/shellserver.pl";
|
||||
if (!-r $shellserver_cmd) {
|
||||
create_wrapper($shellserver_cmd, $module_name, "shellserver.pl");
|
||||
}
|
||||
$ENV{'SESSION_ID'} = $main::session_id;
|
||||
# shellserver.pl validates the websocket key against SESSION_ID; keep it in
|
||||
# sync with the backend_session stored in miniserv.conf above.
|
||||
$ENV{'SESSION_ID'} = $websocket_session_id;
|
||||
system_logged($shellserver_cmd." ".quotemeta($port)." ".quotemeta($user).
|
||||
($dir ? " ".quotemeta($dir) : "").
|
||||
" >$module_var_directory/websocket-connection-$port.out 2>&1 </dev/null");
|
||||
@@ -210,8 +214,8 @@ my $term_script = <<EOF;
|
||||
(function() {
|
||||
const socket = new WebSocket('$url', 'binary'),
|
||||
termcont = document.getElementById('terminal'),
|
||||
err_conn_cannot = 'Cannot connect to the socket $url',
|
||||
err_conn_lost = 'Connection to the socket $url lost',
|
||||
err_conn_cannot = 'Cannot connect to the socket ' + socket.url,
|
||||
err_conn_lost = 'Connection to the socket ' + socket.url + ' lost',
|
||||
webGLAddonLink = '$webGLAddon',
|
||||
detectWebGLContext = (function() {
|
||||
const canvas = document.createElement("canvas"),
|
||||
|
||||
Reference in New Issue
Block a user