Config-based KasmVNC

This commit is contained in:
Dmitry Maksyoma
2022-08-19 17:54:11 +00:00
committed by Anthony Merrill
parent d9cf46f83e
commit 36a1ffc5e4
78 changed files with 6214 additions and 954 deletions

84
spec/fixtures/defaults_config.yaml vendored Normal file
View File

@@ -0,0 +1,84 @@
desktop:
resolution:
width: 1024
height: 768
allow_resize: true
pixel_depth: 24
network:
protocol: http
interface: 0.0.0.0
websocket_port: auto
use_ipv4: true
use_ipv6: true
ssl:
pem_certificate: /etc/ssl/certs/ssl-cert-snakeoil.pem
pem_key: /etc/ssl/private/ssl-cert-snakeoil.key
require_ssl: true
security:
brute_force_protection:
blacklist_threshold: 5
blacklist_timeout: 10
data_loss_prevention:
visible_region:
# top: 10
# left: 10
# right: 40
# bottom: 40
concealed_region:
allow_click_down: false
allow_click_release: false
clipboard:
delay_between_operations: none
server_to_client:
enabled: true
size: 10000
primary_clipboard_enabled: false
client_to_server:
enabled: true
size: 10000
keyboard:
enabled: true
rate_limit: unlimited
logging:
level: off
encoding:
max_frame_rate: 60
rect_encoding_mode:
min_quality: 7
max_quality: 8
consider_lossless_quality: 10
rectangle_compress_threads: auto
video_encoding_mode:
jpeg_quality: -1
webp_quality: -1
max_resolution:
width: 1920
height: 1080
enter_video_encoding_mode:
time_threshold: 5
area_threshold: 45%
exit_video_encoding_mode:
time_threshold: 3
logging:
level: off
scaling_algorithm: progressive_bilinear
compare_framebuffer: auto
zrle_zlib_level: auto
hextile_improved_compression: true
server:
advanced:
x_font_path: auto
httpd_directory: /usr/share/kasmvnc/www
kasm_password_file: ~/.kasmpasswd
x_authority_file: auto
auto_shutdown:
no_user_session_timeout: never
active_user_session_timeout: never
inactive_user_session_timeout: never

3
spec/fixtures/global_config.yaml vendored Normal file
View File

@@ -0,0 +1,3 @@
security:
brute_force_protection:
blacklist_threshold: 6

4
spec/fixtures/user_config.yaml vendored Normal file
View File

@@ -0,0 +1,4 @@
security:
brute_force_protection:
blacklist_threshold: 7
blacklist_timeout: 12

0
spec/helper/__init__.py Normal file
View File

View File

@@ -0,0 +1,93 @@
import os
import sys
import shutil
import subprocess
import pexpect
from path import Path
from expects import expect, equal
vncserver_cmd = 'vncserver :1'
running_xvnc = False
debug_output = False
config_dir = "spec/tmp"
config_filename = os.path.join(config_dir, "config.yaml")
if os.getenv('KASMVNC_SPEC_DEBUG_OUTPUT'):
debug_output = True
def write_config(config_text):
os.makedirs(config_dir, exist_ok=True)
with open(config_filename, "w") as f:
f.write(config_text)
def clean_env():
clean_kasm_users()
home_dir = os.environ['HOME']
vnc_dir = os.path.join(home_dir, ".vnc")
Path(vnc_dir).rmtree(ignore_errors=True)
def clean_kasm_users():
home_dir = os.environ['HOME']
password_file = os.path.join(home_dir, ".kasmpasswd")
Path(password_file).remove_p()
def start_xvnc_pexpect(extra_args="", **kwargs):
global running_xvnc
# ":;" is a hack. Without it, Xvnc doesn't run. No idea what happens, but
# when I run top, Xvnc just isn't there. I suspect a race.
child = pexpect.spawn('/bin/bash',
['-ic', f':;{vncserver_cmd} {extra_args}'],
timeout=5, encoding='utf-8', **kwargs)
if debug_output:
child.logfile_read = sys.stderr
running_xvnc = True
return child
def start_xvnc(extra_args="", **kwargs):
global running_xvnc
completed_process = run_cmd(f'{vncserver_cmd} {extra_args}',
print_stderr=False, **kwargs)
if completed_process.returncode == 0:
running_xvnc = True
return completed_process
def run_cmd(cmd, print_stderr=True, **kwargs):
completed_process = subprocess.run(cmd, shell=True, text=True,
capture_output=True,
executable='/bin/bash', **kwargs)
if debug_output:
if len(completed_process.stderr) > 0:
print(completed_process.stderr)
if len(completed_process.stdout) > 0:
print(completed_process.stdout)
elif print_stderr:
if len(completed_process.stderr) > 0:
print(completed_process.stderr)
return completed_process
def add_kasmvnc_user_docker():
completed_process = run_cmd('echo -e "password\\npassword\\n" | vncpasswd -u docker -w')
expect(completed_process.returncode).to(equal(0))
def kill_xvnc():
global running_xvnc
if not running_xvnc:
return
run_cmd('vncserver -kill :1', print_stderr=False)
running_xvnc = False

