From 45ca170c20fb09150bcb65f535cfced3e413f14c Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 22 Jun 2026 16:19:33 +0200 Subject: [PATCH 01/11] 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. --- WebminCore.pm | 2 +- miniserv.pl | 252 +++++++++++++++++++++++++++++++++++++++-------- servers/link.cgi | 137 ++++++++++++++++++++++---- t/miniserv.t | 22 ++++- web-lib-funcs.pl | 49 ++++++--- xterm/index.cgi | 18 ++-- 6 files changed, 396 insertions(+), 84 deletions(-) diff --git a/WebminCore.pm b/WebminCore.pm index 0e7c144cc..030ebb499 100644 --- a/WebminCore.pm +++ b/WebminCore.pm @@ -23,7 +23,7 @@ $main::export_to_caller = 1; # Add functions in web-lib-funcs.pl # Generated with : # grep -h "^sub " web-lib-funcs.pl ui-lib.pl | sed -e 's/sub //' | xargs echo -@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress generate_icon urlize un_urlize include copydata ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase); +@EXPORT = qw(read_file read_file_cached read_file_cached_with_stat write_file html_escape html_unescape html_strip quote_escape quote_literal_escape quote_javascript default_webmin_temp_dir tempname_dir tempname_dir_sys webmin_temp_dir_name webmin_temp_dir_path tempname transname transname_timestamped trunc indexof indexoflc sysprint check_ipaddress check_ip6address is_non_public_ipaddress generate_icon urlize un_urlize include copydata ReadParseMime ReadParse read_fully read_parse_mime_callback read_parse_mime_javascript PrintHeader header get_html_title get_html_framed_title get_html_status_line popup_header footer popup_footer load_module_preferences load_theme_library redirect kill_byname kill_byname_logged find_byname error error_stderr popup_error register_error_handler call_error_handlers error_setup wait_for fast_wait_for has_command make_date make_date_relative file_chooser_button popup_window_button popup_window_link read_acl acl_filename acl_check get_miniserv_config_file get_miniserv_config put_miniserv_config restart_miniserv reload_miniserv check_os_support http_download complete_http_download http_post ftp_download ftp_upload no_proxy open_socket download_timeout ftp_command to_ipaddress to_ip6address to_hostname icons_table replace_meta replace_file_line read_file_lines flush_file_lines unflush_file_lines unix_user_input unix_group_input hlink user_chooser_button group_chooser_button foreign_check foreign_exists foreign_available foreign_require foreign_call foreign_config foreign_installed foreign_defined get_system_hostname get_webmin_version get_webmin_version_release get_webmin_full_version get_module_acl get_group_module_acl save_module_acl save_group_module_acl init_config load_language_auto load_language text_subs text encode_base64 decode_base64 encode_base32 decode_base32 get_module_info get_all_module_infos list_themes get_theme_info list_locales list_languages safe_language read_env_file write_env_file lock_file unlock_file test_lock unlock_all_files can_lock_file webmin_log additional_log var_dump webmin_debug_log system_logged backquote_logged backquote_with_timeout backquote_command kill_logged rename_logged rename_file symlink_logged symlink_file link_file make_dir make_dir_recursive set_ownership_permissions unlink_logged unlink_file copy_permissions_source_dest copy_source_dest move_source_dest remote_session_name verify_session_id remote_foreign_require remote_foreign_call remote_foreign_check remote_foreign_config remote_eval remote_write remote_read remote_finished remote_error_setup remote_rpc_call remote_multi_callback remote_multi_callback_error serialise_variable unserialise_variable other_groups date_chooser_button help_file read_help_file seed_random disk_usage_kb recursive_disk_usage help_search_link can_use_http_ssl make_http_connection validate_ssl_connection read_http_connection write_http_connection close_http_connection read_http_headers get_http_auth_reason clean_environment reset_environment clean_language progress_callback switch_to_remote_user switch_to_unix_user eval_as_unix_user create_user_config_dirs create_missing_homedir filter_javascript resolve_links simplify_path same_file flush_webmin_caches list_usermods available_usermods get_available_module_infos get_visible_module_infos get_visible_modules_categories is_under_directory parse_http_url check_clicks_function load_entities_map entities_to_ascii get_product_name get_charset get_display_hostname save_module_config save_user_module_config nice_size get_perl_path get_goto_module select_all_link select_invert_link select_rows_link check_pid_file get_mod_lib module_root_directory list_mime_types guess_mime_type open_tempfile close_tempfile print_tempfile is_selinux_enabled get_clear_file_attributes reset_file_attributes cleanup_tempnames open_lock_tempfile END month_to_number number_to_month supports_ipv6 execute_command execute_command_logged open_readfile open_execute_command translate_filename translate_command register_filename_callback register_command_callback capture_function_output capture_function_output_tempfile modules_chooser_button substitute_template substitute_pattern running_in_zone running_in_vserver running_in_xen running_in_openvz list_categories is_readonly_mode command_as_user list_osdn_mirrors convert_osdn_url get_current_dir supports_users supports_symlinks quote_path get_windows_root read_file_contents write_file_contents read_file_contents_limit unix_crypt split_quoted_string write_to_http_cache check_in_http_cache clear_http_cache supports_javascript get_module_name get_module_variable clear_time_locale reset_time_locale callers_package web_libs_package get_userdb_string connect_userdb disconnect_userdb split_userdb_string uniquelc list_combined_webmin_menu list_modules_webmin_menu module_to_menu_item list_combined_system_info shell_is_bash compare_version_numbers convert_to_json convert_from_json print_json get_referer_relative get_webmin_email_url get_webmin_browser_url trim ui_link ui_help ui_img ui_link_button ui_table_start ui_table_end ui_table_row ui_table_hr ui_table_span ui_columns_start ui_columns_row ui_columns_header ui_checked_columns_row ui_radio_columns_row ui_columns_end ui_columns_table ui_form_columns_table ui_form_elements_wrapper ui_form_start ui_form_end ui_form_end_side_by_side ui_form_grouped_buttons ui_textbox ui_filebox ui_bytesbox ui_upload ui_password ui_hidden ui_select ui_multi_select ui_multi_select_javascript ui_radio ui_yesno_radio ui_radio_row ui_checkbox ui_oneradio ui_textarea ui_user_textbox ui_users_textbox ui_group_textbox ui_groups_textbox ui_opt_textbox ui_submit ui_reset ui_button ui_date_input ui_buttons_start ui_buttons_end ui_buttons_row ui_buttons_hr ui_post_header ui_pre_footer ui_print_header ui_print_unbuffered_header ui_print_footer ui_config_link ui_print_endpage ui_subheading ui_links_row ui_hidden_javascript ui_hidden_start ui_hidden_end ui_hidden_table_row_start ui_hidden_table_row_end ui_hidden_table_start ui_hidden_table_end ui_tabs_start ui_tabs_end ui_tabs_start_tab ui_tabs_start_tabletab ui_tabs_end_tab ui_tabs_end_tabletab ui_max_text_width ui_radio_selector ui_radio_selector_javascript ui_switch_theme_javascript ui_grid_table ui_radio_table ui_up_down_arrows ui_hr ui_nav_link ui_confirmation_form ui_text_color ui_alert_box js_disable_inputs ui_page_flipper ui_form_field_state_javascript js_checkbox_disable js_redirect ui_webmin_link ui_line_break_double ui_page_refresh ui_details ui_div_row ui_space ui_newline ui_text_wrap ui_element_inline ui_paginations ui_hide_outside_of_viewport ui_read_file_contents_limit ui_note ui_brh ui_tag_start ui_tag_content ui_tag_end ui_tag ui_alert ui_button_icon ui_link_icon ui_icon ui_br ui_p ui_div ui_text_mask get_python_cmd get_buffer_size get_buffer_size_binary get_webprefix get_sub_ref_name setvar getvar delvar print_call_stack webmin_user_can_rpc webmin_user_login_mode webmin_user_is_admin webmin_user_is get_current_theme_info_cached miniserv_using_default_cert is_int float is_float parse_accepted_language get_default_system_locale get_http_redirect get_http_cookie create_wrapper get_lock_links_dir generate_miniserv_websocket_token allocate_miniserv_websocket get_miniserv_websocket_url remove_miniserv_websocket cleanup_miniserv_websockets get_miniserv_websockets_modules get_webmin_base_url encrypt_phrase decrypt_phrase is_encrypt_phrase); # Add global variables in web-lib.pl push(@EXPORT, qw(&unique)); diff --git a/miniserv.pl b/miniserv.pl index 7cf6b0aaa..2d0e130d7 100755 --- a/miniserv.pl +++ b/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"; +} diff --git a/servers/link.cgi b/servers/link.cgi index 13d937324..aed9f5df3 100755 --- a/servers/link.cgi +++ b/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 (//) { - # Remote webmin file manager applet - give it the + if ($dolinks && //) { + # Remote webmin file manager applet - give it the # session ID on *this* system print "\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); +} diff --git a/t/miniserv.t b/t/miniserv.t index c9335a153..f65ee2f97 100644 --- a/t/miniserv.t +++ b/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) diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 0e9b1fbcc..6c5a10eb9 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -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) { diff --git a/xterm/index.cgi b/xterm/index.cgi index a9c6157e7..1a7818021 100755 --- a/xterm/index.cgi +++ b/xterm/index.cgi @@ -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 "
\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 Date: Mon, 22 Jun 2026 20:02:56 +0200 Subject: [PATCH 02/11] Fix parent route cleanup work --- miniserv.pl | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/miniserv.pl b/miniserv.pl index 2d0e130d7..af100596d 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -6358,14 +6358,19 @@ 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); + # link.cgi, so remove them and refresh the master when the tunnel ends. + open(my $config_lock, "<", $config_file) || + die "Failed to open config file $config_file : $!"; + flock($config_lock, 2); my %miniserv = &read_config_file($config_file); + my $deleted; if (delete($miniserv{"websockets_$ws->{'path'}"})) { &write_file($config_file, \%miniserv); + $deleted = 1; } - &unlock_file($config_file); - &reload_miniserv(); + flock($config_lock, 8); + close($config_lock); + kill('USR1', $miniserv_main_pid || getppid()) if ($deleted); } print DEBUG "done websockets loop\n"; From 26311baab94beacd6fe1d2816dd8161a96bbe544 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 22 Jun 2026 20:34:00 +0200 Subject: [PATCH 03/11] Fix to use Webmin config locking for ws-link cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Make linked websocket teardown use the same `miniserv.conf.lock` convention as `link.cgi`, release the lock safely on cleanup errors --- miniserv.pl | 81 +++++++++++++++++++++++++++++++++++++++++++++------- t/miniserv.t | 24 ++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) diff --git a/miniserv.pl b/miniserv.pl index af100596d..93a62d31a 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -5097,6 +5097,63 @@ close(CONF); return %rv; } +# lock_config_file(file) +# Lock a config file using Webmin's .lock sidecar convention. miniserv.pl must +# stay standalone, but link.cgi also edits miniserv.conf through this lock. +sub lock_config_file +{ +my ($file) = @_; +if ($file =~ /\r|\n|\0/) { + die "Lock filename contains invalid characters"; + } +my $lockfile = $file.".lock"; +my $tries = 0; +my $last_lock_err; +while(1) { + my $pid; + # Webmin's lock file contains the owner PID. A dead owner means the lock + # can be safely reclaimed. + if (open(my $locking, "<", $lockfile)) { + $pid = int(<$locking>); + close($locking); + } + if (!$pid || !kill(0, $pid) || $pid == $$) { + # The sidecar lock is the shared convention with web-lib-funcs.pl; + # the non-blocking flock just narrows races between lock claimers. + unlink($lockfile); + if (open(my $locking, ">", $lockfile)) { + my $locked = eval { flock($locking, 2+4) }; + my $ok; + if ($locked) { + $ok = print $locking $$,"\n"; + } + my $closed = close($locking); + return 1 if ($locked && $ok && $closed); + unlink($lockfile); + $last_lock_err = "Lock failed : ".($@ || $!); + } + else { + $last_lock_err = "Lock open failed : ".$!; + } + } + elsif ($pid) { + $last_lock_err = "Locked by PID $pid"; + } + sleep(1); + if ($tries++ > 5*60) { + die "Failed to lock config file $file : $last_lock_err"; + } + } +} + +# unlock_config_file(file) +# Release a config lock taken by lock_config_file. +sub unlock_config_file +{ +my ($file) = @_; +unlink($file.".lock"); +} + # read_any_file(file) # Reads any given file and returns its content sub read_any_file @@ -6359,17 +6416,21 @@ close(SOCK); if ($ws->{'path'} =~ /\/ws-link-/) { # Linked-server websocket routes are single-use routes registered by # link.cgi, so remove them and refresh the master when the tunnel ends. - open(my $config_lock, "<", $config_file) || - die "Failed to open config file $config_file : $!"; - flock($config_lock, 2); - my %miniserv = &read_config_file($config_file); + &lock_config_file($config_file); my $deleted; - if (delete($miniserv{"websockets_$ws->{'path'}"})) { - &write_file($config_file, \%miniserv); - $deleted = 1; - } - flock($config_lock, 8); - close($config_lock); + # Always release the config lock, even if the read/write path fails. + my $cleanup_ok = eval { + my %miniserv = &read_config_file($config_file); + if (delete($miniserv{"websockets_$ws->{'path'}"})) { + &write_file($config_file, \%miniserv); + $deleted = 1; + } + 1; + }; + my $cleanup_err = $@; + &unlock_config_file($config_file); + die $cleanup_err if (!$cleanup_ok); + # The master keeps websocket routes parsed in memory. kill('USR1', $miniserv_main_pid || getppid()) if ($deleted); } print DEBUG "done websockets loop\n"; diff --git a/t/miniserv.t b/t/miniserv.t index f65ee2f97..acb35c135 100644 --- a/t/miniserv.t +++ b/t/miniserv.t @@ -1245,6 +1245,30 @@ EOF ok(!exists $got{'# this is a comment'}, 'comment lines skipped'); }; +# lock_config_file — matches Webmin's .lock convention without loading web-lib +subtest 'lock_config_file' => sub { + require File::Temp; + my ($fh, $path) = File::Temp::tempfile(UNLINK => 1); + close($fh); + + ok(miniserv::lock_config_file($path), 'lock succeeds'); + ok(-e "$path.lock", 'sidecar lock file created'); + + open(my $locking, '<', "$path.lock") or die "open lock: $!"; + chomp(my $pid = <$locking>); + close($locking); + is($pid, $$, 'lock records the current PID'); + + miniserv::unlock_config_file($path); + ok(!-e "$path.lock", 'unlock removes sidecar lock file'); + + open($locking, '>', "$path.lock") or die "create stale lock: $!"; + print $locking "999999999\n"; + close($locking); + ok(miniserv::lock_config_file($path), 'stale lock is reclaimed'); + miniserv::unlock_config_file($path); +}; + # read_any_file — basic file reader; returns undef on open failure subtest 'read_any_file' => sub { require File::Temp; From a0b67db65baa0187f6d66ad15a6bd9a06953d543 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 22 Jun 2026 20:41:02 +0200 Subject: [PATCH 04/11] Fix to simplify Miniserv config lock handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Remove the obsolete eval around `flock` in the `miniserv` config lock helper and report lock, write, and close failures explicitly. --- miniserv.pl | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/miniserv.pl b/miniserv.pl index 93a62d31a..7264a4068 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -5122,15 +5122,23 @@ while(1) { # the non-blocking flock just narrows races between lock claimers. unlink($lockfile); if (open(my $locking, ">", $lockfile)) { - my $locked = eval { flock($locking, 2+4) }; - my $ok; - if ($locked) { - $ok = print $locking $$,"\n"; + if (!flock($locking, 2+4)) { + $last_lock_err = "Flock failed : ".$!; + close($locking); + unlink($lockfile); + } + elsif (!print $locking $$,"\n") { + $last_lock_err = "Lock write failed : ".$!; + close($locking); + unlink($lockfile); + } + elsif (!close($locking)) { + $last_lock_err = "Lock close failed : ".$!; + unlink($lockfile); + } + else { + return 1; } - my $closed = close($locking); - return 1 if ($locked && $ok && $closed); - unlink($lockfile); - $last_lock_err = "Lock failed : ".($@ || $!); } else { $last_lock_err = "Lock open failed : ".$!; From fac6b478b5edaada4b469361976f4308d906455f Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 22 Jun 2026 21:47:16 +0200 Subject: [PATCH 05/11] Fix to drain pending SSL data in websocket proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Check OpenSSL's pending buffer before `select()` in the websocket forwarding loop so TLS-backed linked websocket streams do not stall during bursty backend output. --- miniserv.pl | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/miniserv.pl b/miniserv.pl index 7264a4068..bd0601cd8 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -6258,6 +6258,13 @@ my $backend_readline = sub { return $line; }; +# OpenSSL can keep decrypted bytes in its own buffer after the socket fd stops +# being readable, so the forwarding loop must check this before select(). +my $backend_pending = sub { + return $backend_ssl && defined(&Net::SSLeay::pending) && + Net::SSLeay::pending($backend_ssl) > 0; + }; + # Prepare successful connection headers, but don't send them to the browser # until the backend websocket handshake has succeeded. eval "use Digest::SHA"; @@ -6384,20 +6391,32 @@ my $last_session_check_time = time(); # 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; - vec($rmask, fileno(SOCK), 1) = 1; - my $sel = select($rmask, undef, undef, 10); my ($buf, $ok); my $uptime = 0; - if (vec($rmask, fileno($fh), 1)) { + my ($backend_ready, $browser_ready); + if (&$backend_pending()) { + $backend_ready = 1; + my $bm = undef; + vec($bm, fileno(SOCK), 1) = 1; + select($bm, undef, undef, 0); + $browser_ready = vec($bm, fileno(SOCK), 1); + } + else { + my $rmask = undef; + vec($rmask, fileno($fh), 1) = 1; + vec($rmask, fileno(SOCK), 1) = 1; + my $sel = select($rmask, undef, undef, 10); + $backend_ready = vec($rmask, fileno($fh), 1); + $browser_ready = vec($rmask, fileno(SOCK), 1); + } + if ($backend_ready) { # Got something from the websockets backend $buf = $backend_read->(1024); last if (!defined($buf) || length($buf) == 0); &write_data($buf); $uptime = 1; } - if (vec($rmask, fileno(SOCK), 1)) { + if ($browser_ready) { # Got something from the browser $buf = &read_data(1024); last if (!defined($buf) || length($buf) == 0); From d42a6dc725f90c41a4452dd06807eeb57da41618 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 22 Jun 2026 23:01:46 +0200 Subject: [PATCH 06/11] Fix parent-prefixed linked websocket rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Correct linked-server WebSocket proxy registration for parent-prefixed URLs, rebuild backend Host/Origin from the child server, and prevent duplicate rewrites from invalidating tokens. --- servers/link.cgi | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/servers/link.cgi b/servers/link.cgi index aed9f5df3..0879f73a2 100755 --- a/servers/link.cgi +++ b/servers/link.cgi @@ -197,13 +197,18 @@ if ($header->{'content-type'} && # (as themes do for AJAX page loads), because a websocket connection # cannot be tunnelled through link.cgi itself. my $dolinks = !$header->{'x-no-links'}; + my %websocket_links; &cleanup_link_websockets(); while($_ = &read_http_connection($con)) { # 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; + s#(['"])(wss?://[^'"]+)# + "$1".®ister_link_websocket( + $2, $s, $auth, \%websocket_links)#egi; + s#(['"])(wss?:\\/\\/[^'"]+)# + "$1".®ister_link_websocket( + $2, $s, $auth, \%websocket_links)#egi; if ($dolinks) { s/src='(\/[^']*)'/src='$url$1'/gi; s/src="(\/[^"]*)"/src="$url$1"/gi; @@ -253,7 +258,7 @@ else { # the linked Webmin server, and returns the local URL for the browser to use. sub register_link_websocket { -my ($wsurl, $s, $auth) = @_; +my ($wsurl, $s, $auth, $cache) = @_; # 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 =~ /\\\//; @@ -277,17 +282,28 @@ my $from_link_path = $remote_path =~ s/^\Q$link_prefix\E//; # before registering the backend path. return $wsurl if (!$from_link_path && !grep { lc($_) eq $url_host_cmp } @valid_hosts); +return $wsurl if (!$from_link_path && defined($url_port) && + $url_port != $port); my ($remote_port) = $remote_path =~ /\/ws-(\d+)(?:\?|$)/; return $wsurl if (!$remote_port); +# Reuse the same local URL when a response repeats the same backend websocket. +# Otherwise the later rewrite would replace the config token for the earlier URL. +my $cache_key = join("\0", $s->{'id'}, $remote_path); +if ($cache && $cache->{$cache_key}) { + my $rv = $cache->{$cache_key}; + $rv =~ s#/#\\/#g if ($escaped_slashes); + return $rv; + } my $token = &generate_miniserv_websocket_token(); -my $wspath = "/$module_name/ws-link-$s->{'id'}-$remote_port"; +my $wspath = "/$module_name/ws-link-$s->{'id'}-$remote_port-$token"; 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); +# If the URL was already routed through this parent link.cgi, its host is the +# parent server. Backend Host, Origin and TLS checks must use the child server. +my $hostheader = $from_link_path ? $host : $url_host; +$hostheader .= ":".$port if ($port != $defport); my $origin = ($ssl ? "https" : "http")."://".$hostheader; my $checkssl = $s->{'checkssl'} ? 1 : 0; my %miniserv; @@ -306,6 +322,7 @@ $miniserv{"websockets_$wspath"} = # 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); +$cache->{$cache_key} = $rv if ($cache); $rv =~ s#/#\\/#g if ($escaped_slashes); return $rv; } From 6091f08e37f6afffc981fcbf361e0fdcb85c9e82 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Mon, 22 Jun 2026 23:01:58 +0200 Subject: [PATCH 07/11] Fix comment --- servers/link.cgi | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/servers/link.cgi b/servers/link.cgi index 0879f73a2..3459eb149 100755 --- a/servers/link.cgi +++ b/servers/link.cgi @@ -253,7 +253,7 @@ else { } &close_http_connection($con); -# register_link_websocket(url, &server, auth) +# register_link_websocket(url, &server, auth, \%cache) # 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 From c72d232e2f21a5b4484be9bf7b0632642f3c72e2 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 23 Jun 2026 01:09:43 +0200 Subject: [PATCH 08/11] Fix to restrict Basic auth for websocket routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Require websocket routes to opt in with allow_basic_ws before Basic auth is accepted in session mode. Mark linked ws-link routes and no-cookie backend-session routes as allowed, while leaving normal session-backed routes unmarked. --- miniserv.pl | 15 +++++++++++---- servers/link.cgi | 2 +- t/miniserv.t | 3 ++- web-lib-funcs.pl | 4 +++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/miniserv.pl b/miniserv.pl index bd0601cd8..fe17098f8 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -1512,6 +1512,7 @@ alarm(0); my $websocket_upgrade_request = lc($header{'connection'}) =~ /upgrade/ && lc($header{'upgrade'}) eq 'websocket'; my $websocket_configured_request; +my $websocket_basic_auth_ok; if ($websocket_upgrade_request) { # Check the configured websocket paths before auth, so Basic auth can # remain disabled for normal session-mode requests. @@ -1519,6 +1520,12 @@ if ($websocket_upgrade_request) { my $ws_simple = &simplify_path($page, $wsbogus); $websocket_configured_request = !$wsbogus && &find_websocket_config($ws_simple); + if ($websocket_configured_request) { + # Session-mode Basic auth stays disabled unless the websocket + # route explicitly allows a no-cookie Basic-auth hop. + $websocket_basic_auth_ok = + $websocket_configured_request->{'allow_basic_ws'}; + } } # If a remote IP is given in a header (such as via a proxy), only use it @@ -1840,11 +1847,11 @@ if (!$validated && !$deny_authentication) { } } -# 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. +# Keep Basic auth disabled in session mode except for websocket routes that +# explicitly allow no-cookie Basic-auth hops. Token/user checks still run +# before the backend connection is opened. if (!$validated && !$deny_authentication && - (!$config{'session'} || $websocket_configured_request) && + (!$config{'session'} || $websocket_basic_auth_ok) && $header{authorization} =~ /^basic\s+(\S+)$/i) { # authorization given.. ($authuser, $authpass) = split(/:/, &b64decode($1), 2); diff --git a/servers/link.cgi b/servers/link.cgi index 3459eb149..ea2b7c3d2 100755 --- a/servers/link.cgi +++ b/servers/link.cgi @@ -312,7 +312,7 @@ my %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 ". + "checkssl=$checkssl nokey=1 allow_basic_ws=1 user=$main::remote_user ". "token=$token time=$now"; &put_miniserv_config(\%miniserv); &unlock_file(&get_miniserv_config_file()); diff --git a/t/miniserv.t b/t/miniserv.t index acb35c135..59a690352 100644 --- a/t/miniserv.t +++ b/t/miniserv.t @@ -1311,7 +1311,7 @@ EOF subtest 'parse_websockets_config' => sub { no warnings 'once'; local %miniserv::config = ( - 'websockets_/chat' => 'host=back.example.com port=9000 proto=ws ssl=1 checkssl=1 backend_session=abc123', + 'websockets_/chat' => 'host=back.example.com port=9000 proto=ws ssl=1 checkssl=1 backend_session=abc123 allow_basic_ws=1', 'unrelated_key' => 'ignored', ); local @miniserv::websocket_paths = (); @@ -1326,6 +1326,7 @@ subtest 'parse_websockets_config' => sub { is($ws->{ssl}, '1', 'ssl kv parsed'); is($ws->{checkssl}, '1', 'SSL check kv parsed'); is($ws->{backend_session}, 'abc123', 'backend session kv parsed'); + is($ws->{allow_basic_ws}, '1', 'Basic auth websocket flag parsed'); }; # find_websocket_config diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 6c5a10eb9..005d821d0 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -14331,8 +14331,10 @@ my $opt_backend = ""; # 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+$/); +my $opt_basic = ""; +$opt_basic = " allow_basic_ws=1" if ($opt_backend); $miniserv{"websockets_$wspath"} = "host=127.0.0.1 port=$port wspath=/ ". - "user=$remote_user$opt_buser$opt_backend token=$token time=$now"; + "user=$remote_user$opt_buser$opt_backend$opt_basic token=$token time=$now"; &put_miniserv_config(\%miniserv); &unlock_file(&get_miniserv_config_file()); &reload_miniserv(); From fa09b191b93de81ac61a83dcc4383c16324223ab Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 23 Jun 2026 01:12:44 +0200 Subject: [PATCH 09/11] Fix to avoid storing browser sessions for xterm websockets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Only store `backend_session` for xterm websocket routes when there is no real browser session and a one-time backend key is needed. Normal xterm sessions continue using the browser session directly. --- xterm/index.cgi | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/xterm/index.cgi b/xterm/index.cgi index 1a7818021..8921cc6d2 100755 --- a/xterm/index.cgi +++ b/xterm/index.cgi @@ -8,7 +8,7 @@ our $unsafe_index_cgi = 1; require './xterm-lib.pl'; ## no critic our (%in, %text, %config, %gconfig, %access, %module_info, $module_name, $module_config_directory, $module_var_directory, - $remote_user); + $remote_user, $session_id); ReadParse(); # Check for needed modules @@ -179,12 +179,18 @@ ui_print_header(undef, $text{'index_title'}, "", undef, 1, 1, 0, undef, # Print main container print "
\n"; -# 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(); +# Get a free port that can be used for the socket. Normal browser sessions +# are revalidated by miniserv while the websocket stays open. Proxied or +# Basic-auth requests may not have a session cookie, so only those routes get +# a one-time backend handshake secret. +my $websocket_session_id = $session_id; +my $backend_session; +if (!$websocket_session_id) { + $websocket_session_id = generate_miniserv_websocket_token(); + $backend_session = $websocket_session_id; + } my $port = allocate_miniserv_websocket( - $module_name, undef, $websocket_session_id); + $module_name, undef, $backend_session); # Decide which Unix account the terminal will run as my $user = resolve_shell_user(\%access, $remote_user, \%in, \%config); @@ -199,8 +205,9 @@ my $shellserver_cmd = "$module_config_directory/shellserver.pl"; if (!-r $shellserver_cmd) { create_wrapper($shellserver_cmd, $module_name, "shellserver.pl"); } -# shellserver.pl validates the websocket key against SESSION_ID; keep it in -# sync with the backend_session stored in miniserv.conf above. +# shellserver.pl validates the backend websocket key against SESSION_ID. For +# normal sessions miniserv forwards the browser session; no-cookie routes use +# the one-time backend_session stored in miniserv.conf above. $ENV{'SESSION_ID'} = $websocket_session_id; system_logged($shellserver_cmd." ".quotemeta($port)." ".quotemeta($user). ($dir ? " ".quotemeta($dir) : ""). From 14c8d9b61e1317b4e538e35238266e5a49bedfe9 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 23 Jun 2026 01:30:29 +0200 Subject: [PATCH 10/11] Fix to clean up consumed linked websocket routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Remove single-use ws-link routes when backend setup fails or after the backend handshake is consumed, with final loop cleanup kept as a fallback. This prevents failed linked websocket retries from leaving temporary credential-bearing routes in `miniserv.conf`. --- miniserv.pl | 134 ++++++++++++++++++++++++++++++++-------------------- 1 file changed, 83 insertions(+), 51 deletions(-) diff --git a/miniserv.pl b/miniserv.pl index fe17098f8..a42c7651e 100755 --- a/miniserv.pl +++ b/miniserv.pl @@ -6173,6 +6173,14 @@ if (!grep { $_ eq $parsed_origin } @allowed_origins) { } my @protos = split(/\s*,\s*/, $header{'sec-websocket-protocol'}); print DEBUG "websockets protos ",join(" ", @protos),"\n"; +# Once token and origin checks have passed, a ws-link route is a live +# single-use credential. If the backend cannot complete its handshake, remove +# that route before http_error exits this child. +my $backend_fail = sub { + &cleanup_websocket_route($ws); + &http_error(@_); + return 0; + }; # Connect to the configured backend my $fh = "WEBSOCKET"; @@ -6180,22 +6188,23 @@ 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); + return &$backend_fail(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; + return &$backend_fail(500, + "Missing Net::SSLeay perl module"); } $backend_ssl_ctx = Net::SSLeay::CTX_new(); if (!$backend_ssl_ctx) { - &http_error(500, "Failed to create SSL context"); - return 0; + return &$backend_fail(500, + "Failed to create SSL context"); } $backend_ssl = Net::SSLeay::new($backend_ssl_ctx); if (!$backend_ssl) { - &http_error(500, "Failed to create SSL connection"); - return 0; + return &$backend_fail(500, + "Failed to create SSL connection"); } Net::SSLeay::set_fd($backend_ssl, fileno($fh)); my $sslhost = $ws->{'hostheader'} || $ws->{'host'}; @@ -6213,17 +6222,16 @@ if ($ws->{'host'}) { 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; + return &$backend_fail(500, + "SSL connect to websockets backend failed"); } if ($ws->{'checkssl'}) { my $err = &check_websocket_backend_ssl( $backend_ssl, $sslhost); if ($err) { - &http_error(500, + return &$backend_fail(500, "Invalid SSL certificate from websockets ". "backend : $err"); - return 0; } } } @@ -6232,11 +6240,11 @@ if ($ws->{'host'}) { elsif ($ws->{'pipe'}) { # Backend is a Unix pipe open($fh, $ws->{'pipe'}) || - &http_error(500, "Websockets pipe failed : $?"); + return &$backend_fail(500, "Websockets pipe failed : $?"); print DEBUG "websockets pipe $ws->{'pipe'}\n"; } else { - &http_error(500, "Invalid Webmin websockets config"); + return &$backend_fail(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. @@ -6276,7 +6284,7 @@ my $backend_pending = sub { # until the backend websocket handshake has succeeded. eval "use Digest::SHA"; if ($@) { - &http_error(500, "Missing Digest::SHA perl module"); + return &$backend_fail(500, "Missing Digest::SHA perl module"); } my $rkey = $key."258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; my $sha1 = Digest::SHA->new; @@ -6320,22 +6328,22 @@ $backend_write->("\r\n"); # Read back the reply my $rh = $backend_readline->(); if (!defined($rh)) { - &http_error(500, "No response from websockets backend"); - return 0; + return &$backend_fail(500, "No response from websockets backend"); } $rh =~ s/\r|\n//g; print DEBUG "got $rh from websockets backend\n"; -$rh =~ /^HTTP\/1\.1\s+(\d+)/ || - &http_error(500, "Bad response from websockets backend : ". - &html_strip($rh)); +if ($rh !~ /^HTTP\/1\.1\s+(\d+)/) { + return &$backend_fail(500, "Bad response from websockets backend : ". + &html_strip($rh)); + } my $code = $1; my %rheader; my $lastheader; while(1) { $rh = $backend_readline->(); if (!defined($rh)) { - &http_error(500, "Unexpected EOF from websockets backend"); - return 0; + return &$backend_fail(500, + "Unexpected EOF from websockets backend"); } $rh =~ s/\r|\n//g; last if ($rh eq ""); @@ -6347,18 +6355,24 @@ while(1) { $rheader{$lastheader} .= $headline; } else { - &http_error(500, "Bad header from websockets backend ". - &html_strip($rh)); + return &$backend_fail(500, + "Bad header from websockets backend ". + &html_strip($rh)); } } if ($code != 101) { - &http_error(500, "Bad response code $code from websockets backend : ". - &html_strip($rh)); + return &$backend_fail(500, + "Bad response code $code from websockets backend : ". + &html_strip($rh)); + } +if (lc($rheader{'upgrade'}) ne 'websocket') { + return &$backend_fail(500, + "Missing Upgrade header from websockets backend"); + } +if (lc($rheader{'connection'}) !~ /upgrade/) { + return &$backend_fail(500, + "Missing Connection header from websockets backend"); } -lc($rheader{'upgrade'}) eq 'websocket' || - &http_error(500, "Missing Upgrade header from websockets backend"); -lc($rheader{'connection'}) =~ /upgrade/ || - &http_error(500, "Missing Connection header from websockets backend"); # Check the reply key my $bdigest; @@ -6373,8 +6387,14 @@ else { $bdigest = &b64encode($bdigest); } print DEBUG "expecting digest $bdigest\n"; -lc($rheader{'sec-websocket-accept'}) eq lc($bdigest) || - &http_error(500, "Incorrect digest header from websockets backend"); +if (lc($rheader{'sec-websocket-accept'}) ne lc($bdigest)) { + return &$backend_fail(500, + "Incorrect digest header from websockets backend"); + } +# The route has been consumed by this child process. The active tunnel now owns +# the open backend socket, so the temporary miniserv.conf route can be removed +# before the tunnel goes long-lived. Final cleanup remains a harmless fallback. +&cleanup_websocket_route($ws); # Send successful connection headers &write_data("HTTP/1.1 101 Switching Protocols\r\n"); @@ -6447,31 +6467,43 @@ 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 and refresh the master when the tunnel ends. - &lock_config_file($config_file); - my $deleted; - # Always release the config lock, even if the read/write path fails. - my $cleanup_ok = eval { - my %miniserv = &read_config_file($config_file); - if (delete($miniserv{"websockets_$ws->{'path'}"})) { - &write_file($config_file, \%miniserv); - $deleted = 1; - } - 1; - }; - my $cleanup_err = $@; - &unlock_config_file($config_file); - die $cleanup_err if (!$cleanup_ok); - # The master keeps websocket routes parsed in memory. - kill('USR1', $miniserv_main_pid || getppid()) if ($deleted); - } +&cleanup_websocket_route($ws); print DEBUG "done websockets loop\n"; return 0; } +# cleanup_websocket_route(&wsconfig) +# Removes a single-use linked-server websocket route from miniserv.conf. +sub cleanup_websocket_route +{ +my ($ws) = @_; +# Only link.cgi-created ws-link routes are temporary. Ordinary module +# websocket routes are managed by their owning code and must be left alone. +return 0 if (!$ws || !$ws->{'path'} || $ws->{'path'} !~ /\/ws-link-/); +my $deleted; +my $locked; +my $cleanup_ok = eval { + &lock_config_file($config_file); + $locked = 1; + my %miniserv = &read_config_file($config_file); + if (delete($miniserv{"websockets_$ws->{'path'}"})) { + &write_file($config_file, \%miniserv); + $deleted = 1; + } + 1; + }; +my $cleanup_err = $@; +eval { &unlock_config_file($config_file) } if ($locked); +if (!$cleanup_ok) { + &log_error("Failed to cleanup linked websocket route", $cleanup_err); + return 0; + } +# The master keeps websocket routes parsed in memory. +kill('USR1', $miniserv_main_pid || getppid()) if ($deleted); +return $deleted; +} + # get_system_hostname() # Returns the hostname of this system, for reporting to listeners sub get_system_hostname From 7878ef466f15ded360a5ad855c88b0e42642d701 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 23 Jun 2026 02:24:17 +0200 Subject: [PATCH 11/11] Fix to expire abandoned linked websocket routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Use the existing scheduled websocket cleanup path for linked-server ws-link routes and expire unopened temporary routes after five minutes. This limits how long credential-bearing proxy routes can remain in `miniserv.conf` while leaving active and normal websocket cleanup behavior unchanged. --- servers/link.cgi | 5 +++-- servers/module.info | 1 + web-lib-funcs.pl | 13 ++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/servers/link.cgi b/servers/link.cgi index ea2b7c3d2..798eaab21 100755 --- a/servers/link.cgi +++ b/servers/link.cgi @@ -329,18 +329,19 @@ 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. +# Routes opened by the browser are removed by miniserv when consumed. sub cleanup_link_websockets { my %miniserv; my $now = time(); +my $link_ttl = 5*60; 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) { + if (!$time || $now - $time > $link_ttl) { delete($miniserv{$k}); $changed++; } diff --git a/servers/module.info b/servers/module.info index b5a046d81..ade452882 100644 --- a/servers/module.info +++ b/servers/module.info @@ -4,3 +4,4 @@ desc=Webmin Servers Index longdesc=Displays an index of other Webmin servers for easy linking. readonly=1 depends=mailboxes cron +websockets=1 diff --git a/web-lib-funcs.pl b/web-lib-funcs.pl index 005d821d0..6f2bdc69e 100755 --- a/web-lib-funcs.pl +++ b/web-lib-funcs.pl @@ -14429,19 +14429,26 @@ sub cleanup_miniserv_websockets my ($skip, $module) = @_; $skip ||= [ ]; $module ||= $module_name; +my $link_ttl = 5*60; &lock_file(&get_miniserv_config_file()); my %miniserv; &get_miniserv_config(\%miniserv); my $now = time(); my @clean; foreach my $k (keys %miniserv) { - $k =~ /^websockets_\/$module\/ws-(\d+)$/ || next; - my $port = $1; - next if (&indexof($port, @$skip) >= 0); my $when = 0; if ($miniserv{$k} =~ /time=(\d+)/) { $when = $1; } + if ($k =~ /^websockets_\/\Q$module\E\/ws-link-/) { + # Linked-server websocket routes carry a backend credential and are + # single-use. If the browser never opens them, expire them by age. + push(@clean, $k) if (!$when || $now - $when > $link_ttl); + next; + } + $k =~ /^websockets_\/\Q$module\E\/ws-(\d+)$/ || next; + my $port = $1; + next if (&indexof($port, @$skip) >= 0); if ($now - $when > 60) { # Has been open for a while, check if the port is still in use? my $err;