Change:
After a recent upgrade ( followed by ) the browser-based terminal in my setup, served with ttyd, no longer accepts user input (i.e. where the user can type and enter commands through the browser). Normally you are greeted with a terminal login screen and can enter username/password and log in, but it is now no longer possible to type or submit either. After researching, there is a breaking change in ttyd version =1.7.4, where the terminal is now readonly by default, users must pass or to get an interactive terminal. Note that the release notes appear to incorrectly indicate a lower-case flag, upper-case is needed. Consulting both the listed and the nixpkgs , there is no currently no way to pass this option. I am unsure who if anyone is the maintainer of this service, although I see that maintains the ttyd package. I will attempt to fix and submit a PR.

Discussion:
{ config, lib, pkgs, ... }: <del> with lib; </del> let cfg = config.services.ttyd; <ins> inherit (lib) optionals types concatLists mapAttrsToList mkOption ; </ins> # Command line arguments for the ttyd daemon args = [ "--port" (toString cfg.port) ] ++ optionals (cfg.socket != null) [ "--interface" cfg.socket ]

++ (concatLists (mapAttrsToList (_k: _v: [ "--client-option" "${_k}=${_v}" ]) cfg.clientOptions)) ++ [ "--terminal-type" cfg.terminalType ] ++ optionals cfg.checkOrigin [ "--check-origin" ] <ins> ++ optionals cfg.writeable [ "--writable" ] # the typo is correct </ins> ++ [ "--max-clients" (toString cfg.maxClients) ] ++ optionals (cfg.indexFile != null) [ "--index" cfg.indexFile ] ++ optionals cfg.enableIPv6 [ "--ipv6" ]