42
spec/kasmvnc.yaml Normal file
View File

@@ -0,0 +1,42 @@
desktop:
resolution:
width: 1024
height: 768
allow_resize: true
pixel_depth: 24
security:
brute_force_protection:
blacklist_threshold: 1
blacklist_timeout: 10
logging:
log_writer_name: all
log_dest: logfile
# 0 - silent(?), 100 - most verbose
level: 100
data_loss_prevention:
visible_region:
# top: 10
# left: 10
# right: 40
# bottom: 40
concealed_region:
allow_click_down: false
allow_click_release: false
clipboard:
delay_between_operations: none
# Cut buffers and CLIPBOARD selection.
server_to_client:
enabled: true
size: unlimited
primary_clipboard_enabled: false
keyboard:
remap_keys:
- 0x22->0x40
- 0x24->0x40
command_line:
prompt: true

View File

@@ -0,0 +1,10 @@
from mamba import description, context, it, fit, before, after
from expects import expect, equal, contain, match
from helper.spec_helper import start_xvnc, kill_xvnc, run_cmd, clean_env, \
add_kasmvnc_user_docker, clean_kasm_users, start_xvnc_pexpect, \
write_config, config_filename
with description("Perl warnings"):
with it("treats Perl warning as error"):
completed_process = run_cmd("vncserver -dry-run")
expect(completed_process.stderr).not_to(match(r'line \d+\.$'))

View File

