diff --git a/vsftp/build/Dockerfile b/vsftp/build/Dockerfile new file mode 100644 index 00000000..acde72ec --- /dev/null +++ b/vsftp/build/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine:3.7 + +RUN apk add --no-cache vsftpd + +CMD /usr/sbin/vsftpd /etc/vsftpd/vsftpd.conf \ No newline at end of file diff --git a/vsftp/hooks/init b/vsftp/hooks/init new file mode 100755 index 00000000..8da3bb9f --- /dev/null +++ b/vsftp/hooks/init @@ -0,0 +1,300 @@ +#!/bin/bash + +## Init is run on host +## For now it is run every time the script is launched, but +## it should be launched only once after build. + +## Accessible variables are: +## - SERVICE_NAME Name of current service +## - DOCKER_BASE_IMAGE Base image from which this service might be built if any +## - SERVICE_DATASTORE Location on host of the DATASTORE of this service +## - SERVICE_CONFIGSTORE Location on host of the CONFIGSTORE of this service + + +. lib/common || exit 1 + + +CONFIGFILE="$SERVICE_CONFIGSTORE/etc/vsftpd/vsftpd.conf" + + +VSFTP_OPTIONS=( + + ## + ## Feature not yet supported (and removed for simplicity's sake, and + ## because implication were not though through yet), or in dire need + ## of YAML simplification in the options. + ## + + ## No support for anon user for now (for simplification purpose) + #"allow_anon_ssl:bool" "anon_mkdir_write_enable:bool" "anon_other_write_enable:bool" + #"anon_upload_enable:bool" "anon_world_readable_only:bool" "anonymous_enable:bool" + #"force_anon_data_ssl:bool" "force_anon_logins_ssl:bool" + #"no_anon_password:bool" + #"anon_root:string" "chown_username:string" "ftp_username:string" + #"anon_max_rate:numeric" "anon_umask:numeric" + + ## No support for chroot list + #"chroot_list_enable:bool" + + #"connect_from_port_20:bool" ## port not propagated yet to host + + ## No support for that (requires more YAML love to list emails) + #"secure_email_list_enable:bool" + #"user_config_dir:string" + + ## To simplify usage + #"userlist_deny:bool" + #"userlist_enable:bool" + + + ## + ## Not sure if we need that + ## + + #"deny_email_enable:bool" + #"guest_enable:bool" "setproctitle_enable:bool" + #"session_support:bool" "tcp_wrappers:bool" + #"banned_email_file:string" "guest_username:string" + #"pam_service_name:string" "ftp_data_port:numeric" + + + ## + ## Set by design + ## + + ## Alway background by design (as a dockerized process) + # "background:bool" "listen:bool" + + ## Using local user functionality + #"chroot_local_user:bool" + #"local_enable:bool" + #"passwd_chroot_enable:bool" + #"virtual_use_local_privs:bool" + #"chroot_list_file:string" + #"email_password_file:string" + #"user_sub_token:string" + #"userlist_file:string" + + ## Logging options are simplified + #"dual_log_enable:bool" "log_ftp_protocol:bool" "no_log_lock:bool" + #"syslog_enable:bool" + #"xferlog_enable:bool" "xferlog_std_format:bool" + #"vsftpd_log_file:string" "xferlog_file:string" + + ## Managed by ``ssl`` options + #"implicit_ssl:bool" + #"ssl_enable:bool" "ssl_request_cert:bool" "ssl_sslv2:bool" "ssl_sslv3:bool" + #"ssl_tlsv1:bool" + #"ca_certs_file:string" + #"dsa_cert_file:string" + #"dsa_private_key_file:string" + #"rsa_cert_file:string" + #"rsa_private_key_file:string" + #"ssl_ciphers:string" + + ## Obscure option not really needed + #"one_process_model:bool" + + ## Managed by ``pasv`` options + #"pasv_addr_resolve:bool" "pasv_enable:bool" + #"pasv_promiscuous:bool" + #"pasv_max_port:numeric" "pasv_min_port:numeric" + #"pasv_address:string" + + ## We use root user in docker to launch vsftp + #"run_as_launching_user:bool" + + ## We don't need to change this in docker + #"listen_port:numeric" + #"listen_address:string" "listen_address6:string" + + ## Hum, should not be of charm users concern + #"nopriv_user:string" + #"secure_chroot_dir:string" + + + ## + ## Permitted + ## + + ## commands + "chmod_enable:bool" "dirlist_enable:bool" "download_enable:bool" + "port_enable:bool" "ls_recurse_enable:bool" "write_enable:bool" + + ## others + "dirmessage_enable:bool" "ascii_download_enable:bool" + "ascii_upload_enable:bool" "async_abor_enable:bool" + "check_shell:bool" "chown_uploads:bool" "debug_ssl:bool" + "delete_failed_uploads:bool" "force_dot_files:bool" + "hide_ids:bool" "listen_ipv6:bool" "lock_upload_files:bool" + "mdtm_write:bool" "port_promiscuous:bool" "require_cert:bool" + "require_ssl_reuse:bool" "strict_ssl_read_eof:bool" + "strict_ssl_write_shutdown:bool" "text_userdb_names:bool" + "tilde_user_enable:bool" "use_localtime:bool" "use_sendfile:bool" + "validate_cert:bool" + + ## timeouts + "accept_timeout:numeric" "connect_timeout:numeric" "data_connection_timeout:numeric" + "idle_session_timeout:numeric" + + ## delays + "delay_failed_login:numeric" "delay_successful_login:numeric" + + ## modes + "chown_upload_mode:numeric" "file_open_mode:numeric" + + "local_max_rate:numeric" "local_umask:numeric" + + "max_clients:numeric" "max_login_fails:numeric" "max_per_ip:numeric" + "trans_chunk_size:numeric" + + "banner_file:string" + + "cmds_allowed:string" "cmds_denied:string" + + "deny_file:string" "ftpd_banner:string" "hide_file:string" + "local_root:string" "message_file:string" + + "force_local_data_ssl:bool" "force_local_logins_ssl:bool" + + "allow_writeable_chroot:bool" +) + +VSFTP_OPTIONS_CONCAT=" ${VSFTP_OPTIONS[*]} " + +get_ips_from_hostname() { + local hostname="$1" + getent hosts "$hostname" | awk '{ print $1 }' +} + + +get_ip_from_hostname() { + local hostname="$1" + get_ips_from_hostname "$hostname" | head -n 1 +} + + +mkdir -p "$(dirname "$CONFIGFILE")" + +service_def=$(get_compose_service_def "$SERVICE_NAME") || return 1 +domain= +users= +pasv= +ssl= +while read-0 key val; do + key_option=${key//-/_} + case "$VSFTP_OPTIONS_CONCAT" in + *" ${key_option}:bool "*) + case "${val,,}" in + true|ok|yes|y) + val=yes + ;; + false|ko|nok|no|n) + val=no + ;; + *) + die "Invalid value for ${WHITE}$key$NORMAL, please use a boolean value." + ;; + esac + ;; + *" ${key_option}:numeric "*) + if ! is_int "$val"; then + die "Invalid value for ${WHITE}$key$NORMAL, please use numeric value." + fi + ;; + *" ${key_option}:string "*) + : + ;; + *) + case "${key//_/-}" in + users) users="$val";; + pasv) pasv="$val";; + ssl) ssl="$val";; + domain) domain="$val";; + *) die "Unknown option ${WHITE}$key$NORMAL.";; + esac + continue + ;; + esac + case "$key_option" in + allow_writeable_chroot) + allow_writeable_chroot=1 + ;; + esac + printf "%s=%s\n" "$key_option" "$val" +done < <(printf "%s" "$service_def" | shyaml key-values-0 options) > "$CONFIGFILE" + +if [ -z "$domain" ]; then + if [[ "$SERVICE_NAME" =~ ^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$ ]]; then + domain="$SERVICE_NAME" + fi +fi + +make_pasv_options "$pasv" "$domain" >> "$CONFIGFILE" || exit 1 +make_ssl_options "$ssl" "$domain" >> "$CONFIGFILE" || exit 1 + + +## Logs + +cat <> "$CONFIGFILE" +## +## Logs +## + +syslog_enable=no +dual_log_enable=no +vsftpd_log_file=/var/log/vsftp/vsftp.log +xferlog_enable=yes +xferlog_std_format=no +EOF + +## Dockerisation means those + +cat <> "$CONFIGFILE" + +## +## Dockerisation +## + +background=no +## we don't use that feature +passwd_chroot_enable=no + +## +## Use local system passwd account +## + +local_enable=yes +chroot_local_user=yes + +## Seem to be required to avoid 500 OOOps child died +seccomp_sandbox=no + +EOF + + + +## +## Users +## + +## Note: this creates a file that will be interpreted in the +## entrypoint of the docker image. It also creates group with same gid +## on host to be able to easily share files. + +build_script="$(make_build_script "$users" "$allow_writeable_chroot")" || exit 1 +echo "build-script:" >&2 +echo "$build_script" | prefix " $GRAY|$NORMAL " >&2 +docker_update "$SERVICE_NAME" "$build_script" -v "$SERVICE_DATASTORE/home":"/home" || exit 1 + +config_hash=$( + { + printf "%s\0" "$build_script" "$(cat "$CONFIGFILE")" + } | md5_compat) || exit 1 + +init-config-add " +$MASTER_BASE_SERVICE_NAME: + labels: + - compose.config_hash=$config_hash +" + diff --git a/vsftp/lib/common b/vsftp/lib/common new file mode 100644 index 00000000..7791917d --- /dev/null +++ b/vsftp/lib/common @@ -0,0 +1,292 @@ +# -*- mode: shell-script -*- + + +ssl_get_plugin_fun() { + local cfg="$1" + + type="$(echo "$cfg" | shyaml -y get-type 2>/dev/null)" || return 1 + case "$type" in + bool) + err "Option ${WHITE}ssl${NORMAL} value '$cfg' is not supported." + return 1 + ;; + str) + keys=("$cfg") + ;; + struct) + keys=( $(echo "$cfg" | shyaml keys 2>/dev/null) ) + ;; + *) + err "Invalid ${WHITE}ssl${NORMAL} value type '$type': please provide a string or a struct." + return 1 + ;; + esac + + for key in "${keys[@]}"; do + target_relation="cert-provider" + fun="ssl_plugin_${target_relation}" + while read-0 relation_name target_service relation_config tech_dep; do + [ "$relation_name" == "${target_relation}" ] || continue + [ "$target_service" == "$key" ] || continue + verb "Corresponding plugin ${DARKGREEN}found${NORMAL}" \ + "in ${DARKBLUE}$relation_name${NORMAL}/${DARKYELLOW}$key${NORMAL}" + ssl_cfg=$(printf "%s" "$cfg" | shyaml get-value "$key" 2>/dev/null) || true + merged_config=$(merge_yaml_str "$relation_config" "$ssl_cfg") || return 1 + printf "%s\0" "$fun" "$key" "$merged_config" + return 0 + done < <(get_service_relations "$SERVICE_NAME") || return 1 + case "$key" in + cert|ca-cert|key) + : + ;; + *) + err "Invalid key '$key' in ${WHITE}ssl${NORMAL}:" \ + "no corresponding services declared in ${DARKBLUE}${target_relation}$NORMAL" + return 1 + ;; + esac + done +} + + +ssl_fallback_vars() { + local cfg="$1" DOMAIN="$2" cert key ca_cert + + base_dir=/etc/ssl + print "%s\0" \ + "$base_dir"/certs/"${DOMAIN}".pem \ + "$base_dir"/private/"${DOMAIN}".key \ + "$base_dir"/certs/"${DOMAIN}"-ca.pem + + print "%s\0" "$base_dir"/cert.pem "$base_dir"/privkey.pem "$base_dir"/chain.pem +} + + +ssl_fallback_prepare() { + local cfg="$1" service="$2" l_cert l_key l_ca_cert + + shift 2 + read-0 l_cert l_key l_ca_cert < <(ssl_fallback_vars "$cfg" "$@") + + dst="$CONFIGSTORE/$BASE_SERVICE_NAME" + volumes="" + for label in cert key ca-cert; do + if content=$(echo "$cfg" | shyaml get-value "$label" 2>/dev/null); then + location="$(eval echo "\$l_${label//-/_}")" + echo "$content" | file_put "$dst$location" + config_hash=$(printf "%s\0" "$config_hash" "$label" "$content" | md5_compat) + volumes="$volumes + - $dst$location:$location:ro" + fi + done + + if [ "$volumes" ]; then + init-config-add "\ +$MASTER_TARGET_SERVICE_NAME: + volumes: +$volumes +" + fi + +} + +ssl_plugin_cert-provider_vars() { + local cfg="$1" DOMAIN="$2" + + base_dir="/etc/letsencrypt/live/$DOMAIN" + + print "%s\0" "$base_dir"/cert.pem "$base_dir"/privkey.pem "$base_dir"/chain.pem +} + +ssl_plugin_cert-provider_prepare() { + local cfg="$1" service="$2" options + shift 2 + + options=$(yaml_key_val_str "options" "$cfg") || return 1 + service_config=$(yaml_key_val_str "$service" "$options") + compose --debug --add-compose-content "$service_config" run --rm --service-ports "$service" \ + crt create "$@" || { + err "Failed to launch letsencrypt for certificate creation." + return 1 + } + init-config-add "\ +$MASTER_TARGET_SERVICE_NAME: + volumes: + - $DATASTORE/$service/etc/letsencrypt:/etc/letsencrypt:ro +" || return 1 +} + + +make_pasv_options() { + local pasv="$1" + if [[ "${pasv,,}" =~ ^(false|no|n|0|disable|disabled)$ ]]; then + echo "pasv_enable=no" + return 0 + fi + + ports="$(printf "%s" "$pasv" | shyaml get-value -q "ports")" + if [ "$ports" ]; then + if [[ "$ports" =~ ^[0-9]+-[0-9]+$ ]]; then + port_min=${ports##*-} + port_max=${ports%%-*} + if [ "$port_min" -gt "$port_max" ]; then + die "Invalid value for ${WHITE}ports${NORMAL}: first port in range has to be lesser than second." + fi + else + die "Invalid value for ${WHITE}ports${NORMAL}: please specify a port range (ie: '123-345')." + fi + fi + + port_min=${port_min:-21100} + port_max=${port_max:-21110} + + host_address="$(printf "%s" "$pasv" | shyaml get-value -q "address")" + if [ -z "$host_address" ]; then + if [[ "$SERVICE_NAME" =~ ^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$ ]]; then + host_address="$SERVICE_NAME" + fi + fi + + if [ -z "$host_address" ]; then + echo "## Could not find appropriate host return address" + echo "pasv_enable=no" + return 0 + fi + + if ! str_is_ipv4 "$host_address"; then + host_address="$(get_ip_from_hostname "$host_address")" || { + err "Can't resolve given hostname '$host_address'" + return 1 + } + else + err "You should NOT provide a direct IP for ${WHITE}pasv.address${NORMAL}," \ + "try a resolvable address." + return 1 + fi + cat <&2 + if [ "$first" ]; then + first_group="$group" + first= + else + remaining_groups+=("$group") + fi + groups+=("$group") + done < <(echo "$user_def" | shyaml get-values-0 groups 2>/dev/null) + password=$(echo "$user_def" | shyaml get-value password 2>/dev/null) || + password=$(gen_password 14) + uid=$(echo "$user_def" | shyaml get-value uid 2>/dev/null) + + useradd_options=( + "-D" ## don't assign a password + "-s" "/bin/false" ## default shell + ) + if [ "$uid" ]; then + useradd_options+=("-u" "$uid") ## force uid + fi + if [ "$first_group" ]; then + useradd_options+=("-G" "$first_group") ## force main group + fi + + code+="adduser ${useradd_options[*]} \"$user\""$'\n' + code+="mkdir -p \"/home/$user\""$'\n' + if [ "$allow_writeable_chroot" ]; then + code+="chown $user \"/home/$user\""$'\n' ## sanitize + else + code+="chown root:root \"/home/$user\""$'\n' ## sanitize + fi + code+="chmod 755 \"/home/$user\""$'\n' ## sanitize + code+="echo '$user:$password' | chpasswd"$'\n' + for group in "${remaining_groups[@]}"; do + code+="adduser \"$user\" \"$group\""$'\n' + done + done < <(echo "$users_def" | shyaml key-values-0) + echo -n "$fixed_groups_code" + echo -n "$groups_code" + echo -n "$code" +} \ No newline at end of file diff --git a/vsftp/metadata.yml b/vsftp/metadata.yml new file mode 100644 index 00000000..7447c7f1 --- /dev/null +++ b/vsftp/metadata.yml @@ -0,0 +1,41 @@ +config-resources: + - /etc/vsftpd/vsftpd.conf +data-resources: + - /home + - /var/log/vsftp + +default-options: + ftpd-banner: "Welcome to FTP Server" + dirmessage-enable: yes + delay-failed-login: 2 + max-clients: 10 + max-per-ip: 5 + max-login-fails: 3 + + ## to check what these are + write-enable: yes + local-umask: "022" ## remember to send a string here + + users: + +docker-compose: + ports: + - "21:21" + +provides: + ftp-access: + tech-dep: reversed + + +uses: + log-rotate: + constraint: recommended + auto: pair + solves: + disk-leak: "/var/log/vsftp" + cert-provider: + constraint: optional + auto: pair + limit: n + solves: + feature: "SSL certificate generation"