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:
Ilia Ross
2026-06-22 16:19:33 +02:00
parent 4064f0675c
commit 45ca170c20
6 changed files with 396 additions and 84 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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";
}

View File

@@ -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".&register_link_websocket($2, $s, $auth)#egi;
s#(['"])(wss?:\\/\\/[^'"]+)#"$1".&register_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);
}

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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"),