@@ -0,0 +1,155 @@
import os
import sys
import pexpect
from mamba import description, context, it, fit, before, after
from expects import expect, equal
from helper.spec_helper import start_xvnc, kill_xvnc, run_cmd, clean_env, \
add_kasmvnc_user_docker, clean_kasm_users, start_xvnc_pexpect
# WIP. Plan to move to start_xvnc_pexpect(), because pexpect provides a way to
# wait for vncserver output. start_xvnc() just blindly prints input to vncserver
# without knowing what it prints back.
def select_de(de_name):
try:
extra_args = f'-select-de {de_name}'
completed_process = start_xvnc(extra_args)
expect(completed_process.returncode).to(equal(0))
finally:
kill_xvnc()
def check_de_was_setup_to_run(de_name):
completed_process = run_cmd(f'grep -q {de_name} ~/.vnc/xstartup')
expect(completed_process.returncode).to(equal(0))
with description('vncserver') as self:
with before.each:
clean_env()
with after.each:
kill_xvnc()
with context('on the first run'):
with before.each:
add_kasmvnc_user_docker()
with it('asks user to select a DE'):
child = start_xvnc_pexpect()
child.expect(r'\[1\] Cinnamon.+Manually edit')
child.sendline("1\n")
child.wait()
expect(child.exitstatus).to(equal(0))
check_de_was_setup_to_run('cinnamon')
with it("doesn't prompt user to select a DE if prompting is disabled"):
child = start_xvnc_pexpect("-prompt 0")
child.expect(r'Warning: the Desktop Environment.+wasn\'t selected')
child.wait()
expect(child.exitstatus).to(equal(0))
with it('asks to select a DE, when ran with -select-de'):
child = start_xvnc_pexpect('-select-de')
child.expect(r'\[1\] Cinnamon.+Manually edit')
child.sendline("1\n")
child.wait()
expect(child.exitstatus).to(equal(0))
check_de_was_setup_to_run('cinnamon')
with it("doesn't prompt user to select a DE if prompting is disabled"):
child = start_xvnc_pexpect("-select-de -prompt 0")
child.expect(r'Warning: the Desktop Environment.+wasn\'t selected')
child.wait()
expect(child.exitstatus).to(equal(0))
with it('selects passed DE with -s'):
select_de('mate')
check_de_was_setup_to_run('mate')
with it('selects manual xstartup editing, not a DE'):
select_de('manual')
check_de_was_setup_to_run('twm')
with context('after DE was selected'):
with before.each:
add_kasmvnc_user_docker()
with it("don't ask to choose DE by default"):
select_de('mate')
completed_process = start_xvnc()
expect(completed_process.returncode).to(equal(0))
check_de_was_setup_to_run('mate')
with it('asks to select a DE, when ran with -select-de'):
select_de('mate')
choose_cinnamon_and_answer_yes = "1\ny\n"
completed_process = start_xvnc('-select-de',
input=choose_cinnamon_and_answer_yes)
expect(completed_process.returncode).to(equal(0))
check_de_was_setup_to_run('cinnamon')
with it("doesn't ask user to select a DE if prompting is disabled"):
child = start_xvnc_pexpect("-select-de -prompt 0")
child.expect(r'Warning: the Desktop Environment.+wasn\'t selected')
child.wait()
expect(child.exitstatus).to(equal(0))
with it('selects passed DE with -s'):
select_de('mate')
completed_process = start_xvnc('-select-de cinnamon')
expect(completed_process.returncode).to(equal(0))
check_de_was_setup_to_run('cinnamon')
with context('guided user creation'):
with it('asks to create a user if none exist'):
clean_kasm_users()
child = start_xvnc_pexpect('-select-de cinnamon')
child.expect('Provide selection number:')
child.sendline('1')
child.expect('Enter username')
child.sendline()
child.expect('Password:')
child.sendline('password')
child.expect('Verify:')
child.sendline('password')
child.expect(pexpect.EOF)
child.close()
expect(child.exitstatus).to(equal(0))
home_dir = os.environ['HOME']
user = os.environ['USER']
completed_process = run_cmd(f'grep -qw {user} {home_dir}/.kasmpasswd')
expect(completed_process.returncode).to(equal(0))
with it('specify custom username'):
custom_username = 'custom_username'
child = start_xvnc_pexpect('-select-de cinnamon')
child.expect('Provide selection number:')
child.sendline('1')
child.expect('Enter username')
child.sendline(custom_username)
child.expect('Password:')
child.sendline('password')
child.expect('Verify:')
child.sendline('password')
child.expect(pexpect.EOF)
child.wait()
expect(child.exitstatus).to(equal(0))
home_dir = os.environ['HOME']
completed_process = run_cmd(f'grep -qw {custom_username} {home_dir}/.kasmpasswd')
expect(completed_process.returncode).to(equal(0))

View File