options = { services.ttyd = { <del> enable = mkEnableOption (lib.mdDoc "ttyd daemon"); </del> <ins> enable = lib.mkEnableOption ("ttyd daemon"); </ins> port = mkOption { type = types.port; default = 7681; <del> description = lib.mdDoc "Port to listen on (use 0 for random port)"; </del> <ins> description = "Port to listen on (use 0 for random port)"; </ins> }; socket = mkOption { type = types.nullOr types.path; default = null; example = "/var/run/ttyd.sock"; <del> description = lib.mdDoc "UNIX domain socket path to bind."; </del> <ins> description = "UNIX domain socket path to bind."; </ins> }; interface = mkOption { type = types.nullOr types.str; default = null; example = "eth0"; <del> description = lib.mdDoc "Network interface to bind."; </del> <ins> description = "Network interface to bind."; </ins> }; username = mkOption { type = types.nullOr types.str; default = null; <del> description = lib.mdDoc "Username for basic authentication."; </del> <ins> description = "Username for basic http authentication."; </ins> }; passwordFile = mkOption { type = types.nullOr types.path; default = null; apply = value: if value == null then null else toString value; <del> description = lib.mdDoc '' File containing the password to use for basic authentication. </del> <ins> description = '' File containing the password to use for basic http authentication. </ins> For insecurely putting the password in the globally readable store use `pkgs.writeText "ttydpw" "MyPassword"`. '';

signal = mkOption { type = types.ints.u8; default = 1; <del> description = lib.mdDoc "Signal to send to the command on session close."; </del> <ins> description = "Signal to send to the command on session close."; }; entrypoint = mkOption { type = types.listOf types.str; default = [ "${pkgs.shadow}/bin/login" ]; defaultText = lib.literalExpression '' [ "''${pkgs.shadow}/bin/login" ] ''; example = lib.literalExpression '' [ (lib.getExe pkgs.htop) ] ''; description = "Which command ttyd runs."; apply = lib.escapeShellArgs; }; user = mkOption { type = types.str; # `login` needs to be run as root default = "root"; description = "Which unix user ttyd should run as."; }; writeable = mkOption { type = types.nullOr types.bool; default = null; # null causes an eval error, forcing the user to consider attack surface example = true; description = "Allow clients to write to the TTY."; </ins> }; clientOptions = mkOption { type = types.attrsOf types.str; default = {}; <del> example = literalExpression '' </del> <ins> example = lib.literalExpression '' </ins> { fontSize = "16"; fontFamily = "Fira Code"; } ''; <del> description = lib.mdDoc '' </del> <ins> description = '' </ins> Attribute set of client options for xtermjs. <https://xtermjs.org/docs/api/terminal/interfaces/iterminaloptions/> '';

terminalType = mkOption { type = types.str; default = "xterm-256color"; <del> description = lib.mdDoc "Terminal type to report."; </del> <ins> description = "Terminal type to report."; </ins> }; checkOrigin = mkOption { type = types.bool; default = false; <del> description = lib.mdDoc "Whether to allow a websocket connection from a different origin."; </del> <ins> description = "Whether to allow a websocket connection from a different origin."; </ins> }; maxClients = mkOption { type = types.int; default = 0; <del> description = lib.mdDoc "Maximum clients to support (0, no limit)"; </del> <ins> description = "Maximum clients to support (0, no limit)"; </ins> }; indexFile = mkOption { type = types.nullOr types.path; default = null; <del> description = lib.mdDoc "Custom index.html path"; </del> <ins> description = "Custom index.html path"; </ins> }; enableIPv6 = mkOption { type = types.bool; default = false; <del> description = lib.mdDoc "Whether or not to enable IPv6 support."; </del> <ins> description = "Whether or not to enable IPv6 support."; </ins> }; enableSSL = mkOption { type = types.bool; default = false; <del> description = lib.mdDoc "Whether or not to enable SSL (https) support."; </del> <ins> description = "Whether or not to enable SSL (https) support."; </ins> }; certFile = mkOption { type = types.nullOr types.path; default = null; <del> description = lib.mdDoc "SSL certificate file path."; </del> <ins> description = "SSL certificate file path."; </ins> }; keyFile = mkOption { type = types.nullOr types.path; default = null; apply = value: if value == null then null else toString value; <del> description = lib.mdDoc '' </del> <ins> description = '' </ins> SSL key file path. For insecurely putting the keyFile in the globally readable store use `pkgs.writeText "ttydKeyFile" "SSLKEY"`.

caFile = mkOption { type = types.nullOr types.path; default = null; <del> description = lib.mdDoc "SSL CA file path for client certificate verification."; </del> <ins> description = "SSL CA file path for client certificate verification."; </ins> }; logLevel = mkOption { type = types.int; default = 7; <del> description = lib.mdDoc "Set log level."; </del> <ins> description = "Set log level."; </ins> }; }; }; ###### implementation <del> config = mkIf cfg.enable { </del> <ins> config = lib.mkIf cfg.enable { </ins> assertions = [ { assertion = cfg.enableSSL -> cfg.certFile != null && cfg.keyFile != null && cfg.caFile != null; message = "SSL is enabled for ttyd, but no certFile, keyFile or caFile has been specified."; } <ins> { assertion = cfg.writeable != null; message = "services.ttyd.writeable must be set"; } </ins> { assertion = ! (cfg.interface != null && cfg.socket != null); message = "Cannot set both interface and socket for ttyd."; } { assertion = (cfg.username != null) == (cfg.passwordFile != null);

wantedBy = [ "multi-user.target" ]; serviceConfig = { <del> # Runs login which needs to be run as root # login: Cannot possibly work without effective root User = "root"; </del> <ins> User = cfg.user; </ins> LoadCredential = lib.optionalString (cfg.passwordFile != null) "TTYD_PASSWORD_FILE:${cfg.passwordFile}"; }; script = if cfg.passwordFile != null then '' PASSWORD=$(cat "$CREDENTIALS_DIRECTORY/TTYD_PASSWORD_FILE") ${pkgs.ttyd}/bin/ttyd ${lib.escapeShellArgs args}  <del> --credential ${escapeShellArg cfg.username}:"$PASSWORD"  ${pkgs.shadow}/bin/login </del> <ins> --credential ${lib.escapeShellArg cfg.username}:"$PASSWORD"  ${cfg.entrypoint} </ins> '' else '' ${pkgs.ttyd}/bin/ttyd ${lib.escapeShellArgs args}  <del> ${pkgs.shadow}/bin/login </del> <ins> ${cfg.entrypoint} </ins> ''; }; };

name = "ttyd"; meta.maintainers = with lib.maintainers; [ stunkymonkey ]; <del> nodes.machine = { pkgs, ... }: { </del> <ins> nodes.readonly = { pkgs, ... }: { services.ttyd = { enable = true; entrypoint = [ (lib.getExe pkgs.htop) ]; writeable = false; }; }; nodes.writeable = { pkgs, ... }: { </ins> services.ttyd = { enable = true; username = "foo"; passwordFile = pkgs.writeText "password" "bar"; <ins> writeable = true; </ins> }; }; testScript = '' <del> machine.wait_for_unit("ttyd.service") machine.wait_for_open_port(7681) response = machine.succeed("curl -vvv -u foo:bar -s -H 'Host: ttyd' http://127.0.0.1:7681/") assert '<title>ttyd - Terminal</title>' in response, "Page didn't load successfully" </del> <ins> for machine in [readonly, writeable]: machine.wait_for_unit("ttyd.service") machine.wait_for_open_port(7681) response = machine.succeed("curl -vvv -u foo:bar -s -H 'Host: ttyd' http://127.0.0.1:7681/") assert '<title>ttyd - Terminal</title>' in response, "Page didn't load successfully" </ins> ''; })