├── desktop ├── Version ├── tcfiles ├── desktop ├── xinitrc ├── dhcp.network ├── usb-access.rules ├── xorg.conf ├── autologin ├── interfaces ├── pipewire-pulse.conf ├── set-hostname ├── 099_tc ├── tc-wifipower.service ├── tc-copyconfig.service ├── tc-copywpa.service ├── debian.sources ├── firstboot ├── auto-maintenance.debian ├── bashrc ├── .fvwm │ └── config └── thinclient ├── login.png ├── .gitignore ├── UFTC Thin Client Troubleshooting.docx ├── d2vm ├── Dockerfile └── Readme.md /desktop: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /Version: -------------------------------------------------------------------------------- 1 | 2.2 2 | -------------------------------------------------------------------------------- /tcfiles/desktop: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tcfiles/xinitrc: -------------------------------------------------------------------------------- 1 | fvwm -------------------------------------------------------------------------------- /login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henk717/uftc/HEAD/login.png -------------------------------------------------------------------------------- /tcfiles/dhcp.network: -------------------------------------------------------------------------------- 1 | [Match] 2 | Name=* 3 | 4 | [Network] 5 | DHCP=yes 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.vhd 3 | *.deb 4 | *.Identifier 5 | tcconfig* 6 | *.AppImage -------------------------------------------------------------------------------- /tcfiles/usb-access.rules: -------------------------------------------------------------------------------- 1 | SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", MODE="0666" -------------------------------------------------------------------------------- /tcfiles/xorg.conf: -------------------------------------------------------------------------------- 1 | Section "ServerFlags" 2 | Option "DontVTSwitch" "true" 3 | EndSection -------------------------------------------------------------------------------- /tcfiles/autologin: -------------------------------------------------------------------------------- 1 | [Service] 2 | ExecStart= 3 | ExecStart=-/sbin/mingetty --autologin thinclient tty1 -------------------------------------------------------------------------------- /tcfiles/interfaces: -------------------------------------------------------------------------------- 1 | auto eth0 2 | allow-hotplug eth0 3 | iface eth0 inet dhcp 4 | up ethtool -s eth0 wol g -------------------------------------------------------------------------------- /tcfiles/pipewire-pulse.conf: -------------------------------------------------------------------------------- 1 | pulse.cmd = [ 2 | { cmd = "load-module" args = "module-switch-on-connect" } 3 | ] 4 | -------------------------------------------------------------------------------- /UFTC Thin Client Troubleshooting.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henk717/uftc/HEAD/UFTC Thin Client Troubleshooting.docx -------------------------------------------------------------------------------- /d2vm: -------------------------------------------------------------------------------- 1 | docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock --privileged -v .:/d2vm -w /d2vm linkacloud/d2vm:latest $* 2 | -------------------------------------------------------------------------------- /tcfiles/set-hostname: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | if [ -z "$1" ];then 3 | echo You did not specify a hostname 4 | exit 5 | fi 6 | 7 | read oldhost /etc/systemd/system/auto-maintenance.service 49 | systemctl enable auto-maintenance 50 | echo Automatic Maintenance will be enabled upon reboot. 51 | echo Use systemctl start auto-maintenance to start the service immediately. 52 | read -p "Press a key to continue" 53 | exit 0 54 | fi 55 | 56 | if [[ $1 = "disable" ]]; then 57 | systemctl stop auto-maintenance 58 | systemctl disable auto-maintenance 59 | rm /etc/systemd/system/auto-maintenance.service 60 | echo Automatic Maintenance is now disabled. 61 | read -p "Press a key to continue" 62 | exit 0 63 | fi 64 | 65 | # Ensure we are not running in a Live Environment (That could make us run out of RAM on old ISO's). 66 | if grep -q "Live session" /etc/passwd; then 67 | echo Auto Maintenance should not be performed on a Live Environment. 68 | echo If you wish to manually install or update programs use Apt . 69 | exit 1 70 | fi 71 | 72 | # NOTE: This script will ALWAYS keep the currently running kernel 73 | # NOTE: Default is to keep 2 more, user overrides with --keep N 74 | KEEP=2 75 | # NOTE: Any unrecognized option will be passed straight through to apt 76 | APT_OPTS= 77 | while [ ! -z "$1" ]; do 78 | case "$1" in 79 | --keep) 80 | # User specified the number of kernels to keep 81 | KEEP="$2" 82 | shift 2 83 | ;; 84 | *) 85 | APT_OPTS="$APT_OPTS $1" 86 | shift 1 87 | ;; 88 | esac 89 | done 90 | 91 | # Build our list of kernel packages to purge 92 | CANDIDATES=$(ls -tr /boot/vmlinuz-* | head -n -${KEEP} | grep -v "$(uname -r)$" | cut -d- -f2- | awk '{print "linux-image-" $0 " linux-headers-" $0}' ) 93 | for c in $CANDIDATES; do 94 | dpkg-query -s "$c" >/dev/null 2>&1 && PURGE="$PURGE $c" 95 | done 96 | 97 | #Ensure we have the latest list of packages 98 | apt update 99 | 100 | #Fix broken dependencies and installations 101 | dpkg --configure -a 102 | DEBIAN_FRONTEND=noninteractive \ 103 | apt-get \ 104 | -o Dpkg::Options::=--force-confold \ 105 | -o Dpkg::Options::=--force-confdef \ 106 | -y --allow-downgrades --allow-remove-essential --allow-change-held-packages \ 107 | install -f 108 | 109 | if [ ! -z "$PURGE" ]; then 110 | apt $APT_OPTS remove -yq --purge $PURGE 111 | fi 112 | 113 | #Remove unrequired packages 114 | apt autoremove --purge -yq 115 | 116 | #Update the system using APT 117 | DEBIAN_FRONTEND=noninteractive \ 118 | apt-get \ 119 | -o Dpkg::Options::=--force-confold \ 120 | -o Dpkg::Options::=--force-confdef \ 121 | -y --allow-downgrades --allow-remove-essential --allow-change-held-packages \ 122 | dist-upgrade 123 | 124 | #Update flatpak's and remove unused dependencies 125 | flatpak update -y 126 | flatpak uninstall --unused -y 127 | 128 | #Update and remove unused Snap's 129 | snap refresh 130 | snap list --all | while read snapname ver rev trk pub notes; do if [[ $notes = *disabled* ]]; then snap remove "$snapname" --revision="$rev"; fi; done 131 | -------------------------------------------------------------------------------- /tcfiles/bashrc: -------------------------------------------------------------------------------- 1 | # ~/.bashrc: executed by bash(1) for non-login shells. 2 | # see /usr/share/doc/bash/examples/startup-files (in the package bash-doc) 3 | # for examples 4 | 5 | # If not running interactively, don't do anything 6 | case $- in 7 | *i*) ;; 8 | *) return;; 9 | esac 10 | 11 | # don't put duplicate lines or lines starting with space in the history. 12 | # See bash(1) for more options 13 | HISTCONTROL=ignoreboth 14 | 15 | # append to the history file, don't overwrite it 16 | shopt -s histappend 17 | 18 | # for setting history length see HISTSIZE and HISTFILESIZE in bash(1) 19 | HISTSIZE=1000 20 | HISTFILESIZE=2000 21 | 22 | # check the window size after each command and, if necessary, 23 | # update the values of LINES and COLUMNS. 24 | shopt -s checkwinsize 25 | 26 | # If set, the pattern "**" used in a pathname expansion context will 27 | # match all files and zero or more directories and subdirectories. 28 | #shopt -s globstar 29 | 30 | # make less more friendly for non-text input files, see lesspipe(1) 31 | [ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)" 32 | 33 | # set variable identifying the chroot you work in (used in the prompt below) 34 | if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then 35 | debian_chroot=$(cat /etc/debian_chroot) 36 | fi 37 | 38 | # set a fancy prompt (non-color, unless we know we "want" color) 39 | case "$TERM" in 40 | xterm-color|*-256color) color_prompt=yes;; 41 | esac 42 | 43 | # uncomment for a colored prompt, if the terminal has the capability; turned 44 | # off by default to not distract the user: the focus in a terminal window 45 | # should be on the output of commands, not on the prompt 46 | #force_color_prompt=yes 47 | 48 | if [ -n "$force_color_prompt" ]; then 49 | if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then 50 | # We have color support; assume it's compliant with Ecma-48 51 | # (ISO/IEC-6429). (Lack of such support is extremely rare, and such 52 | # a case would tend to support setf rather than setaf.) 53 | color_prompt=yes 54 | else 55 | color_prompt= 56 | fi 57 | fi 58 | 59 | if [ "$color_prompt" = yes ]; then 60 | PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ ' 61 | else 62 | PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ ' 63 | fi 64 | unset color_prompt force_color_prompt 65 | 66 | # If this is an xterm set the title to user@host:dir 67 | case "$TERM" in 68 | xterm*|rxvt*) 69 | PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1" 70 | ;; 71 | *) 72 | ;; 73 | esac 74 | 75 | # enable color support of ls and also add handy aliases 76 | if [ -x /usr/bin/dircolors ]; then 77 | test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)" 78 | alias ls='ls --color=auto' 79 | #alias dir='dir --color=auto' 80 | #alias vdir='vdir --color=auto' 81 | 82 | alias grep='grep --color=auto' 83 | alias fgrep='fgrep --color=auto' 84 | alias egrep='egrep --color=auto' 85 | fi 86 | 87 | # colored GCC warnings and errors 88 | #export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01' 89 | 90 | # some more ls aliases 91 | alias ll='ls -alF' 92 | alias la='ls -A' 93 | alias l='ls -CF' 94 | 95 | # Add an "alert" alias for long running commands. Use like so: 96 | # sleep 10; alert 97 | alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"' 98 | 99 | # Alias definitions. 100 | # You may want to put all your additions into a separate file like 101 | # ~/.bash_aliases, instead of adding them here directly. 102 | # See /usr/share/doc/bash-doc/examples in the bash-doc package. 103 | 104 | if [ -f ~/.bash_aliases ]; then 105 | . ~/.bash_aliases 106 | fi 107 | 108 | # enable programmable completion features (you don't need to enable 109 | # this, if it's already enabled in /etc/bash.bashrc and /etc/profile 110 | # sources /etc/bash.bashrc). 111 | if ! shopt -oq posix; then 112 | if [ -f /usr/share/bash-completion/bash_completion ]; then 113 | . /usr/share/bash-completion/bash_completion 114 | elif [ -f /etc/bash_completion ]; then 115 | . /etc/bash_completion 116 | fi 117 | fi 118 | 119 | TTY="$(/usr/bin/tty | sed 's:.*/::')" 120 | if [[ ! ${DISPLAY} && ${TERM} == 'linux' && ${TTY} == 'tty1' ]]; then 121 | unset TTY 122 | udiskie & 123 | exec startx 124 | fi 125 | unset TTY -------------------------------------------------------------------------------- /tcfiles/.fvwm/config: -------------------------------------------------------------------------------- 1 | # This file is copied to a new user's FVWM_USERDIR by FvwmForm-Setup form. 2 | # This file contains the commands fvwm reads while starting. 3 | # 4 | 5 | 6 | TitleStyle Height 17 7 | TitleStyle -- Flat 8 | TitleStyle (VGradient 128 2 royalblue1 50 royalblue2 50 royalblue1) -- Flat 9 | TitleStyle Inactive (VGradient 128 2 grey85 50 grey70 50 grey50) -- Flat 10 | 11 | ButtonStyle 1 (MiniIcon -- Flat) 12 | ButtonStyle 1 (VGradient 128 2 royalblue1 50 royalblue2 50 royalblue1)-- Flat 13 | ButtonStyle 1 Inactive (VGradient 128 2 grey85 50 grey70 50 grey50) -- Flat 14 | AddButtonStyle 1 ActiveDown (5 01x01@0 99x01@0 99x99@1 01x99@1 01x01@0) 15 | 16 | ButtonStyle 2 (VGradient 128 2 royalblue1 50 royalblue2 50 royalblue1)-- Flat 17 | ButtonStyle 2 Inactive (VGradient 128 2 grey85 50 grey70 50 grey50) -- Flat 18 | AddButtonStyle 2 ActiveUp (13 20x20@0 80x80@0 85x80@0 25x20@0 30x20@0 90x80@0 50x50@0 20x80@0 80x20@0 85x20@0 25x80@0 30x80@0 90x20@0) -- Flat 19 | AddButtonStyle 2 ActiveDown (13 20x20@1 80x80@1 85x80@1 25x20@1 30x20@1 90x80@1 50x50@1 20x80@1 80x20@1 85x20@1 25x80@1 30x80@1 90x20@1) -- Flat 20 | AddButtonStyle 2 Inactive (13 20x20@0 80x80@0 85x80@0 25x20@0 30x20@0 90x80@0 50x50@0 20x80@0 80x20@0 85x20@0 25x80@0 30x80@0 90x20@0) -- Flat 21 | 22 | ButtonStyle 6 (VGradient 128 2 royalblue1 50 royalblue2 50 royalblue1)-- Flat 23 | ButtonStyle 6 Inactive (VGradient 128 2 grey85 50 grey70 50 grey50) -- Flat 24 | AddButtonStyle 6 ActiveUp (4 25x80@0 85x80@0 85x75@0 25x75@0 25x70@0 85x70@0) -- Flat 25 | AddButtonStyle 6 ActiveDown (4 25x80@1 85x80@1 85x75@1 25x75@1 25x70@1 85x70@1) -- Flat 26 | AddButtonStyle 6 Inactive (4 25x80@0 85x80@0 85x75@0 25x75@0 25x70@0 85x70@0) -- Flat 27 | 28 | ButtonStyle 4 (VGradient 128 2 royalblue1 50 royalblue2 50 royalblue1)-- Flat 29 | ButtonStyle 4 Inactive (VGradient 128 2 grey85 50 grey70 50 grey50) -- Flat 30 | AddButtonStyle 4 ActiveUp (7 20x20@0 90x20@0 90x25@0 20x25@0 20x80@0 90x80@0 90x20@0) -- Flat 31 | AddButtonStyle 4 ActiveDown (7 20x20@1 90x20@1 90x25@1 20x25@1 20x80@1 90x80@1 90x20@1) -- Flat 32 | AddButtonStyle 4 Inactive (7 20x20@0 90x20@0 90x25@0 20x25@0 20x80@0 90x80@0 90x20@0) -- Flat 33 | 34 | MenuStyle * Foreground gray90, Background royalblue1, SeparatorsShort 35 | MenuStyle * Hilight3DOff, HilightBack royalblue2, ActiveFore gray17 36 | MenuStyle * Animation 37 | MenuStyle * Font lucidasans-14, PopupOffset 1 120 38 | MenuStyle * MenuFace VGradient 128 1 royalblue2 50 royalblue1 50 royalblue1 39 | 40 | BorderStyle -- HiddenHandles NoInset 41 | HilightColor gray17 royalblue3 42 | DeskTopSize 1x1 43 | 44 | Style "*" BorderWidth 6, HandleWidth 4 45 | Style "*" Color grey17/grey75 46 | Style "*" NoPPosition 47 | Style "*" ClickToFocus 48 | Style "*" ActivePlacement 49 | 50 | 51 | # Styles for various common programs: 52 | Style *lock NoTitle, NoHandles, Sticky, WindowListSkip, ClickToFocus 53 | Style xbiff NoTitle, Sticky, WindowListSkip, ClickToFocus 54 | Style xcalc Icon xcalc.xpm, NoButton 2,ClickToFocus 55 | Style xmh Icon mail1.xpm, NoIconTitle,StickyIcon 56 | Style xmh NoButton 2 57 | Style xman Icon xman.xpm, ClickToFocus 58 | Style xmag Icon mag_glass.xpm, ClickToFocus 59 | Style xgraph Icon graphs.xpm, ClickToFocus 60 | Style xmosaic Color Green/Yellow, ClickToFocus 61 | 62 | 63 | # some simple default key bindings: 64 | Key Next A SCM Next [*] Focus 65 | Key Prior A SCM Prev [*] Focus 66 | 67 | # some simple default mouse bindings: 68 | # for the root window: 69 | Mouse 1 R A Nop 70 | Mouse 2 R A WindowList 71 | Mouse 3 R A Menu MenuFvwmRoot Nop 72 | 73 | # for the title bar buttons: 74 | Mouse 0 1 A Menu MenuFvwmWindowOps2 Close 75 | Mouse 0 2 A Delete 76 | Mouse 0 4 A Maximize 100 97 77 | 78 | 79 | # for other parts of the window/borders/icons: 80 | Mouse 1 F A FuncFvwmResizeOrRaise 81 | Mouse 1 T A FuncFvwmMoveOrRaise 82 | Mouse 1 S A FuncFvwmResizeOrRaise 83 | Mouse 1 I A FuncFvwmMoveOrIconify 84 | Mouse 2 I A Iconify 85 | Mouse 3 TSIF A RaiseLower 86 | Mouse 3 TSIF A RaiseLower 87 | Mouse 2 T A WindowShade 88 | 89 | Style "*" PositionPlacement Center 90 | 91 | ######################## Initialization Functions ############################ 92 | AddToFunc StartFunction 93 | + I Module FvwmBacker 94 | + I exec thinclient 95 | 96 | ######################## Menus ################### 97 | DestroyMenu MenuFvwmRoot 98 | AddToMenu MenuFvwmRoot " User Friendly Thin Client by Henk.Tech " Title 99 | + "&1. Thinclient GUI" Exec killall thinclient;killall yad;thinclient 100 | + "" Nop 101 | + "&R. Restart" Exec systemctl reboot 102 | + "&Q. Shutdown" Exec systemctl poweroff 103 | 104 | 105 | ######################## Sample Functions ########################## 106 | 107 | DestroyFunc FuncFvwmMoveOrRaise 108 | AddToFunc FuncFvwmMoveOrRaise 109 | + I Raise 110 | + M Move 111 | + D Lower 112 | 113 | DestroyFunc FuncFvwmMaximize 114 | AddToFunc FuncFvwmMaximize 115 | + M Maximize 0 100 116 | + H Maximize 0 100 117 | + C Maximize 0 80 118 | + D Maximize 100 100 119 | 120 | DestroyFunc FuncFvwmMoveOrIconify 121 | AddToFunc FuncFvwmMoveOrIconify 122 | + I Raise 123 | + M Move 124 | + D Iconify 125 | 126 | DestroyFunc FuncFvwmResizeOrRaise 127 | AddToFunc FuncFvwmResizeOrRaise 128 | + I Raise 129 | + M Resize 130 | + D Lower 131 | 132 | DestroyFunc FuncFvwmFocusPolicyChange 133 | AddToFunc FuncFvwmFocusPolicyChange 134 | + I Style * $0 135 | + I Recapture 136 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # User Friendly ThinClient 2 | 3 | ![UFTC Login Screen](login.png) 4 | 5 | UFTC was born out of my passion for IT, I have always wanted the ability to have thin clients in my home lab yet nothing online I could use for free was what I wanted. 6 | Many organizations I supported across IT departments always wanted the same thing, a lightweight locked down thinclient with a simple login screen. 7 | 8 | This project is geared towards that use case, repurpose machines into thin clients or save money by using your own consistent thinclient image with mini PC's. 9 | Super simple to setup, and easy for the end user. 10 | 11 | - Henk.Tech 12 | 13 | ## Features 14 | 15 | - Simple UI, your users only see a login screen and a shutdown button just like you'd want! 16 | - Admin options are present but hidden behind secret passwords. 17 | - Users can use the "ping" password to ping the remote server including a full trace route. You don't have to guess where the connection goes wrong just let them send you a picture. 18 | - Error messages that make sense and include your own helpdesk info, your users know exactly who to contact and what to say (Written by an experienced sysadmin who also does first line support). 19 | - Disk image that is not machine bound, you can capture it any time and redeploy your config on other machines. Hostnames change automatically based on the wired adapters mac address. 20 | - Optimized RDP defaults, rdp will just work out of the box with optimal quality. If you need to customize this further the option is available. 21 | - Based on the excellent xfreerdp project like most Linux based thinclients 22 | - Xanmod 6.12 Kernel for wide device compatibility 23 | - Docker as the build system making it easy to build your own custom image. 24 | - auto-maintenance command for system updates (Own risk especially on auto update mode, if a bad update releases and you enabled automatic updates you have to manually roll back your machines). 25 | - No remote access ports and minimal packages to reduce the attack surface even if the machine is outdated (The UI can be navigated easily over the phone, VNC is not neccesary. Instead if you need to assist users request remote access within the remote desktop.) 26 | 27 | ## Build your own image 28 | 29 | To succesfully build the image this project must be git cloned with a Linux system, if not the line endings and file permissions may not be correct. 30 | 31 | Image building requires docker and can be done inside of WSL2 if desired. 32 | 33 | ``` 34 | ./build.sh 35 | ``` 36 | 37 | grub as the bootloader unlocks a few things most importantly uefi support, you also get a seperate fat32 boot partition where you can place the config files when provisioning. 38 | Boot partition is a sizable 4GB to reduce the risk of running out of space for the kernels, with a total size of under 16GB this should fit on a 16GB USB stick if you wish to use a USB Stick for customization and capture. 39 | 40 | Important: The modern kernel could not be included in the build process, boot your generated image in a VM once so it can finalize the build. Release images have this step completed. 41 | 42 | ## Usage 43 | 44 | ### Installation from the ISO 45 | 46 | For easy installation an ISO based on clonezilla is available, this iso can be burned to a CD, flashed to USB with Rufus or be loaded from a Ventoy/Easy2boot USB stick. 47 | 48 | While a full copy of Clonezilla is bundled that allows you to capture your own images the recommended way of installing the official image is by using the first option in the boot menu. This will automatically use the recommended CloneZilla options. 49 | 50 | ### Network deployment using Clonezilla on the ISO 51 | 52 | Because a full copy of Clonezilla is bundled on the disk you can use this to facilitate PXE deployments over the network using a tempoary private bittorrent server. A full copy of Clonezilla is normally not present on clonezilla made disks, because of this we will need to apply a small workaround to be able to use this option. 53 | 54 | ##### Requirements: 55 | 56 | - The PXE server should be one of your target machines with the smallest amount of disk space and an identical type of drive. 57 | - This source machine should be correctly installed and configured as desired without disabling dynamic_hostname. 58 | - Every PXE booting machine will be wiped, ensure no machines that are not part of the deployment will PXE boot during this time. 59 | 60 | ##### Instructions: 61 | 62 | 0. Install and configure the source machine with UFTC. 63 | 1. Load CloneZilla from the installation disk using the Start Clonezilla option. 64 | 2. Enter Shell 65 | 3. Type the following command : sudo rm /home/partimag && sudo mkdir /home/partimag && sudo mkdir /home/partimag/live 66 | 4. Type exit to return back to the main screen and choose the Start_Clonezilla option. 67 | 5. Choose Lite-Server and then choose Start 68 | 6. Choose netboot or both 69 | 7. Choose autodetect unless you know this to be incorrect for your network environment. 70 | 8. Confirm the warning with Y 71 | 9. Choose Beginner 72 | 10. Choose massive-deployment 73 | 11. Choose from-device 74 | 12. Choose disk-2-mdisks 75 | 13. Choose the correct source disk of your thinclient (This needs to be identical on the targets) 76 | 14. Choose -fsck 77 | 15. Choose -k0 78 | 16. Choose -reboot (or -shutdown if you prefer, but rebooting is recommended as it is easier to see if the install was succesfull and in their default configuration the thinclients will automatically turn off) 79 | 17. Choose bittorrent (This is the fastest and most reliable option, it will help all your thinclients seed to other thin clients during the setup speeding up the process and allowing proper error checking) 80 | 81 | After these steps your source thinclient is now a deployment server for the other machines in your network. Ensure that the target thinclients boot from the network. After you are done you can finish yes to the question if all jobs finished to shut down the PXE server. 82 | 83 | ### Flashing to target media 84 | 85 | This image is a direct drive image without an installer, you can directly flash it to the target media. 86 | For flashing on Windows Rufus is compatible and directly compatible with the .vhd format. 87 | On Linux you can use ``qemu-img convert /location/of.vhd /dev/targetdevice`` 88 | 89 | ### Installing the VHD on the target device 90 | 91 | Because we don't have a mandatory installer you have every possibility available for deployment that you'd like. 92 | The recommended method of flashing VHD's directly is using RescueZilla on a Ventoy USB stick, this will allow you to deploy the provided VHD image as well as capture your own. 93 | 94 | ### WiFi 95 | 96 | WiFi can be enabled by placing a suitable wpa_supplicant.conf on the boot partition. 97 | Here is a template (Don't forget to change the country, I put china as the example due to the broadest range): 98 | 99 | ``` 100 | ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev 101 | update_config=1 102 | country=CN 103 | network={ 104 | ssid="SSID GOES HERE" 105 | psk="Password goes here" 106 | bgscan="simple:30:-65:15" 107 | } 108 | ``` 109 | 110 | ### Manual configuration 111 | 112 | If the thinclient is not preconfigured on the boot partition it will automatically boot its configuration screen. 113 | Fill in the fields you require for your deployment, if your server is not connected to a domain leave this blank. 114 | The parameters field are for additional xfreerdp parameters. 115 | The helpdesk field will be used in the middle of error message sentences, for example "Please contact HELPDESK if this is not resolved after 5 minutes." 116 | 117 | ### Automatic configuration 118 | 119 | Just like the WiFi the settings for the thinclient software can also be preconfigured by placing a tcconfig file in the boot partition. 120 | The template for this file is as follows (pay attention to the line endings, they need to be linux compatible): 121 | 122 | ``` 123 | server="server1|server2|server3" 124 | domain="" 125 | param="" 126 | adminpass="" 127 | helpdesk="the helpdesk" 128 | login_timeout="600" 129 | volume="100" 130 | microphone="100" 131 | brightness="50" 132 | screen_timeout="600" 133 | keylayout="" 134 | exit_type="Shutdown" 135 | config_url="" 136 | ``` 137 | 138 | ### Remote configuration (Own risk) 139 | 140 | If a config_url is defined the thinclient will automatically download its config file every time the login screen is shown. 141 | As a safety measure the config is only written on a succesful download and the previous working URL is backed up to a seperate file (If your new location is succesful the old URL is overwritten). 142 | Should the config become corrupt the backuped up config URL can be used to recover functionality, there are cases where the incorrect URL can become permanent such as migrating your production thinclients to the configuration of your development environment as this sets a working config_url . To help minimize this risk its recommended not to specify a config_url in configurations that are not meant for production (Do not leave it empty as this will disable remote setup, remove the line entirely). 143 | 144 | Because of this and the inherent dangers of remote configuration ensure the config file webserver is well secured and the configuration files are well tested before mass deployment. 145 | Even though this functionality was exploit tested it is a possible point of failure if a hacker finds a novel bash exploit or overwrites the RDP server with a malicious one. 146 | 147 | tc_hostname in the URL is automatically replaced with the hostname of the thinclient to enable per client configuration. 148 | 149 | You implement this functionality strictly on your own risk. If left blank this functionality is fully disabled. 150 | 151 | ### RDP Files 152 | 153 | UFTC supports existing RDP files if downloaded from a central location, to do this simply put the RDP URL as the server name. 154 | 155 | ### Citrix Mode 156 | 157 | There is a basic Citrix mode on board that can be activated by putting citrix as the server name. The URL of the Citrix Storefront can be preconfigured by putting it in the parameters, each time this setting changes to a new URL the Citrix profile is wiped. 158 | 159 | ### Multiple Servers 160 | 161 | Starting at version 1.10 UFTC supports specifying multiple RDP servers (and optionally also citrix if neither uses additional parameters). 162 | To set this up use the regular server field and seperate the servers with | 163 | 164 | ### Root Account 165 | 166 | In the release the root account is disabled with two exceptions that do not require a password: 167 | auto-maintenance (Own risk), this tool can be used to manually update the system or can be used to enable automatic updates. 168 | set-hostname , this tool changes the hostname of the thinclient. If the dynamic_hostname file is present in the user account hostnames will be set according to the macaddress of the wired adapter. 169 | (Likewise the thinclient account has no default password) 170 | 171 | When self building you can pass a -p parameter to enable the root password. 172 | 173 | ### Password commands 174 | 175 | config : Re-open the config dialogue 176 | 177 | terminal: Open the terminal 178 | 179 | ping (without your admin password in front): Ping the RDP server with a full traceroute, users can change this to any required destination if needed. 180 | 181 | ip (without your admin password in front): Shows the devices network information 182 | 183 | ## Terms of Use 184 | 185 | - I currently don't know which formal license is the best fit, when using this software please respect the following: 186 | - I am not responsible for what happens with your deployment, its designed to be as robust as I could make it. But should unforseen consequences, bugs or updates happen I am not liable as you accept you use and deploy this on your own risk especially if you enabled automatic updates and your company is now offline due to a bad/incompatible debian update. 187 | - The software is free for both personal and business use and may not be resold. Preinstallation on physical hardware is allowed as long as it is made clear that it runs software based on this free repository. 188 | - You have the freedom to make modifications to this software as long as you do not sell them (Henk.Tech does have the right to sell private modified builds). If distributed publically for free the source code must be provided (If it was modified manually list your changes and how to apply them, don't just post the image). For private internal modifications within your deployment this requirement does not apply ( MSP's using it for a customer they manage counts as internal), but if anyone asks what software is running point them to the public repo. 189 | - Please share your success stories in https://github.com/henk717/uftc/discussions/categories/show-and-tell , while I give out the software for free my reward is the satisfaction of knowing that my work made a positive difference in your organization. Of course for security reasons it is fine if you leave the company name out, deployment size will do. 190 | - If I pick a formal license that embodies these terms your repo/deployment is retroactively licensed under the license of this (parent) repo on the condition that the new license is an open source license similar to the above (If not the above freedoms apply for any version prior to the license change). 191 | -------------------------------------------------------------------------------- /tcfiles/thinclient: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Thin Client RDP GUI by Henk717 3 | 4 | shutdown_thinclient(){ 5 | echo "Shutdown requested.. Invoking $exit_type" 6 | if [[ -f "desktop" || $exit_type == "Exit" || $exit_type == "exit" ]]; then 7 | echo Desktop file exists or shutdown defined as exit only, not shutting the device down. 8 | exit 9 | elif [[ $exit_type == "Sleep" || $exit_type == "sleep" ]]; then 10 | systemctl suspend 11 | $0 "$@" & 12 | exit 13 | elif [[ $exit_type == "Restart" || $exit_type == "restart" || $exit_type == "reboot" ]]; then 14 | systemctl reboot 15 | else 16 | systemctl poweroff 17 | fi 18 | } 19 | 20 | # Universal variables 21 | PROTOCOL_DETECT="://" 22 | VERSION=`cat /tcversion` 23 | 24 | # Run second phase of the build if present 25 | if [[ -f /usr/bin/firstboot ]]; then 26 | xterm -bg black -fg white -hold -e sudo /usr/bin/firstboot 27 | fi 28 | 29 | # Rename thinclient 30 | hostname=$(< /etc/hostname) 31 | if [[ $hostname == "thinclient" || -f ~/dynamic_hostname ]]; then 32 | if [ -f /sys/class/net/eth0/address ];then 33 | read newhost ~/config_url 99 | # Quick reload in case things changed, spares the user the reboot 100 | load_config ~/tcconfig 101 | else 102 | # Something didn't go right and we did not use the new URL successfully 103 | load_config ~/config_url 104 | curl -fLo ~/tcconfig $config_url 105 | load_config ~/tcconfig 106 | if [ "$config_url_invalid" == "$config_url" ]; then 107 | config_url="Config URL $config_url_invalid was invalid and a backup was used." # This was a dilemma since it can pick either one from the file depending on external servers, lets just warn instead. In an automatic scenario this is not saved, even if the admin ends up saving this the backup URL should revert it again. 108 | fi 109 | fi 110 | fi 111 | fi 112 | 113 | load_config ~/tcconfig_override 114 | 115 | # Some values used to be hardcoded but are important, we define the old hardcoded variables here in case they are missing from production configs 116 | if [[ -z "$exit_type" ]]; then 117 | exit_type="Shutdown" 118 | fi 119 | 120 | if [[ -z "$login_timeout" ]]; then 121 | login_timeout="600" 122 | fi 123 | 124 | # Apply system configuration 125 | pamixer -u --allow-boost --set-volume $volume 126 | pamixer -u --default-source --allow-boost --set-volume $microphone 127 | light -S $brightness 128 | setxkbmap $keylayout 129 | xset dpms 0 $screen_timeout 0 130 | 131 | 132 | # Handle multiple servers 133 | server_list=$server # We duplicate this so that a full list remains available, that way we don't break the existing code and configs 134 | IFS='|' read -r -a servers <<< "$server_list" 135 | if [ "${#servers[@]}" -ge 2 ]; then 136 | yad_list="" 137 | for s in "${servers[@]}"; do 138 | yad_list+="$s\n" 139 | done 140 | 141 | yad_list=$(echo -e "$yad_list" | sed '/^$/d') 142 | selected=$(echo -e "$yad_list" | yad --list --center --window-icon=changes-preven --button=$exit_type:1 --button=Select:0 --timeout=$login_timeout --title="Network Login | $VERSION" --column="Server Name" --width 300 --height 300) 143 | OUTPUT_RESULTS=$? 144 | if ((OUTPUT_RESULTS != 0)); then 145 | shutdown_thinclient 146 | fi 147 | server=$(echo "$selected" | sed 's/[|[:space:]]*$//') 148 | else 149 | server="${servers[0]}" 150 | fi 151 | 152 | # Citrix 153 | if [ "$server" = "citrix" ];then 154 | yad --center --title=Loading --button="Please wait" --timeout=1 155 | if [ "$?" == "70" ];then 156 | if [ -f /opt/Citrix/ICAClient/selfservice ];then 157 | if [[ -n "$param" && "$(cat ~/.ICAClient/param)" != "$param" ]]; then 158 | echo Store URL incorrect, trying to reconfigure 159 | rm -rf ~/.ICAClient 160 | /opt/Citrix/ICAClient/util/storebrowse -a "$param" && echo $param > ~/.ICAClient/param 161 | if [ ! -f ~/.ICAClient/param ];then 162 | echo "Something went wrong, we have no configuration. Restarting..." 163 | $0 "$@" & 164 | exit 165 | fi 166 | fi 167 | /opt/Citrix/ICAClient/util/storebrowse -c SharedUserMode=True 168 | /opt/Citrix/ICAClient/selfservice 169 | shutdown_thinclient 170 | fi 171 | fi 172 | fi 173 | 174 | # Moonlight 175 | if [ "$server" = "moonlight" ];then 176 | yad --center --title=Loading --button="Please wait" --timeout=1 177 | if [ "$?" == "70" ];then 178 | if [ -f /usr/bin/moonlight ];then 179 | /usr/bin/moonlight $param 180 | shutdown_thinclient 181 | fi 182 | fi 183 | fi 184 | 185 | # RDP File support, this will break the server string for the other modes so we do it last 186 | if [[ "$server" == *"$PROTOCOL_DETECT"* ]]; then 187 | curl -m 5 -fLs "$server" | enconv -L none -x UTF-8 > ~/destination.tmp && [ -s ~/destination.tmp ] && mv ~/destination.tmp ~/destination.rdp 188 | rdp_destination="~/destination.rdp" 189 | else 190 | rdp_destination="/v:"$server 191 | fi 192 | 193 | # Login Dialogue 194 | OUTPUT=$(yad --form --center --window-icon=changes-prevent --image=dialog-password --button=$exit_type:1 --button=Login:0 --timeout=$login_timeout --title="Network Login | $VERSION" \ 195 | --text="Enter your network credentials below" \ 196 | --separator=$'\x1F' \ 197 | --field="Username" \ 198 | --field="Password":h) 199 | OUTPUT_RESULTS=$? 200 | if ((OUTPUT_RESULTS != 0)); then 201 | shutdown_thinclient 202 | fi 203 | Blank="" 204 | username=$(printf '%q' "$(awk -F'\x1F' '{print $1}' <<<"$OUTPUT")") 205 | password=$(printf '%q' "$(awk -F'\x1F' '{print $2}' <<<"$OUTPUT")") 206 | 207 | fi 208 | 209 | # Password commands 210 | if [ "$password" = $adminpass"kill" ];then 211 | exit 0 212 | fi 213 | 214 | if [ "$password" = $adminpass"terminal" ];then 215 | xterm -bg black -fg white 216 | $0 217 | exit 0 218 | fi 219 | 220 | if [ "$password" = "ping" ];then 221 | if [[ "$server" == *"$PROTOCOL_DETECT"* ]]; then 222 | mtr $(cat ~/destination.rdp | grep "full address:s:" | sed 's/full address:s://g' | sed 's/:.*//g' | tr -d '\r' | tr -d '\n') 223 | else 224 | mtr $( echo "$server" | sed 's/:.*//g' ) 225 | fi 226 | if (($? != 0)); then 227 | yad --title="Error" --text "Something went wrong, will open the ping tool with localhost as the destination instead" --button="Ok" 228 | mtr 229 | fi 230 | $0 231 | exit 0 232 | fi 233 | 234 | if [ "$password" = "ip" ];then 235 | yad --title="Network Information" --center --text="Hostname: $hostname\nLocal IP: $(hostname --all-ip-addresses || hostname -I)\nPublic IP: $(curl -4 ifconfig.me)\n$(resolvectl status | grep "DNS Servers" | sed -e 's/^[ \t]*//')\nGateway: $(ip route | awk '/default/ { print $3 }')" --button="Ok" 236 | $0 237 | exit 0 238 | fi 239 | 240 | # This section handles the default configuration and configuration dialogue 241 | if [ ! -f ~/tcconfig ];then 242 | yad --info --ellipsize=middle --center --title="User Friendly Thin Client $VERSION by Henk.Tech" --text="You have not yet configured your thin client and a configuration screen will now open.\nTo access this configuration screen at a later time use config as the login password.\nYou can also use the login password terminal to access the terminal.\n\nNOTE: If you enter an admin password you will need to type this in front of the commands,\nthis version stores that password in plain text so do not submit your usual administrator passwords.\n\nCommands:\nconfig: Configuration screen\nterminal: Interactive Terminal\nping (Without password): My Traceroute\nip (Without password): Show IP information\nkill: Shut down this tool" 243 | password=config 244 | helpdesk="the helpdesk" 245 | volume="100" 246 | microphone="100" 247 | brightness="50" 248 | login_timeout="600" 249 | screen_timeout="600" 250 | exit_type="Shutdown" 251 | fi 252 | 253 | if [ "$password" = $adminpass"config" ];then 254 | server=$server_list 255 | OUTPUT=$(yad --form --center --window-icon=changes-allow --image=preferences-system --title="Configuration Utility | $VERSION" \ 256 | --text="This will replace the configuration file with the provided settings" \ 257 | --separator=$'\x1F' \ 258 | --field="RDP Configuration":lbl --align=center "" \ 259 | --field="Server" "$server" \ 260 | --field="Domain" "$domain" \ 261 | --field="Parameters" "$param" \ 262 | --field="Dialog Configuration":lbl --align=center "" \ 263 | --field="Admin Password":h "$adminpass" \ 264 | --field="Helpdesk Contact" "$helpdesk" \ 265 | --field="Login Timeout in Seconds" "$login_timeout" \ 266 | --field="OS Configuration":lbl --align=center "" \ 267 | --field="Speaker Volume" "$volume" \ 268 | --field="Microphone Volume" "$microphone" \ 269 | --field="Screen Brightness" "$brightness" \ 270 | --field="Screen Standby Timeout in Seconds" "$screen_timeout" \ 271 | --field="Keyboard Layout" "$keylayout" \ 272 | --field="Exit Type (Shutdown/Sleep/Restart)" "$exit_type" \ 273 | --field="Remote Configuration":lbl --align=center "" \ 274 | --field="Remote Config Location" "$config_url" ) 275 | OUTPUT_RESULTS=$? 276 | if ((OUTPUT_RESULTS == 0)); then 277 | echo server=\"$(awk -F'\x1F' '{print $2}' <<<$OUTPUT)\" > ~/tcconfig 278 | echo domain=\"$(awk -F'\x1F' '{print $3}' <<<$OUTPUT)\" >> ~/tcconfig 279 | echo param=\"$(awk -F'\x1F' '{print $4}' <<<$OUTPUT)\" >> ~/tcconfig 280 | echo adminpass=\"$(awk -F'\x1F' '{print $6}' <<<$OUTPUT)\" >> ~/tcconfig 281 | echo helpdesk=\"$(awk -F'\x1F' '{print $7}' <<<$OUTPUT)\" >> ~/tcconfig 282 | echo login_timeout=\"$(awk -F'\x1F' '{print $8}' <<<$OUTPUT)\" >> ~/tcconfig 283 | echo volume=\"$(awk -F'\x1F' '{print $10}' <<<$OUTPUT)\" >> ~/tcconfig 284 | echo microphone=\"$(awk -F'\x1F' '{print $11}' <<<$OUTPUT)\" >> ~/tcconfig 285 | echo brightness=\"$(awk -F'\x1F' '{print $12}' <<<$OUTPUT)\" >> ~/tcconfig 286 | echo screen_timeout=\"$(awk -F'\x1F' '{print $13}' <<<$OUTPUT)\" >> ~/tcconfig 287 | echo keylayout=\"$(awk -F'\x1F' '{print $14}' <<<$OUTPUT)\" >> ~/tcconfig 288 | echo exit_type=\"$(awk -F'\x1F' '{print $15}' <<<$OUTPUT)\" >> ~/tcconfig 289 | echo config_url=\"$(awk -F'\x1F' '{print $17}' <<<$OUTPUT)\" >> ~/tcconfig 290 | else 291 | yad --error --title="Error" --center --ellipsize=middle --text="Configuration cancelled or failed" 292 | fi 293 | $0 "$@" & 294 | exit 0 295 | fi 296 | 297 | # We passed all the UI's and now try to launch RDP 298 | echo "Start of session" > session.log 299 | if [ "$server" != "citrix" && "$server" != "moonlight" ];then 300 | # Because we are about to bash -c we sanetize the inputs which bash -c will convert back properly 301 | # Conditional because xfreerdp breaks if we do this with an empty variable 302 | if [[ -n "$rdp_destination" ]]; then 303 | rdp_destination=$(printf '%q' "$rdp_destination") 304 | fi 305 | 306 | if [[ -n "$domain" ]]; then 307 | domain=$(printf '%q' "$domain") 308 | fi 309 | 310 | if [[ -n "$param" ]]; then 311 | param=$(printf '%q' "$param") 312 | fi 313 | 314 | if [[ -n "$hostname" ]]; then 315 | hostname=$(printf '%q' "$hostname") 316 | fi 317 | # Username and Password are already sanetized during input, we don't do it twice. 318 | bash -c "WLOG_APPENDER=file WLOG_FILEAPPENDER_OUTPUT_FILE_NAME=session.log WLOG_FILEAPPENDER_OUTPUT_FILE_PATH=. xfreerdp3 $rdp_destination /d:$domain /u:$username /p:$password /multimon +multitouch /cert:ignore /f /network:auto /sound:rate:44100,channel:2 /dynamic-resolution /usb:auto +drives +auto-reconnect +multitransport /client-hostname:$hostname $param" # Somehow this only works when wrapped in bash otherwise it cant understand the positional argument 319 | fi 320 | 321 | # RDP is no longer running, we diagnose why 322 | if ! grep -F "BY_USER" session.log;then 323 | grep -F "ERRCONNECT_LOGON_FAILURE" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="The Login attempt has failed.\nPlease try again or contact $helpdesk if the issue persists." 324 | grep -F "ERRCONNECT_WRONG_PASSWORD" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="You have entered incorrect login information.\nPlease try again." 325 | grep -F "ERRCONNECT_CONNECT_FAILED" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="We could not establish a connection with the server $server, restart your device and contact $helpdesk if the issue persists after 5 minutes." 326 | grep -F "ERRCONNECT_DNS_NAME_NOT_FOUND" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="We could not find a server called $server, restart your device and contact $helpdesk if the issue persists after 5 minutes." 327 | grep -F "ERRCONNECT_AUTHENTICATION_FAILED" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="The server was unable to verify your credentials.\nIf you still experience this error after 10 minutes please contact $helpdesk." 328 | grep -F "ERRCONNECT_CONNECT_TRANSPORT_FAILED" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="We have encountered a server issue.\nThis can have the following causes.\n\nThe server is unable to take your connection at this time.\nYour account does not have permissions to access the server but was not locked out.\nThe server $server is not the correct server.\nThere was another issue on this machine or network that prevented the connection from succeeding.\n\nPlease reboot your device, if you are still unable to login after this contact $helpdesk" 329 | grep -F "ERRCONNECT_PASSWORD_MUST_CHANGE" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="Your password has expired and needs to be changed, please contact $helpdesk for assistance." 330 | grep -F "ERRCONNECT_ACCOUNT_DISABLED" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="You can not login with this account.\nContact $helpdesk for more information." 331 | grep -F "ERRCONNECT_ACCOUNT_LOCKED_OUT" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="Your account has been locked out because of to many failed attempts, please contact $helpdesk and ask for an account unlock (Accounts may unlock automatically after enough time has passed)." 332 | grep -F "ERRCONNECT_ACCOUNT_RESTRICTION" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="You are unable to login because of an account restriction.\nFor example you may not be allowed to login at this hour or day.\nPlease contact $helpdesk for more information." 333 | grep -F "ERRINFO_SERVER" session.log && yad --error --title="Error" --timeout=900 --center --ellipsize=middle --text="Server connection failed.\nPlease try again, restart your device and otherwise contact $helpdesk for assistance." 334 | grep -F "ERRINFO_LICENSE" session.log && yad --error --title="Error" --timeout=900 --center --ellipsize=middle --text="To many accounts are currently logged in.\n Please inform $helpdesk ." 335 | grep -F "ERRINFO_OUT_OF_MEMORY" session.log && yad --error --title="Error" --timeout=900 --center --ellipsize=middle --text="The server $server ran out of memory.\n Please inform $helpdesk ." 336 | grep -F "ERRINFO_CB_DESTINATION_NOT_FOUND" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="The server $server could not be reached, restart your device and contact $helpdesk if the issue persists." 337 | grep -F "ERRINFO_CB_DESTINATION_POOL_NOT_FREE" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="The server $server did not have a session available for you.\nTry again in 10 minutes and contact $helpdesk if the issue persists." 338 | grep -F "ERRINFO_CB_CONNECTION_ERROR_INVALID_SETTINGS" session.log && yad --error --timeout=900 --title="Error" --center --ellipsize=middle --text="Invalid settings.\n Please contact $helpdesk and inform them the settings used are \n$server\n$param." 339 | fi 340 | 341 | # Restart the application, this will fetch updated configs and return us to the login screen 342 | password="" 343 | $0 "$@" & 344 | --------------------------------------------------------------------------------