@@ -0,0 +1,297 @@
import os
import re
import shutil
from os.path import expanduser
from mamba import description, context, fcontext, it, fit, before, after
from expects import expect, equal, contain, match
from helper.spec_helper import start_xvnc, kill_xvnc, run_cmd, clean_env, \
add_kasmvnc_user_docker, clean_kasm_users, start_xvnc_pexpect, \
write_config, config_filename
home_dir = expanduser("~")
vnc_dir = f'{home_dir}/.vnc'
user_config = f'{vnc_dir}/kasmvnc.yaml'
def run_vncserver():
return run_cmd(f'vncserver -dry-run -config {config_filename}')
def pick_cli_option(cli_option, xvnc_cmd):
cli_option_regex = re.compile(f'\'?-{cli_option}\'?(?:\s+[^-][^\s]*|$)')
results = cli_option_regex.findall(xvnc_cmd)
if len(results) == 0:
return None
return ' '.join(results)
def prepare_env():
os.makedirs(vnc_dir, exist_ok=True)
shutil.copyfile('spec/kasmvnc.yaml', user_config)
with description('YAML to CLI') as self:
with before.all:
prepare_env()
with context("convert a boolean key"):
with it("convert true to 1"):
write_config('''
desktop:
allow_resize: true
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('AcceptSetDesktopSize',
completed_process.stdout)
expect(cli_option).to(equal("-AcceptSetDesktopSize '1'"))
with it("convert false to 0"):
write_config('''
desktop:
allow_resize: false
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('AcceptSetDesktopSize',
completed_process.stdout)
expect(cli_option).to(equal("-AcceptSetDesktopSize '0'"))
with it("converts a numeric key to a CLI arg"):
write_config('''
security:
brute_force_protection:
blacklist_threshold: 2
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('BlacklistThreshold',
completed_process.stdout)
expect(cli_option).to(equal("-BlacklistThreshold '2'"))
with it("converts an ANY key to a CLI arg"):
write_config('''
network:
ssl:
pem_certificate: /etc/ssl/certs/ssl-cert-snakeoil.pem
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('cert',
completed_process.stdout)
expect(cli_option).to(
equal("-cert '/etc/ssl/certs/ssl-cert-snakeoil.pem'"))
with it("converts an array key to a CLI arg"):
write_config('''
keyboard:
remap_keys:
- 0x22->0x40
- 0x24->0x40
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('RemapKeys',
completed_process.stdout)
expect(cli_option).to(
equal("-RemapKeys '0x22->0x40,0x24->0x40'"))
with it("converts a constant value to the corresponding numeric value"):
write_config('''
data_loss_prevention:
clipboard:
server_to_client:
size: 20
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('DLP_ClipSendMax',
completed_process.stdout)
expect(cli_option).to(equal("-DLP_ClipSendMax '20'"))
with context("websocketPort"):
with it("converts 'auto' value to calculated value"):
write_config('''
network:
websocket_port: auto
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('websocketPort',
completed_process.stdout)
expect(["-websocketPort '8444'", "-websocketPort '8445'"]). \
to(contain(cli_option))
with it("passes numeric value to CLI option"):
write_config('''
network:
websocket_port: 8555
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('websocketPort',
completed_process.stdout)
expect(cli_option).to(equal("-websocketPort '8555'"))
with it("no key - no CLI option"):
write_config('''
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('websocketPort',
completed_process.stdout)
expect(cli_option).to(equal(None))
with context("option that can yield nothing"):
with it("converts a config value that yields nothing"):
write_config('''
network:
protocol: http
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('noWebsocket',
completed_process.stdout)
expect(cli_option).to(equal(None))
with it("converts a config value that yields CLI option"):
write_config('''
network:
protocol: vnc
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('noWebsocket',
completed_process.stdout)
expect(cli_option).to(equal("-noWebsocket '1'"))
with it("interpolates env variable"):
write_config('''
server:
advanced:
kasm_password_file: ${HOME}/.kasmpasswd
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('KasmPasswordFile',
completed_process.stdout)
expect(cli_option).to(equal("-KasmPasswordFile '/home/docker/.kasmpasswd'"))
with it("converts logging options into one -Log"):
write_config('''
logging:
log_writer_name: all
log_dest: logfile
level: 40
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('Log',
completed_process.stdout)
expect(cli_option).to(equal("-Log '*:stdout:40'"))
with it("converts DLP region options into one -DLP_Region"):
write_config('''
data_loss_prevention:
visible_region:
top: -10
left: 10
right: 40%
bottom: 40
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('DLP_Region',
completed_process.stdout)
expect(cli_option).to(equal("-DLP_Region '10,-10,40%,40'"))
with context("converts x_font_path"):
with it("auto"):
write_config('''
server:
advanced:
x_font_path: auto
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('fp',
completed_process.stdout)
expect(cli_option).to(match(r'/usr/share/fonts'))
with it("none specified"):
write_config('''
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('fp',
completed_process.stdout)
expect(cli_option).to(match(r'/usr/share/fonts'))
with it("path specified"):
write_config('''
server:
advanced:
x_font_path: /src
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('fp', completed_process.stdout)
expect(cli_option).to(equal("-fp '/src'"))
with it("CLI override"):
write_config('''
server:
advanced:
x_font_path: /src
''')
completed_process = \
run_cmd(f'vncserver -dry-run -fp /override -config {config_filename}')
cli_option = pick_cli_option('fp', completed_process.stdout)
expect(cli_option).to(equal("-fp '/override'"))
with it("converts network.interface to -interface"):
write_config('''
network:
interface: 0.0.0.0
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('interface',
completed_process.stdout)
expect(cli_option).to(equal("-interface '0.0.0.0'"))
with it("CLI option directly passed, overrides config"):
write_config('''
encoding:
video_encoding_mode:
jpeg_quality: -1
''')
completed_process = \
run_cmd(f'vncserver -dry-run -JpegVideoQuality 8 -config {config_filename}')
cli_option = pick_cli_option("JpegVideoQuality",
completed_process.stdout)
expect(cli_option).to(equal("'-JpegVideoQuality' '8'"))
with it("converts 2 keys into a single CLI option"):
write_config('''
desktop:
resolution:
width: 1024
height: 768
''')
completed_process = run_vncserver()
cli_option = pick_cli_option('geometry',
completed_process.stdout)
expect(cli_option).to(equal("-geometry '1024x768'"))
with it("ignores empty section override"):
write_config('''
security:
''')
completed_process = \
run_cmd(f'vncserver -dry-run -config spec/fixtures/global_config.yaml,{config_filename}')
cli_option = pick_cli_option('BlacklistThreshold',
completed_process.stdout)
expect(cli_option).to(equal("-BlacklistThreshold '6'"))
with it("overrides default config value with global config value"):
completed_process = run_cmd("vncserver -dry-run -config spec/fixtures/defaults_config.yaml,spec/fixtures/global_config.yaml")
cli_option = pick_cli_option('BlacklistThreshold',
completed_process.stdout)
expect(cli_option).to(equal("-BlacklistThreshold '6'"))
with it("uses default config value even if section was overriden"):
completed_process = run_cmd("vncserver -dry-run -config spec/fixtures/defaults_config.yaml,spec/fixtures/global_config.yaml")
cli_option = pick_cli_option('BlacklistTimeout',
completed_process.stdout)
expect(cli_option).to(equal("-BlacklistTimeout '10'"))
with it("overrides global config with user config value"):
completed_process = run_cmd("vncserver -dry-run -config spec/fixtures/defaults_config.yaml,spec/fixtures/global_config.yaml,spec/fixtures/user_config.yaml")
cli_option = pick_cli_option('BlacklistThreshold',
completed_process.stdout)
expect(cli_option).to(equal("-BlacklistThreshold '7'"))

View File

@@ -0,0 +1,111 @@
from mamba import description, context, fit, it, before, after
from expects import expect, equal, contain
from helper.spec_helper import start_xvnc, kill_xvnc, run_cmd, clean_env, \
add_kasmvnc_user_docker, clean_kasm_users, start_xvnc_pexpect, \
config_filename, write_config
with description('YAML validation') as self:
with it("produces error message for an incomplete data clump"):
write_config('''
desktop:
resolution:
width: 1024
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain('desktop.resolution.width, desktop.resolution.height: either all keys or none must be present'))
with it("produces error message if int key was set to a string"):
write_config('''
desktop:
resolution:
width: 1024
height: none
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain("desktop.resolution.height 'none': must be an integer"))
with it("produces no error for valid boolean values"):
write_config('''
network:
use_ipv4: true
use_ipv6: false
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}')
expect(completed_process.stderr).to(equal(''))
with it("produces an error for invalid boolean values"):
write_config('''
desktop:
allow_resize: 10
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain("desktop.allow_resize '10': must be true or false"))
with it("produces an error for invalid enum value"):
write_config('''
desktop:
pixel_depth: none
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain("desktop.pixel_depth 'none': must be one of [16, 24, 32]"))
with it("produces an error for invalid pattern enum value"):
write_config('''
desktop:
pixel_depth: 16|24|32
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain("desktop.pixel_depth '16|24|32': must be one of [16, 24, 32]"))
with it("produces an error fo partially present enum value"):
write_config('''
network:
protocol: vnc2
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain("network.protocol 'vnc2': must be one of [http, vnc]"))
with it("is silent for a valid enum value"):
write_config('''
desktop:
pixel_depth: 16
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}')
expect(completed_process.stderr).to(equal(""))
with it("produces an error for an array value"):
write_config('''
keyboard:
remap_keys:
- 0xzz->0x40
- 0x24->0x40
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(contain("keyboard.remap_keys '0xzz->0x40, 0x24->0x40': must be in the format 0x<hex_number>->0x<hex_number>"))
with context("unsupported keys"):
with it("produces an error for an unsupported top-level key"):
write_config('''
foo: 1
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(
contain("Unsupported config keys found:\nfoo"))
with it("produces an error for an unsupported 2nd-level key"):
write_config('''
bar:
baz: 1
''')
completed_process = run_cmd(f'vncserver -dry-run -test-output-topic validation -config {config_filename}', print_stderr=False)
expect(completed_process.stderr).to(
contain("Unsupported config keys found:\nbar.baz"))