diff --git a/.eid/authorized_certificates b/.eid/authorized_certificates index ab088e6..d611179 100644 --- a/.eid/authorized_certificates +++ b/.eid/authorized_certificates @@ -28,3 +28,34 @@ SNt5JZCLBT4nLt0uQp9O/xctdHElZw+/W8OfnP5vxnPdccIeVOxpGIyzErwjD0+E ZJc6v5e+iqbE1ECZLco5LjWqqfvFfYCrkqeOhCRsRkVPsXnGPo2QDDYdTm4EGCmg dVZ6R452SZsrE4V+3LR011BxzEg= -----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFQDCCAyigAwIBAgIRAJmQ852eXApHBvrQujWaFAEwDQYJKoZIhvcNAQELBQAw +ODESMBAGA1UEChMJRGljdCBUZWNoMSIwIAYDVQQDExlEaWN0IFRlY2ggSXNzdWlu +ZyBDQSAyMDI2MB4XDTI2MDgxMjE1NTQyM1oXDTI5MDgyMTExMDAwMFowKzESMBAG +A1UEChMJRGljdCBUZWNoMRUwEwYDVQQDEwxzazEuZHhuZy5uZXQwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDAkkF0Qc1YhhgmgZcujjfyn3liCxAAJSGl +Eme0eD6s908zm95mGd64fQJSoYVaFt5y9wrQLdWX16c+gvRyNaQdPfs5jxaP+G8V +r6PkkE79hwauEOQOTbr+q3bQJmfLe56cMV7oHrKVFffkJQTvp1mcK4+CYytsxJP5 +v0m5TDyv4op6VcZ2m0gdrFEBV3DMMgsqF6/tSPkqbTTj9fMMSOFzXPq7qEQWgYmx +T+M0iQdgEiG7Z+INkofM56tBmQ7jSeO65/FTytB6ZMiI23NGmTmYvHPyxSDWXL0X +7tX42S0/A7QhxZHN0GUD0NGaSExLfgTawRLhARVHbUXC9iu5aCArAgMBAAGjggFQ +MIIBTDAOBgNVHQ8BAf8EBAMCBaAwKwYDVR0lBCQwIgYIKwYBBQUHAwIGCisGAQQB +gjcUAgIGCisGAQQBgjdDAQEwDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUOyThWF2A +BkoQv9xljyo3fFOclq0wHwYDVR0jBBgwFoAU9qXPjCjcil59akpPVISS8Yu/Jc0w +SgYIKwYBBQUHAQEEPjA8MDoGCCsGAQUFBzAChi5odHRwOi8vdG9vbHMuZHhuZy5j +bi9jZXJ0cy9pc3N1aW5nLWNhLTIwMjYuY2VyMCoGA1UdHwQjMCEwH6AdoBuGGWh0 +dHA6Ly9jYS5pYmQuaW5rLzEuMC9jcmwwRwYMKwYBBAGCpGTGKEABBDcwNQIBAQQD +cGl2BCtaMnFWY05lMzlUaHZyc3RIbFVFWE5laXg1QXd2aFU3MjBlWEFFZWRlUG4w +MA0GCSqGSIb3DQEBCwUAA4ICAQCKSLjnMOlQiZ381IaBEl4iT7pCvY0QQxsVEMDD +RWYhIImx3JJDt7kiZXcJwlH70VG0opSz9eKrT3cw7OY6DnNm7Bm0JT2fJgyCSnWo +nG7J1soE74Rl2MiNnP1JTqhFlZKLrmIb6eP4U0eM+ZaJ7luyxizqiJAeRyJB+YCe +DM9uZ7rC33DYZeZ46V4f7DfJ23TjVJvaX/NFu0Ab/7ylF6X7frT8xf11XLlE2IyR +MmnLtQiREXXETaw9l5suMuH8fmVg01QrqnOHmz4r9Nuzh7vHxogEsJtgBLhv2Min +X6IfaKABhFXk1cVZGNgaC0Pao6cYbaWJwF2zcVHGq5EDKXhkKmCLeDo9em0rOJ2y +fU23yP3ByMGJ3Xi7nI8E5dfSw6Q39b3t1/kxj1y8T3PGdTN/3vFLVqf5A7XXdQL9 +/uy2lYh3Fud/aZuthZyoL2rkC4O8N4seqfNIOBEHAmfbk5j6LoFFwSTrRGqzCAf4 +4r6ckHjL3KBnR8EeMwwit324iRYd3ppUL3TINkeiHRNFVKU0flUvcG6T0v+hC2sX +yupYy0wylkonub6L0gUIINqx38nsDCR07eAfsDXJshIwTxUBFrVpKjPK7jVQOZ6+ +9/yPu72BhyGMZs4bgYMEPwqKTbzUNitO2WfBIgXhzGe8YzMA1U2BMTRT0nIQ6oa9 +8YbnQw== +-----END CERTIFICATE----- diff --git a/.gitconfig2 b/.gitconfig2 index c731d12..8f32eb5 100644 --- a/.gitconfig2 +++ b/.gitconfig2 @@ -1,6 +1,7 @@ [user] - email = me@beardic.cn + email = me@dxng.cn name = Dict Xiong + signingkey = 3E241558655D7FE06C6711A5A5D6250D1806CAA8 [core] editor = vim # test this by `git update-index --test-untracked-cache` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fe32f4a..e5b5587 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,47 +44,6 @@ jobs: shell: /bin/zsh -ileo PIPE_FAIL {0} run: source tools/test.zsh - test-macos: - name: test on macos - runs-on: macos-latest - steps: - - name: checkout repo - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: install dfs - run: | - rev=`git rev-parse HEAD` - pwd - set -x - DFS_NO_WALL=0 ./install.sh -adl - test `git rev-parse HEAD` = "$rev" - - - name: antigen build - shell: /bin/zsh -ileo PIPE_FAIL {0} - run: | - echo $SHELL - antigen list - - - name: run tests and reset - shell: /bin/zsh -ileo PIPE_FAIL {0} - run: | - source tools/test.zsh - antigen reset - rm -rf $ANTIGEN $HOME/.antigen $HOME/.config/dotfiles - ./install.sh -dl - - - name: antigen build with DFS_NO_WALL - shell: /bin/zsh -ileo PIPE_FAIL {0} - run: | - echo $SHELL - antigen list - - - name: run tests - shell: /bin/zsh -ileo PIPE_FAIL {0} - run: source tools/test.zsh - test-autodep: name: test of auto-install dependencies runs-on: ubuntu-latest @@ -128,4 +87,4 @@ jobs: shell: /bin/zsh -ileo PIPE_FAIL {0} run: | dfs cd - source tools/test.zsh \ No newline at end of file + source tools/test.zsh diff --git a/.ssh/authorized_keys2 b/.ssh/authorized_keys2 index c58401d..efd264c 100644 --- a/.ssh/authorized_keys2 +++ b/.ssh/authorized_keys2 @@ -2,11 +2,15 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGakUcSuNhuH7tk+Nj9gDfRtxYfGPL9yb8toQ/EpSJAM openpgp:0xD9271B01 +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEJ4FeeBwPcUe25JsZQ7xDmGtFVeO1TQ9332IPjFY0Xm openpgp:0x8774BF70 + ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCUN7IXF4nlFcVfgHesgik3LIAiXlVMYJPm3yD13EVarQx5jqdBgk8Dwgkgf4rPO6MFpvIpinOyEO8zOS6HHQrCLZUv5yTFaDkUuB7eQ0EmpicGbmk9bHqj1HkOZxaobkpEfQUmFKYvkp4EexVw66sO0qfXvjHZ4H6yCAJLK5aUnKfgrE8tODzP82sU/mpJjW+Pq3uanNq754gaHwhxCIXG143/zp8qzBAeKe38xVqqDq9fTkG4hvzFvkRdS88i6l1z++0P3n0HGdOjtSg7P7fO7+7ZyPYr0gO5vB720Om/zxqPrGd9cicWi4P+aVKa+0ujWH/pqufWG6uCjKWHnBs7 sk0/piv/9a -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMyZILj+GxTUhdCgz2w1TxQ+aTcggnOJIb84qA4u271S ltp0 +ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAkkF0Qc1YhhgmgZcujjfyn3liCxAAJSGlEme0eD6s908zm95mGd64fQJSoYVaFt5y9wrQLdWX16c+gvRyNaQdPfs5jxaP+G8Vr6PkkE79hwauEOQOTbr+q3bQJmfLe56cMV7oHrKVFffkJQTvp1mcK4+CYytsxJP5v0m5TDyv4op6VcZ2m0gdrFEBV3DMMgsqF6/tSPkqbTTj9fMMSOFzXPq7qEQWgYmxT+M0iQdgEiG7Z+INkofM56tBmQ7jSeO65/FTytB6ZMiI23NGmTmYvHPyxSDWXL0X7tX42S0/A7QhxZHN0GUD0NGaSExLfgTawRLhARVHbUXC9iu5aCAr sk1/piv/9a -ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBN24pvN1RMN+iSZyHPdyExA1Rvt8pdr3e6ih6iX+KrQ ltp2 +sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIA71ukY9nzzeAGp9WYstPYiIwexzIANlPwMQ68QNQwqxAAAABHNzaDo= sk1/fido2 + +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMyZILj+GxTUhdCgz2w1TxQ+aTcggnOJIb84qA4u271S ltp0 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGZFTyR+R86fUpZBDkDR9yYzJpmeNsIv2CMHTh6EuEeq pc0 @@ -16,5 +20,4 @@ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIF52mPmbLEriZ+DfmrMHFtvg2kiO2JpRpitvs7PbJowh ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNvHya5i3GNniGww9Yx5ikos4Z5jevu8zz8hCQi05++SgejFPnbw9UjbHFjODK2M+ZHuhRF5mdf996r4/BxWyP8= pad0/sep -ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBC5gu+U4picxVvCNYjLlmxGLpJl7svj3LklO7jSpwqdrNxlE+/xkx07PJx76AbA77dDM1Dxmm/VOvVQCKTRLTgk= ip14/sep - +ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMXu7UND6N1EKcbeSeAj2CwzGA3sM5/bRtLwoY0euWdR4vzB0nwcwd6nct5JCEyozjujJfMt8d7oCzhSt1w/1cU= ip17/sep diff --git a/.tmux.conf2 b/.tmux.conf2 index 5cfd35a..346bb8e 100644 --- a/.tmux.conf2 +++ b/.tmux.conf2 @@ -1,4 +1,5 @@ set -g prefix ^a +bind ^a send-prefix set -g mouse on set -g set-clipboard on set -g default-terminal "xterm-256color" @@ -24,9 +25,19 @@ bind - split-window -v -c "#{pane_current_path}" bind | split-window -h -c "#{pane_current_path}" # other key bindings +## prefix r: reload conf bind r source-file ~/.tmux.conf \; display-message "tmux.conf reloaded" +## prefix m: toggle mouse mode bind m run 'old=$(tmux show -gv mouse); new="off"; if [ "$old" = "off" ]; then new="on"; fi; tmux set -g mouse $new && tmux display-message toggle\ mouse\ mode\ $new' +## prefix C-x: pane sync bind C-x set-window-option synchronize-panes\; display-message "synchronize-panes is now #{?pane_synchronized,on,off}" +## prevent jumping to end after mouse selection +bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-selection -x +## F12: passthru +bind -T root F12 set prefix None \; set key-table off \; set status-bg red \; refresh-client -S +bind -T off F12 set -u prefix \; set -u key-table \; set -u status-bg \; refresh-client -S +## ESC: exit copy mode +bind -T copy-mode-vi Escape send-keys -X cancel # styles # status bar diff --git a/.zshrc2 b/.zshrc2 index 5679b0f..8de6c99 100644 --- a/.zshrc2 +++ b/.zshrc2 @@ -9,6 +9,7 @@ else fi export DOTFILES=$( cd "$( dirname "${BASH_SOURCE[0]:-${(%):-%x}}" )" && pwd ) export PATH="$PATH:$DOTFILES/scripts" +fpath=($DOTFILES/functions $fpath) export EDITOR='vim' # env for apps export PYTHONIOENCODING='UTF-8' @@ -130,9 +131,33 @@ alias "jcf"='jc -f' alias "jcfu"='jc -fu' alias "sc"='systemctl' alias "t"='tmux' -gbes() { git for-each-ref --sort=-committerdate refs/heads refs/remotes --format="%(authordate:format:%y-%m-%d.%a %H:%M %z)|%(color:red)%(objectname:short)|%(color:yellow)%(refname:short)%(color:reset)|%(color:reset)%(authorname): %(color:green)%(subject)" --color=always | column -ts"|" | less -FX } -sagt() { eval "$($DOTFILES/tools/sagent.sh $@)" } -use() { nix --experimental-features nix-command --extra-experimental-features flakes shell "${(*)@/#%(#b)([^#]#)/nixpkgs#$match}" } +gbes() { git for-each-ref --sort=-committerdate refs/heads refs/remotes --format="%(authordate:format:%y-%m-%d.%a %H:%M %z)|%(color:red)%(objectname:short)|%(color:yellow)%(refname:short)%(color:reset)|%(color:reset)%(authorname): %(color:green)%(subject)" --color=always | column -ts"|" | less -FX ; } +sagt() { + local output + output="$("$DOTFILES/tools/sagent.sh" "$@")" || return $? + eval "$output" +} +## nixos +use() { nix --experimental-features nix-command --extra-experimental-features flakes shell "${(*)@/#%(#b)([^#]#)/nixpkgs#$match}" ; } +bnd() { + if [[ -n "$1" && -d "./hosts/$1" ]]; then + nixos-rebuild --flake .#$1 build || return $? + nix-diff /opt/NixOS-Config/results/hosts/$1 ./result --color=always | less + else + echo host $1 does not exist + fi +} +nor() { + test -n "$1" || { echo fatal: no verb given; return 1; } + nixos-rebuild "$1" --flake .${2:+#}$2 +} +alias 'nors'='nor switch' +alias 'norbt'='nor boot' +alias 'nort'='nor test' +alias 'norb'='nor build' +alias 'nordb'='nor dry-build' +alias 'norda'='nor dry-activate' + # key bindings bindkey "^b" beginning-of-line # ctrl+b diff --git a/functions/_beam b/functions/_beam new file mode 100644 index 0000000..f337a7a --- /dev/null +++ b/functions/_beam @@ -0,0 +1,99 @@ +#compdef beam + +# ============================================================================= +# Beam-Go Zsh Completion Script +# ============================================================================= + +_beam() { + local context state state_descr line + typeset -A opt_args + + _arguments -C \ + '1: :_beam_commands' \ + '*:: :->args' + + case $state in + (args) + case $line[1] in + (serve) _beam_serve ;; + (add) _beam_add ;; + (list) _beam_list ;; + (del) _beam_del ;; + esac + ;; + esac +} + +# --- Subcommand Definitions --- + +(( $+functions[_beam_commands] )) || +_beam_commands() { + local -a commands + commands=( + 'serve:Start the beam server daemon' + 'add:Share a file or directory' + 'list:List active shares' + 'del:Delete a share by path or code' + ) + _describe -t commands 'beam command' commands +} + +_beam_serve() { + _arguments \ + '(-d --database)'{-d,--database}'=[Database directory (Required)]:directory:_path_files -/' \ + '(-p --port)'{-p,--port}'=[Port to listen on (default :8280)]:port:' \ + '(-s --socket)'{-s,--socket}'=[Socket path]:socket path:_files' \ + '--subpath=[Enable subpath sharing]' +} + +_beam_add() { + _arguments \ + '(-d --days)'{-d,--days}'=[Expiration days (default 7)]:days:' \ + '(-s --socket)'{-s,--socket}'=[Socket path]:socket path:_files' \ + '1:file to share:_files' +} + +_beam_list() { + _arguments \ + '(-s --socket)'{-s,--socket}'=[Socket path]:socket path:_files' +} + +_beam_del() { + _arguments \ + '(-s --socket)'{-s,--socket}'=[Socket path]:socket path:_files' \ + '(-c --code)'{-c,--code}'=[Share code]:share code:_beam_active_codes' \ + '1:shared path:_files' +} + +# --- Helpers --- + +# 动态获取当前的 Share Code +# 它会尝试读取用户当前输入的 -s 参数,去连接正确的 socket +_beam_active_codes() { + local socket_path="" + + # 从 Zsh 解析的参数中提取 socket 路径 + if [[ -n ${opt_args[-s]} ]]; then + socket_path=${opt_args[-s]} + elif [[ -n ${opt_args[--socket]} ]]; then + socket_path=${opt_args[--socket]} + fi + + # 构造查询命令 + local cmd_args=() + cmd_args+=("list") + [[ -n $socket_path ]] && cmd_args+=("-s" "$socket_path") + + # 调用 beam list,跳过前两行表头,提取 Code($1) 和 Path($3) + # 2>/dev/null 防止服务未启动时报错打印到终端 + local -a shares + shares=("${(@f)$(beam "${cmd_args[@]}" 2>/dev/null | awk 'NR>2 {print $1":"$2" "$3" "$4}')}") + + if [[ ${#shares} -gt 0 ]]; then + _describe -t shares 'active share' shares + else + _message 'no active shares or server not running' + fi +} + +_beam "$@" \ No newline at end of file diff --git a/functions/_beaml b/functions/_beaml new file mode 100644 index 0000000..318b831 --- /dev/null +++ b/functions/_beaml @@ -0,0 +1,35 @@ +#compdef beaml + +_beaml() { + local context state line + typeset -A opt_args + + _arguments -C \ + '1: :->cmds' \ + '2: :->args' + + case $state in + cmds) + local commands; commands=( + 'add:Add a new item' + 'del:Delete an item' + 'list:List all items' + ) + _describe -t commands 'beaml commands' commands + ;; + args) + case $line[1] in + add) + _files + ;; + del) + local -a items + items=( /pss/lite/*(N:t) ) + _describe -t items 'item to delete' items + ;; + esac + ;; + esac +} + +_beaml "$@" diff --git a/functions/_nixos_config_hosts b/functions/_nixos_config_hosts new file mode 100644 index 0000000..368457c --- /dev/null +++ b/functions/_nixos_config_hosts @@ -0,0 +1,3 @@ +#compdef bnd nor + +compadd -- /root/NixOS-Config/hosts/*(/:t) \ No newline at end of file diff --git a/functions/_riot b/functions/_riot new file mode 100644 index 0000000..1b044df --- /dev/null +++ b/functions/_riot @@ -0,0 +1,105 @@ +#compdef riot + +_riot() { + local context state state_descr line + typeset -A opt_args + + _arguments -C -s \ + '(-4 -6)-4[Force ssh to use IPv4 addresses only]' \ + '(-4 -6)-6[Force ssh to use IPv6 addresses only]' \ + '(-D --dry-run)'{-D,--dry-run}'[Print commands without executing them]' \ + '(-d --dev)'{-d,--dev}'[Enable shell execution tracing]' \ + '(-g --gpg)'{-g,--gpg}'[Forward the local GPG agent for an interactive login]' \ + '(-h --help)'{-h,--help}'[Display help and exit]' \ + '(-l --lite)'{-l,--lite}'[Enable dotfiles lite mode]' \ + '*-o[Pass an option to ssh]:SSH option:' \ + '(-p --password)'{-p,--password}'[Use password authentication]' \ + '(-q --quiet)'{-q,--quiet}'[Enable dotfiles quiet mode]' \ + '(-t --trust)'{-t,--trust}'[Trust the remote and enable forwarding]' \ + '*-v[Enable verbose ssh output]' \ + '--color[Force colored output]' \ + '--tmux[Open SSH sessions in tmux windows]' \ + '1:remote:_riot_remotes' \ + '2:command:_riot_commands' \ + '*::command argument:->command-args' + + case $state in + command-args) + case $line[2] in + ssh|tmux|zssh) + _riot_complete_with _ssh ssh "$line[1]" "${line[@]:2}" + ;; + git) + _riot_complete_with _git git "${line[@]:2}" + ;; + sftp) + _riot_complete_with _sftp sftp "$line[1]" "${line[@]:2}" + ;; + scp) + _riot_complete_with _scp scp "${line[@]:2}" + ;; + sshl|sshr) + _message 'forwarding specification' + ;; + sshd) + _guard '[0-9]#' 'local port' + ;; + rm|ping|ping4|ping6) + _message 'no more arguments' + ;; + *) + _default + ;; + esac + ;; + esac +} + +_riot_commands() { + local -a commands=( + 'ssh:Open an SSH session (default)' + 'tmux:Open SSH sessions in tmux windows' + 'git:Run git on the remote host' + 'sshl:Create local port forwarding with ssh -L' + 'sshr:Create remote port forwarding with ssh -R' + 'sshd:Create dynamic port forwarding with ssh -D' + 'zssh:Open a zssh session' + 'sftp:Open an SFTP session' + 'scp:Copy files to or from the remote host' + 'rm:Remove the remote host key from known_hosts' + 'ping:Ping the remote host' + 'ping4:Ping the remote host over IPv4' + 'ping6:Ping the remote host over IPv6' + ) + _describe -t commands 'riot command' commands +} + +_riot_presets() { + local -a config_files presets + local file + + [[ -z $DOTFILES ]] || config_files+=("$DOTFILES/riot-config.sh") + config_files+=("$HOME/.config/riot-config.sh" "$PWD/riot-config.sh") + for file in "${config_files[@]}"; do + [[ -r $file ]] || continue + presets+=("${(@f)$(sed -nE 's/^([[:alnum:]_.-]+)\.(remote|batch)\(\).*/\1/p' "$file" 2>/dev/null)}") + done + presets=(${(u)presets}) + _describe -t presets 'riot preset' presets +} + +_riot_remotes() { + _alternative \ + 'presets:riot preset:_riot_presets' \ + 'hosts:host:_hosts' +} + +_riot_complete_with() { + local completer=$1 + shift + local -a words=("$@") + local CURRENT=$#words + "$completer" +} + +_riot "$@" diff --git a/functions/_sagt b/functions/_sagt new file mode 100644 index 0000000..e064bce --- /dev/null +++ b/functions/_sagt @@ -0,0 +1,3 @@ +#compdef sagt + +compadd -- gpg gpg-pin kill ls op piv reset diff --git a/functions/_use b/functions/_use new file mode 100644 index 0000000..6df6fdb --- /dev/null +++ b/functions/_use @@ -0,0 +1,3 @@ +#compdef use + +compadd -- $(nix eval --impure --expr 'builtins.attrNames ( (import ) {} )' 2>/dev/null | tr '[]"' ' ') \ No newline at end of file diff --git a/riot-config.sh b/riot-config.sh index 7c54e96..a5991fc 100644 --- a/riot-config.sh +++ b/riot-config.sh @@ -25,7 +25,7 @@ nasps.batch() { # remotes nasp.remote() { - remote=nasp.fit + remote=jump.nasp.fit RET_PORT=${RET_PORT:-36022} RET_USERNAME=${RET_USERNAME:-root} RET_TRUST_SERVER=1 @@ -60,7 +60,7 @@ nasp.domain() { RET_HOSTNAME=$host RET_PORT=${RET_PORT:-12022} RET_USERNAME=${RET_USERNAME:-root} - RET_JUMP_SERVER="ssh@nasp.fit:36022" + RET_JUMP_SERVER="ssh@jump.nasp.fit:36022" RET_TRUST_SERVER=1 } diff --git a/scripts/riot b/scripts/riot index 6d029d1..93a27bb 100755 --- a/scripts/riot +++ b/scripts/riot @@ -1,10 +1,13 @@ #!/usr/bin/env bash # connect to iot services THIS_DIR=$( cd "$( dirname "${BASH_SOURCE[0]:-${(%):-%x}}" )" && pwd ) +DFS_SKIP_ARG_PARSE=1 source "$THIS_DIR/../tools/common.sh" +unset DFS_SKIP_ARG_PARSE RIOT_TRUST_CLIENT=${RIOT_TRUST_CLIENT:-${DFS_TRUST:-0}} RIOT_TRUST_SERVER=${RIOT_TRUST_SERVER:-0} EXTRA_SSH_OPTIONS=() +GPG_FORWARD=0 # config RIOT_CONFIG_FILES=( @@ -12,22 +15,25 @@ RIOT_CONFIG_FILES=( "$HOME/.config/riot-config.sh" "riot-config.sh" ) -for file in "${RIOT_CONFIG_FILES[@]}"; do - if [[ -f "$file" ]]; then - source "$file" - fi -done +load_riot_config() { + local file + for file in "${RIOT_CONFIG_FILES[@]}"; do + if [[ -f "$file" ]]; then + source "$file" + fi + done +} # check if port number valid check_port() { - ( echo $1 | grep -qxE "[1-9][0-9]{0,4}" ) || return 1 - test $1 -lt 65536 -a $1 -gt 0 || return 1 + [[ "$1" =~ ^[1-9][0-9]{0,4}$ ]] || return 1 + [[ $1 -lt 65536 && $1 -gt 0 ]] || return 1 return 0 } # check if username valid check_username() { - ( echo $1 | grep -qxE "^[a-z][-a-z0-9_]*\$" ) || return 1 + [[ "$1" =~ ^[a-z][-a-z0-9_]*$ ]] || return 1 return 0 } @@ -42,18 +48,28 @@ get_server_meta() { RET_JUMP_SERVER="" # optional # body local remote="$1" - # if in the form user@... + # extract username from user@... if [[ "$remote" == *@* ]]; then RET_USERNAME=${remote%%@*} remote=${remote#*@} check_username $RET_USERNAME || fmt_warning \"$RET_USERNAME\" is not a valid unix username fi - # if in the form ...:22 - if [[ "$remote" == "["*"]":* || ( "$remote" != "["*"]" && "$remote" == *:* ) ]]; then + # extract port from ...:port + if [[ + "$remote" =~ ^[^:]+:[1-9][0-9]*$ # contains only one colon + || "$remote" =~ ^\[.+\]:[1-9][0-9]*$ # in the form of [host]:port + || "$remote" =~ :::[1-9][0-9]*$ # in the form of :::port + || "$remote" =~ ^([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4}:[1-9][0-9]*$ # full ipv6 address with port + || "$remote" =~ ^[0-9A-Fa-f:]+%.+:[1-9][0-9]*$ # ipv6 address with scope and port + ]]; then RET_PORT=${remote##*:} remote=${remote%:*} check_port $RET_PORT || fmt_fatal invalid port number \"$RET_PORT\" fi + # remove square brackets + if [[ "$remote" =~ ^\[.*\]$ ]]; then + remote=${remote:1:-1} + fi # presets -- match remote local remote_func="$remote.remote" if is_function "$remote_func"; then @@ -63,8 +79,8 @@ get_server_meta() { RET_HOSTNAME=${remote} local domain=${remote##*.} local host=${remote%.*} - # if there's no dot - if [[ "$host" == "$domain" && "$host" != "["*"]" ]]; then + # if it contains no dot and is not ipv6 + if [[ "$remote" != *.* && "$remote" != *:* ]]; then domain="default" fi local domain_func="$domain.domain" @@ -83,12 +99,15 @@ parse_remote() { TRUST_SERVER=1 PORT="" # optional USERNAME="" # optional - SSH_OPTIONS=("-o" "RequestTTY=yes") + SSH_OPTIONS=("-o" "ServerAliveInterval=60") + if [[ -t 1 ]]; then + SSH_OPTIONS+=("-o" "RequestTTY=yes") + fi if [[ "$RIOT_TRUST_CLIENT" == "1" ]]; then SSH_OPTIONS+=("-o" "PermitLocalCommand=yes") if [[ "$(get_os_type)" != "msys" ]]; then test "$DFS_DRY_RUN" = "1" || mkdir -p ~/.ssh/master-socket - SSH_OPTIONS+=("-o" "ControlMaster=auto" "-o" "ControlPath=~/.ssh/master-socket/%C") + SSH_OPTIONS+=("-o" "ControlMaster=auto" "-o" "ControlPersist=5s" "-o" "ControlPath=~/.ssh/master-socket/%C") fi fi # handle input @@ -122,10 +141,133 @@ parse_remote() { fi } +check_local_gpg_agent() { + LOCAL_GPG_EXTRA_SOCKET="" + GPG_FORWARD_ERROR="" + + if ! command -v gpgconf > /dev/null 2>&1 || ! command -v gpg-connect-agent > /dev/null 2>&1; then + GPG_FORWARD_ERROR="gpgconf or gpg-connect-agent is not available" + return 1 + fi + + local agent_info + agent_info=$(gpg-connect-agent --no-autostart 'GETINFO pid' /bye 2>/dev/null || true) + if ! grep -qE '^D [1-9][0-9]*$' <<< "$agent_info"; then + GPG_FORWARD_ERROR="local gpg-agent is not running" + return 1 + fi + + LOCAL_GPG_EXTRA_SOCKET=$(gpgconf --list-dirs agent-extra-socket 2>/dev/null || true) + if [[ -z "$LOCAL_GPG_EXTRA_SOCKET" || "$LOCAL_GPG_EXTRA_SOCKET" != /* \ + || "$LOCAL_GPG_EXTRA_SOCKET" == *:* || "$LOCAL_GPG_EXTRA_SOCKET" == *$'\r'* \ + || "$LOCAL_GPG_EXTRA_SOCKET" == *$'\n'* || ! -S "$LOCAL_GPG_EXTRA_SOCKET" ]]; then + GPG_FORWARD_ERROR="local gpg-agent extra socket is unavailable" + return 1 + fi + + local extra_info + extra_info=$(gpg-connect-agent --raw-socket "$LOCAL_GPG_EXTRA_SOCKET" 'GETINFO version' /bye 2>/dev/null || true) + if ! grep -qE '^D [^[:space:]]+' <<< "$extra_info"; then + GPG_FORWARD_ERROR="local gpg-agent extra socket exists but is not accepting connections" + return 1 + fi +} + +probe_remote_gpg_socket() { + REMOTE_GPG_SOCKET="" + + # The probe must be an independent connection: reusing or creating a + # multiplex master here races with the immediately following login. + local query_cmd=(ssh "-S" "none" "-o" "ClearAllForwardings=yes") + if [[ -n "$PORT" ]]; then + query_cmd+=("-p" "$PORT") + fi + query_cmd+=( + "${SSH_OPTIONS[@]}" + "${EXTRA_SSH_OPTIONS[@]}" + "-T" + "$USERNAME${USERNAME:+@}$SERVER" + 'socket=$(gpgconf --list-dirs agent-socket 2>/dev/null) || exit 10 +case "$socket" in /*/S.gpg-agent) ;; *) exit 11;; esac +case "$socket" in *:*) exit 11;; esac +case "$socket" in *" +"*) exit 11;; esac +carriage_return=$(printf "\r") +case "$socket" in *"$carriage_return"*) exit 11;; esac +if [ -e "$socket" ] && [ ! -S "$socket" ]; then exit 12; fi +if [ -S "$socket" ] && command -v gpg-connect-agent >/dev/null 2>&1; then + agent_mode=$(gpg-connect-agent --raw-socket "$socket" "GETINFO restricted" /bye 2>/dev/null || true) + case "$agent_mode" in *"D 1"*) exit 13;; esac +fi +systemd_socket=0 +if command -v systemctl >/dev/null 2>&1 && systemctl --user is-active --quiet gpg-agent.socket >/dev/null 2>&1; then + systemd_socket=1 +fi +gpgconf --kill gpg-agent >/dev/null 2>&1 || exit 14 +rm -f "$socket" || exit 15 +printf "%s\n%s\n" "$socket" "$systemd_socket"' + ) + + local output status + if output=$("${query_cmd[@]}"); then + status=0 + else + status=$? + fi + case "$status" in + 0) ;; + 10) GPG_FORWARD_ERROR="gpgconf is unavailable on the remote host" ;; + 11) GPG_FORWARD_ERROR="remote gpgconf returned an invalid agent socket" ;; + 12) GPG_FORWARD_ERROR="refusing to remove the non-socket remote gpg-agent path" ;; + 13) GPG_FORWARD_ERROR="another forwarded gpg-agent is already using the remote socket; close that session first" ;; + 14) GPG_FORWARD_ERROR="failed to stop the remote gpg-agent (the socket may belong to another forwarding session)" ;; + 15) GPG_FORWARD_ERROR="failed to remove the stale remote gpg-agent socket" ;; + *) GPG_FORWARD_ERROR="failed to query or clean the remote gpg-agent socket (ssh status $status)" ;; + esac + if [[ "$status" != "0" ]]; then + return 1 + fi + + local remote_systemd_socket + REMOTE_GPG_SOCKET=${output%%$'\n'*} + remote_systemd_socket=${output#*$'\n'} + if [[ -z "$REMOTE_GPG_SOCKET" || "$REMOTE_GPG_SOCKET" == *$'\r'* || "$REMOTE_GPG_SOCKET" == *$'\n'* \ + || "$REMOTE_GPG_SOCKET" != /*/S.gpg-agent || "$REMOTE_GPG_SOCKET" == *:* \ + || "$remote_systemd_socket" != "0" && "$remote_systemd_socket" != "1" ]]; then + GPG_FORWARD_ERROR="remote gpgconf returned an invalid agent socket: $output" + return 1 + fi + if [[ "$remote_systemd_socket" == "1" ]]; then + fmt_warning "remote gpg-agent.socket is active and may race with GPG forwarding; consider disabling its socket activation" + fi +} + +prepare_gpg_forwarding() { + if [[ "$DFS_DRY_RUN" == "1" ]]; then + REMOTE_GPG_SOCKET="" + LOCAL_GPG_EXTRA_SOCKET="" + else + if ! check_local_gpg_agent; then + fmt_fatal "cannot forward gpg-agent: $GPG_FORWARD_ERROR" + fi + if ! probe_remote_gpg_socket; then + fmt_fatal "cannot forward gpg-agent: $GPG_FORWARD_ERROR" + fi + fi + + SSH_OPTIONS+=( + # The probe already removed the old socket. Do not let the main SSH + # connection unlink a path recreated during the gap between them. + "-o" "StreamLocalBindUnlink=no" + "-o" "ExitOnForwardFailure=yes" + "-R" "$REMOTE_GPG_SOCKET:$LOCAL_GPG_EXTRA_SOCKET" + ) +} + print_cmd() { local output="" for s in "${CMD[@]}"; do - if [[ "$s" =~ [\ \\\'\"] ]]; then + if [[ "$s" =~ [\ \\\'\"] ]]; then # needs to be escaped s="${s@Q}" fi output+="$s " @@ -135,7 +277,7 @@ print_cmd() { eval_or_echo() { local DO="" - local tmux_win=0 + tmux_win=${tmux_win:-0} if [[ "$DFS_DRY_RUN" == "1" ]]; then DO=echo fi @@ -147,7 +289,10 @@ eval_or_echo() { tmux_win=$((tmux_win+1)) $DO tmux new-window -t $TMUX_SESS:$tmux_win -d bash -l fi - $DO tmux send-keys -t $TMUX_SESS:$tmux_win "${CMD[@]}" Enter + local command + printf -v command '%q ' "${CMD[@]}" + $DO tmux send-keys -l -t "$TMUX_SESS:$tmux_win" "${command% }" + $DO tmux send-keys -t "$TMUX_SESS:$tmux_win" Enter else $DO "${CMD[@]}" fi @@ -185,19 +330,50 @@ run_ssh() # sshl run_sshl() { - local arg="$1" - if [[ "$arg" != *":"* ]]; then - # treat as a port number - arg=localhost:$arg + local arg left right localsock access + local res="${1//[^:]}" + local lorr="-L" + if [[ "${FUNCNAME[1]}" == "run_sshr" ]]; then + lorr="-R" fi - local port=$(get_free_port) - SSH_OPTIONS+=("-NC" "-L" "$port:$arg") + if [[ ${#res} -eq 2 ]]; then + arg="$1" + elif [[ ${#res} -eq 0 ]]; then + if [[ "$lorr" == "-R" ]]; then + arg="$1" + else + arg="$(get_free_port):localhost:$1" + fi + else + left=${1%%:*} + right=${1##*:} + if check_port "$left"; then + arg="$1" + elif check_port "$right"; then + arg="$(get_free_port):$1" + else + arg="$1" + localsock=1 + fi + fi + if [[ "$localsock" == "1" ]]; then + access="unix://${arg%%:*}" + else + access="localhost:${arg%%:*}" + fi + SSH_OPTIONS+=("-NC" "$lorr" "$arg") prepare_ssh_cmd ssh print_cmd - fmt_note " > please access localhost:$port" + fmt_note " > please access $access" eval_or_echo } +# sshr +run_sshr() +{ + run_sshl "$1" +} + # sshd run_sshd() { @@ -259,25 +435,147 @@ remove_hostkey() { # main print_help() { - fmt_info "usage: $0 [-Ddhlqt] [--dry-run] [--dev] [--help] [--lite] [--quite] [--trust] [--tmux] [--password] [[-o ssh-option]...] remote [command] [--] [ssh-command-args]" - cat < /dev/null 2>&1; then + pager=(less -R) + fi + + cat < 0 ]]; do - case "$1" in + local arg="" + local option="" + local remaining="" + local option_value="" + while [[ $# -gt 0 || -n "$arg" ]]; do + if [[ -z "$arg" ]]; then + arg=$1 + shift + fi + + # Normalize a long option or one item from a short-option group. + remaining="" + case "$arg" in + -- ) + positional+=("$@") + break + ;; + --* ) + option=$arg + arg="" + ;; + -?* ) + option=${arg:0:2} + remaining=${arg:2} + arg=${remaining:+-$remaining} + ;; + * ) + positional+=("$arg") + arg="" + if [[ "${#positional[@]}" -ge 2 ]]; then + positional+=("$@") + break + fi + continue + ;; + esac + + case "$option" in -h|--help ) print_help exit 0 @@ -285,36 +583,68 @@ router() { -t|--trust ) RIOT_TRUST_SERVER=1 ;; + -g|--gpg ) + GPG_FORWARD=1 + ;; --tmux ) USE_TMUX=1 ;; - --password ) + -p|--password ) EXTRA_SSH_OPTIONS+=("-o" "PasswordAuthentication=yes" "-o" "PubkeyAuthentication=no") ;; + -D|--dry-run ) + export DFS_DRY_RUN=1 + ;; + -d|--dev ) + export DFS_DEV=1 + set -x + ;; + -l|--lite ) + export DFS_LITE=1 + ;; + -q|--quiet ) + export DFS_QUIET=1 + ;; + --color ) + export DFS_COLOR=1 + setup_color + ;; + -4|-6|-v ) + EXTRA_SSH_OPTIONS+=("$option") + ;; -o ) - EXTRA_SSH_OPTIONS+=("-o" "$2") - shift - ;; - -- ) - shift - positional+=("$@") - break - ;; - -* ) - fmt_fatal "unknown option: $1" + if [[ -n "$remaining" ]]; then + option_value=$remaining + arg="" + else + [[ $# -gt 0 ]] || fmt_fatal "option '-o' requires an argument" + option_value=$1 + shift + fi + EXTRA_SSH_OPTIONS+=("-o" "$option_value") ;; * ) - positional+=("$1") + fmt_fatal "unknown option: '$option'" ;; esac - shift done + + if [[ "${positional[2]}" == "--" ]]; then + positional=("${positional[@]:0:2}" "${positional[@]:3}") + fi + IFS=',' read -ra remotes <<< "${positional[0]}" for i in ${!remotes[@]}; do if [[ -z "${remotes[i]}" ]]; then unset remotes[i]; fi; done if [[ "${#positional[@]}" == "0" || "${#remotes[@]}" == "0" ]]; then print_help exit 1 fi + load_riot_config + if [[ "$GPG_FORWARD" == "1" && ( \ + ( "${positional[1]}" != "" && "${positional[1]}" != "ssh" && "${positional[1]}" != "tmux" ) \ + || "${#positional[@]}" -gt 2 ) ]]; then + fmt_fatal "gpg-agent forwarding is only supported for interactive SSH login" + fi for i in ${!remotes[@]}; do remote="${remotes[i]}" local batch_func="${remote}.batch" @@ -326,8 +656,18 @@ router() { case "${positional[1]}" in ssh|tmux|"" ) [[ "${positional[1]}" == tmux ]] && USE_TMUX=1 + if [[ "$GPG_FORWARD" == "1" ]]; then + prepare_gpg_forwarding + fi run_ssh ssh "${positional[@]:2}" ;; + git ) + if printf '%s\0' "${positional[@]:2}" | grep -Fxqz -- '-C'; then + run_ssh ssh git "${positional[@]:2}" + else + run_ssh ssh git -C "$(pwd)" "${positional[@]:2}" + fi + ;; ping|ping4|ping6 ) test "${#positional[@]}" -eq 2 || fmt_fatal "ping requires no arguments" run_ping "${positional[1]}" @@ -343,6 +683,11 @@ router() { test "${#positional[@]}" -eq 3 || fmt_fatal "sshl requires exactly one argument" run_sshl "${positional[2]}" ;; + sshr ) + test -n "${positional[2]}" || fmt_fatal "no target address provided" + test "${#positional[@]}" -eq 3 || fmt_fatal "sshr requires exactly one argument" + run_sshr "${positional[2]}" + ;; sshd ) test "${#positional[@]}" -le 3 || fmt_fatal "sshd requires one or no arguments" if [[ "${#positional[@]}" -eq 3 ]]; then @@ -374,4 +719,4 @@ router() { fi } -router "${GOT_OPTS[@]}" +router "$@" diff --git a/scripts/sne b/scripts/sne new file mode 100755 index 0000000..b216abe --- /dev/null +++ b/scripts/sne @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +if [ "$#" -eq 0 ]; then + echo "usage: sne " + echo "example: sne config.yaml" + exit 1 +fi + +SEARCH_PATTERN="$1" +files=() +while IFS= read -r -d $'\0' file; do + files+=("$file") +done < <(fd "$SEARCH_PATTERN" --type f --print0) +file_count=${#files[@]} +if [ "$file_count" -eq 0 ]; then + echo "no file match '$SEARCH_PATTERN'" + exit 0 +fi +echo "🔎 found $file_count files:" +for file in "${files[@]}"; do + echo " 📄 $file" +done +echo "----------------------------------------" + +read -r -p "edit? [Y/n] " response +response=${response:-Y} +if [[ "$response" =~ ^[Yy]$ ]]; then + vim "${files[@]}" +else + echo "❌ aborted" +fi diff --git a/tools/common.sh b/tools/common.sh index 86f9bd3..328f5f8 100755 --- a/tools/common.sh +++ b/tools/common.sh @@ -6,19 +6,19 @@ if [[ -f ~/.config/dotfiles/env ]]; then set -a; source ~/.config/dotfiles/env; if [[ "$DFS_DEV" == "1" ]]; then set -x; fi DFS_CURL_OPTIONS="--retry 2 --max-time 20" -# parse args and set env, when it is sourced -# todo: make this skipable -if [[ "${BASH_SOURCE[0]}" != "${0}" ]]; then +# Parse args and set env when sourced, unless the caller handles its own +# option boundary. +if [[ "${BASH_SOURCE[0]}" != "${0}" && "$DFS_SKIP_ARG_PARSE" != "1" ]]; then ORIGIN_ARGS=("$@") ARG="" GOT_OPTS=() - while [[ $# > 0 || -n "$ARG" ]]; do + while [[ $# -gt 0 || -n "$ARG" ]]; do if [[ -z "$ARG" ]]; then if [[ "$1" == "--" ]]; then GOT_OPTS+=("$@"); break; fi ARG="$1"; shift; fi case $ARG in - -q*|--quite ) export DFS_QUIET=1 ;; + -q*|--quiet ) export DFS_QUIET=1 ;; -l*|--lite ) export DFS_LITE=1 ;; -d*|--dev ) export DFS_DEV=1; set -x ;; -D*|--dry-run ) export DFS_DRY_RUN=1 ;; @@ -41,15 +41,9 @@ fi # Color settings # Source: https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh -if [[ -t 1 || "$DFS_COLOR" == "1" ]]; then - is_tty() { - true - } -else - is_tty() { - false - } -fi +is_tty() { + [[ -t 1 || "$DFS_COLOR" == "1" ]] +} supports_truecolor() { case "$COLORTERM" in diff --git a/tools/frigg-client.sh b/tools/frigg-client.sh index f717f6b..713ebac 100755 --- a/tools/frigg-client.sh +++ b/tools/frigg-client.sh @@ -99,7 +99,7 @@ update_dns() elif [[ "$DFS_DDNS_IP4" == "http"* ]]; then ip4=$(curl $DFS_CURL_OPTIONS -sSL "$DFS_DDNS_IP4") else - ip4=$(ip a show $DFS_DDNS_IP4 | grep inet | grep global | awk '/inet / {print $2}' | awk -F'[/]' '{print $1}') + ip4=$(ip -4 --brief addr show $DFS_DDNS_IP4 scope global primary | awk '{split($3,a,"/");print a[1]}' | head -n 1) fi if [[ -n "$DFS_DDNS_IP4" && -z "$ip4" ]]; then fmt_fatal "failed getting ip4 address" @@ -115,7 +115,7 @@ update_dns() elif [[ "$DFS_DDNS_IP6" == "http"* ]]; then ip6=$(curl $DFS_CURL_OPTIONS -sSL "$DFS_DDNS_IP6") else - ip6=$(ip a show $DFS_DDNS_IP6 | grep inet6 | grep global | awk '/inet6 / {print $2}' | awk -F'[/]' '{print $1}') + ip6=$(ip -6 --brief addr show $DFS_DDNS_IP6 scope global primary | awk '{split($3,a,"/");print a[1]}' | head -n 1) fi if [[ -n "$DFS_DDNS_IP6" && -z "$ip6" ]]; then fmt_fatal "failed getting ip6 address" diff --git a/tools/sagent-op.sh b/tools/sagent-op.sh index 8fd8c5c..d1c5e21 100755 --- a/tools/sagent-op.sh +++ b/tools/sagent-op.sh @@ -2,8 +2,8 @@ set -e op=$(command -v op || command -v op.exe || true) -if [[ ! -x $op ]]; then - echo "1password cli not found" > /dev/stderr - exit -1 +if [[ -z "$op" || ! -x "$op" ]]; then + echo "1Password CLI not found" >&2 + exit 1 fi -"$op" read "op://Personal/id25519-passphrase/$(hostname)" +exec "$op" read "op://Personal/id25519-passphrase/$(hostname)" diff --git a/tools/sagent.sh b/tools/sagent.sh index 409a9ac..5e08a72 100755 --- a/tools/sagent.sh +++ b/tools/sagent.sh @@ -20,6 +20,7 @@ find_so_file() return fi done + return 1 } create_agent() @@ -30,27 +31,50 @@ create_agent() kill_agent() { - if pgrep -x ssh-agent > /dev/null; then - fmt_note "killing existing agent" - pkill -9 -x ssh-agent + local status + if pgrep -u "$EUID" -x ssh-agent > /dev/null; then + fmt_note "stopping existing ssh-agent" + if pkill -TERM -u "$EUID" -x ssh-agent; then + : + else + status=$? + [[ $status -eq 1 ]] || return "$status" + fi fi + if command -v gpgconf > /dev/null 2>&1; then + fmt_note "stopping gpg-agent if running" + gpgconf --kill gpg-agent + fi + unset SSH_AUTH_SOCK SSH_AGENT_PID + echo unset SSH_AUTH_SOCK SSH_AGENT_PID } add_piv() { - local SO_FILE=$(find_so_file) - if [[ -n "$SO_FILE" ]]; then - echo ssh-add -s \"$SO_FILE\" - else + local SO_FILE + if ! SO_FILE=$(find_so_file); then fmt_error "opensc-pkcs11.so not found" + return 1 fi + printf 'ssh-add -s %q\n' "$SO_FILE" list } add_id25519_with_op() { - SSH_ASKPASS_REQUIRE=force SSH_ASKPASS="$THIS_DIR/sagent-op.sh" timeout 60s ssh-add ~/.ssh/id_ed25519 || fmt_fatal "timed out when adding the key. probably the passphrase is wrong or 1password-cli is not working" - list + local status + if SSH_ASKPASS_REQUIRE=force SSH_ASKPASS="$THIS_DIR/sagent-op.sh" timeout 60s ssh-add "$HOME/.ssh/id_ed25519"; then + list + return + else + status=$? + fi + + if [[ $status -eq 124 ]]; then + fmt_fatal "timed out when adding the key" + else + fmt_fatal "failed to add the key (ssh-add exit $status); check the key, agent, and 1Password CLI" + fi } list() @@ -59,30 +83,220 @@ list() echo ssh-add -l } +configured_pinentry() +{ + local agent_conf + local agent_confs=() + for agent_conf in "$@"; do + [[ -f "$agent_conf" ]] && agent_confs+=("$agent_conf") + done + [[ ${#agent_confs[@]} -gt 0 ]] || return 1 + + awk ' + /^[[:space:]]*#/ { next } + { + line = $0 + sub(/^[[:space:]]*/, "", line) + if (line ~ /^pinentry-program([[:space:]]|=)/) { + sub(/^pinentry-program[[:space:]=]*/, "", line) + sub(/[[:space:]]*$/, "", line) + pinentry = line + } + } + END { + if (pinentry == "") exit 1 + print pinentry + } + ' "${agent_confs[@]}" +} + +check_pinentry() +{ + local gnupg_home + local gpg_sysconfdir + local agent_conf + local system_agent_conf + local pinentry + local gpg_bindir + local candidate + + gnupg_home=$(gpgconf --list-dirs homedir) + gpg_sysconfdir=$(gpgconf --list-dirs sysconfdir) + agent_conf="$gnupg_home/gpg-agent.conf" + system_agent_conf="$gpg_sysconfdir/gpg-agent.conf" + if pinentry=$(configured_pinentry "$system_agent_conf" "$agent_conf"); then + if [[ "$pinentry" == "~/"* ]]; then + pinentry="$HOME/${pinentry#\~/}" + fi + if [[ -x "$pinentry" ]]; then + return + fi + fmt_warning "configured pinentry is not executable: $pinentry" + else + gpg_bindir=$(gpgconf --list-dirs bindir) + if [[ -x "$gpg_bindir/pinentry" || -x "$gpg_bindir/pinentry-basic" ]]; then + return + fi + fi + + for candidate in \ + "$(command -v pinentry-curses 2>/dev/null || true)" \ + "$(command -v pinentry 2>/dev/null || true)" \ + "$(command -v pinentry-tty 2>/dev/null || true)"; do + [[ -n "$candidate" && -x "$candidate" ]] && break + candidate="" + done + + if [[ -n "$candidate" ]]; then + fmt_warning "gpg-agent has no usable pinentry; add 'pinentry-program $candidate' to $agent_conf" + else + fmt_warning "gpg-agent has no usable pinentry; install one and configure pinentry-program in $agent_conf" + fi +} + +prepare_gpg_agent() +{ + command -v gpgconf > /dev/null 2>&1 || fmt_fatal "gpgconf not found" + command -v gpg-connect-agent > /dev/null 2>&1 || fmt_fatal "gpg-connect-agent not found" + + check_pinentry + + local current_tty + current_tty=$(tty) || fmt_fatal "unable to determine the current TTY" + export GPG_TTY="$current_tty" + + gpgconf --launch gpg-agent + gpg-connect-agent updatestartuptty /bye > /dev/null +} + +use_gpg_agent() +{ + prepare_gpg_agent + + local agent_socket + agent_socket=$(gpgconf --list-dirs agent-ssh-socket) + if [[ -z "$agent_socket" || ! -S "$agent_socket" ]]; then + fmt_fatal "gpg-agent SSH socket not found; add 'enable-ssh-support' to ~/.gnupg/gpg-agent.conf and restart gpg-agent" + fi + + fmt_note "using gpg-agent: $agent_socket" + echo unset SSH_AGENT_PID + printf 'export GPG_TTY=%q\n' "$GPG_TTY" + printf 'export SSH_AUTH_SOCK=%q\n' "$agent_socket" +} + +cache_gpg_pin() +{ + [[ $# -le 1 ]] || fmt_fatal "usage: sagt gpg-pin [KEY]" + command -v gpg > /dev/null 2>&1 || fmt_fatal "gpg not found" + prepare_gpg_agent + + local signing_key="${1:-}" + local temp_dir + local signature_file + local status=0 + local gpg_args=(--detach-sign) + + temp_dir=$(mktemp -d "${TMPDIR:-/tmp}/sagent-gpg-pin.XXXXXXXXXX") || \ + fmt_fatal "failed to create a temporary directory" + signature_file="$temp_dir/signature.gpg" + gpg_args+=(--output "$signature_file") + [[ -z "$signing_key" ]] || gpg_args+=(--local-user "$signing_key") + + fmt_note "performing a test signature; enter the GPG PIN and touch the token if prompted" + printf 'sagt gpg-pin\n' | gpg "${gpg_args[@]}" || status=$? + + rm -f -- "$signature_file" + rmdir -- "$temp_dir" + [[ $status -eq 0 ]] || fmt_fatal "test signature failed (gpg exit $status)" + fmt_note "test signature completed; the GPG PIN should remain cached until the card or agent session is reset" +} + +read_agent_file() +{ + local agent_file="$1" + local line + local agent_socket="" + local agent_pid="" + + while IFS= read -r line; do + case "$line" in + SSH_AUTH_SOCK=*) + agent_socket=${line#SSH_AUTH_SOCK=} + agent_socket=${agent_socket%%;*} + ;; + SSH_AGENT_PID=*) + agent_pid=${line#SSH_AGENT_PID=} + agent_pid=${agent_pid%%;*} + ;; + esac + done < "$agent_file" + + [[ -n "$agent_socket" && "$agent_pid" =~ ^[1-9][0-9]*$ ]] || return 1 + export SSH_AUTH_SOCK="$agent_socket" + export SSH_AGENT_PID="$agent_pid" +} + +agent_is_usable() +{ + [[ -S "$SSH_AUTH_SOCK" ]] || return 1 + ps -p "$SSH_AGENT_PID" -o uid= -o comm= 2>/dev/null | + awk -v uid="$EUID" '$1 == uid && $2 ~ /(^|\/)ssh-agent$/ { found=1 } END { exit !found }' || return 1 + + local status + if ssh-add -l > /dev/null 2>&1; then + status=0 + else + status=$? + fi + [[ $status -eq 0 || $status -eq 1 ]] +} + +print_agent_env() +{ + printf 'export SSH_AUTH_SOCK=%q\n' "$SSH_AUTH_SOCK" + printf 'export SSH_AGENT_PID=%q\n' "$SSH_AGENT_PID" +} + reset() { kill_agent - all + all already-killed } all() { - test -d ~/.ssh || mkdir ~/.ssh - local agent_file=~/.ssh/agent-$(whoami) - if [[ -f $agent_file ]]; then - source $agent_file > /dev/null + local mode="${1:-}" + mkdir -p "$HOME/.ssh" + local agent_file="$HOME/.ssh/agent-$(whoami)" + [[ ! -L "$agent_file" ]] || fmt_fatal "refusing to use symlink as agent file: $agent_file" + unset SSH_AUTH_SOCK SSH_AGENT_PID + + if [[ "$mode" != "already-killed" && -f "$agent_file" ]]; then + chmod 600 "$agent_file" + read_agent_file "$agent_file" || true else - touch $agent_file - chmod 600 $agent_file + touch "$agent_file" + chmod 600 "$agent_file" fi - if ! ps -p "$SSH_AGENT_PID" 1>/dev/null 2>&1; then - kill_agent + + if ! agent_is_usable; then + if [[ "$mode" != "already-killed" ]]; then + kill_agent + fi fmt_note "launching a new agent" - create_agent | tee $agent_file + local agent_output + if ! agent_output=$(create_agent); then + fmt_fatal "failed to launch ssh-agent" + fi + printf '%s\n' "$agent_output" > "$agent_file" + chmod 600 "$agent_file" + read_agent_file "$agent_file" || fmt_fatal "ssh-agent returned invalid environment data" + agent_is_usable || fmt_fatal "new ssh-agent is not usable" else fmt_note "using existing agent: $SSH_AGENT_PID" - cat $agent_file fi + print_agent_env } route() @@ -105,6 +319,12 @@ route() op) add_id25519_with_op ;; + gpg) + use_gpg_agent + ;; + gpg-pin) + cache_gpg_pin "${@:2}" + ;; reset) reset ;; @@ -113,6 +333,7 @@ route() ;; *) fmt_error "unknown command: $1" + return 1 ;; esac } diff --git a/tools/test-getopts.sh b/tools/test-getopts.sh index a6e1956..b4e2b14 100755 --- a/tools/test-getopts.sh +++ b/tools/test-getopts.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -ex -OPTS='-a -bcl --color --arg1=1 --arg2 2 " 1 2" yes' +OPTS='-a -bcl --color --quiet --arg1=1 --arg2 2 " 1 2" yes' TARGET_OPTS='-a -b -c --arg1 1 --arg2 2 1 2 yes' eval set -- $OPTS @@ -8,14 +8,14 @@ THIS_DIR=$( cd "$( dirname "${BASH_SOURCE[0]:-${(%):-%x}}" )" && pwd ) source "$THIS_DIR/common.sh" test "${GOT_OPTS[*]}" = "$TARGET_OPTS" -test $# -eq 8 +test $# -eq 9 test "$*" = "${OPTS//\"/}" test "$DFS_LITE" = "1" is_tty -test -z "$DFS_QUIET" +test "$DFS_QUIET" = "1" set +x echo "test passed, args:" for i in "${GOT_OPTS[@]}"; do echo "$i" -done \ No newline at end of file +done diff --git a/tools/test-riot-gpg.sh b/tools/test-riot-gpg.sh new file mode 100755 index 0000000..154df6b --- /dev/null +++ b/tools/test-riot-gpg.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +THIS_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +RIOT="$THIS_DIR/../scripts/riot" +TEST_DIR=$(mktemp -d /tmp/riot-gpg.XXXXXX) +trap 'rm -rf "$TEST_DIR"' EXIT + +MOCK_BIN="$TEST_DIR/bin" +MOCK_HOME="$TEST_DIR/home" +MOCK_LOCAL_SOCKET="$TEST_DIR/local/S.gpg-agent.extra" +MOCK_REMOTE_SOCKET="$TEST_DIR/remote/S.gpg-agent" +MOCK_GPG_LOG="$TEST_DIR/gpg.log" +MOCK_SSH_LOG="$TEST_DIR/ssh.log" +mkdir -p "$MOCK_BIN" "$MOCK_HOME" "${MOCK_LOCAL_SOCKET%/*}" "${MOCK_REMOTE_SOCKET%/*}" +export MOCK_LOCAL_SOCKET MOCK_REMOTE_SOCKET MOCK_GPG_LOG MOCK_SSH_LOG + +make_stale_socket() { + rm -f "$1" + python3 - "$1" <<'PY' +import socket +import sys + +sock = socket.socket(socket.AF_UNIX) +sock.bind(sys.argv[1]) +sock.close() +PY +} + +cat > "$MOCK_BIN/gpgconf" <<'EOF' +#!/usr/bin/env bash +printf 'gpgconf %s\n' "$*" >> "$MOCK_GPG_LOG" +case "$*" in + '--list-dirs agent-extra-socket') printf '%s\n' "$MOCK_LOCAL_SOCKET" ;; + '--list-dirs agent-socket') printf '%s\n' "$MOCK_REMOTE_SOCKET" ;; + '--kill gpg-agent') exit "${MOCK_KILL_STATUS:-0}" ;; + *) exit 1 ;; +esac +EOF + +cat > "$MOCK_BIN/gpg-connect-agent" <<'EOF' +#!/usr/bin/env bash +printf 'gpg-connect-agent %s\n' "$*" >> "$MOCK_GPG_LOG" +case "$*" in + *'GETINFO pid'*) printf 'D 123\nOK\n' ;; + *'GETINFO version'*) + [[ "${MOCK_LOCAL_LIVE:-1}" == 1 ]] || exit 1 + printf 'D 2.4.0\nOK\n' + ;; + *'GETINFO restricted'*) printf 'D %s\nOK\n' "${MOCK_REMOTE_RESTRICTED:-0}" ;; + *) exit 1 ;; +esac +EOF + +cat > "$MOCK_BIN/systemctl" <<'EOF' +#!/usr/bin/env bash +[[ "${MOCK_SYSTEMD_ACTIVE:-0}" == 1 ]] +EOF + +cat > "$MOCK_BIN/ssh" <<'EOF' +#!/usr/bin/env bash +{ + printf 'CALL\n' + printf 'ARG=%s\n' "$@" +} >> "$MOCK_SSH_LOG" + +is_probe=0 +last_arg='' +for arg in "$@"; do + [[ "$arg" == '-T' ]] && is_probe=1 + last_arg=$arg +done +if [[ "$is_probe" == 1 ]]; then + sh -c "$last_arg" +fi +EOF + +chmod +x "$MOCK_BIN/gpgconf" "$MOCK_BIN/gpg-connect-agent" "$MOCK_BIN/systemctl" "$MOCK_BIN/ssh" + +run_riot() { + HOME="$MOCK_HOME" RIOT_TRUST_CLIENT=0 PATH="$MOCK_BIN:$PATH" "$RIOT" "$@" +} + +expect_failure() { + local expected=$1 + shift + if run_riot "$@" > "$TEST_DIR/out" 2> "$TEST_DIR/err"; then + echo "expected riot to fail: $*" >&2 + exit 1 + fi + grep -Fq "$expected" "$TEST_DIR/err" +} + +# Dry-run must not inspect or mutate either host. +: > "$MOCK_GPG_LOG" +: > "$MOCK_SSH_LOG" +DFS_DRY_RUN=1 run_riot -g example.test > "$TEST_DIR/out" 2> "$TEST_DIR/err" +grep -Fq ':' "$TEST_DIR/out" +[[ ! -s "$MOCK_GPG_LOG" && ! -s "$MOCK_SSH_LOG" ]] + +# A socket inode without a listener must be rejected locally. +make_stale_socket "$MOCK_LOCAL_SOCKET" +MOCK_LOCAL_LIVE=0 expect_failure 'extra socket exists but is not accepting connections' -g example.test + +# -g is valid only for an interactive SSH login. +expect_failure 'only supported for interactive SSH login' -g example.test scp ./a ./b +expect_failure 'only supported for interactive SSH login' -g example.test ssh -- true + +# Never remove a regular file merely because it has the expected basename. +MOCK_LOCAL_LIVE=1 +printf 'keep me\n' > "$MOCK_REMOTE_SOCKET" +expect_failure 'refusing to remove the non-socket remote gpg-agent path' -g example.test +grep -Fqx 'keep me' "$MOCK_REMOTE_SOCKET" + +# A restricted agent at the remote socket represents another forwarding session. +make_stale_socket "$MOCK_REMOTE_SOCKET" +MOCK_REMOTE_RESTRICTED=1 expect_failure 'another forwarded gpg-agent is already using the remote socket' -g example.test + +# A normal remote agent can be cleaned up; systemd activation is reported. +make_stale_socket "$MOCK_REMOTE_SOCKET" +: > "$MOCK_SSH_LOG" +MOCK_REMOTE_RESTRICTED=0 MOCK_SYSTEMD_ACTIVE=1 run_riot -g example.test > "$TEST_DIR/out" 2> "$TEST_DIR/err" +grep -Fq 'remote gpg-agent.socket is active' "$TEST_DIR/err" +grep -Fq 'ARG=none' "$MOCK_SSH_LOG" +grep -Fq 'ARG=ClearAllForwardings=yes' "$MOCK_SSH_LOG" +grep -Fq 'ARG=-T' "$MOCK_SSH_LOG" +! grep -Fq 'ARG=RequestTTY=' "$MOCK_SSH_LOG" +grep -Fq 'ARG=StreamLocalBindUnlink=no' "$MOCK_SSH_LOG" +grep -Fq "ARG=$MOCK_REMOTE_SOCKET:$MOCK_LOCAL_SOCKET" "$MOCK_SSH_LOG" +[[ ! -e "$MOCK_REMOTE_SOCKET" ]] + +echo 'riot gpg forwarding tests passed' diff --git a/tools/test-sagent-gpg-pin.sh b/tools/test-sagent-gpg-pin.sh new file mode 100755 index 0000000..4c54d5c --- /dev/null +++ b/tools/test-sagent-gpg-pin.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +THIS_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +SAGENT="$THIS_DIR/sagent.sh" +TEST_DIR=$(mktemp -d /tmp/sagent-gpg-pin.XXXXXX) +trap 'rm -rf "$TEST_DIR"' EXIT + +MOCK_BIN="$TEST_DIR/bin" +MOCK_HOME="$TEST_DIR/home" +MOCK_TMP="$TEST_DIR/tmp" +MOCK_GPG_ARGS="$TEST_DIR/gpg-args" +MOCK_GPG_INPUT="$TEST_DIR/gpg-input" +MOCK_AGENT_LOG="$TEST_DIR/agent-log" +mkdir -p "$MOCK_BIN" "$MOCK_HOME/gnupg" "$MOCK_HOME/sysconf" "$MOCK_TMP" +export MOCK_BIN MOCK_HOME MOCK_GPG_ARGS MOCK_GPG_INPUT MOCK_AGENT_LOG + +cat > "$MOCK_BIN/gpgconf" <<'EOF' +#!/usr/bin/env bash +printf 'gpgconf %s\n' "$*" >> "$MOCK_AGENT_LOG" +case "$*" in + '--list-dirs homedir') printf '%s\n' "$MOCK_HOME/gnupg" ;; + '--list-dirs sysconfdir') printf '%s\n' "$MOCK_HOME/sysconf" ;; + '--list-dirs bindir') printf '%s\n' "$MOCK_BIN" ;; + '--launch gpg-agent') ;; + *) exit 1 ;; +esac +EOF + +cat > "$MOCK_BIN/gpg-connect-agent" <<'EOF' +#!/usr/bin/env bash +printf 'gpg-connect-agent %s\n' "$*" >> "$MOCK_AGENT_LOG" +EOF + +cat > "$MOCK_BIN/tty" <<'EOF' +#!/usr/bin/env bash +printf '/dev/pts/mock\n' +EOF + +cat > "$MOCK_BIN/gpg" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$@" > "$MOCK_GPG_ARGS" +output='' +while [[ $# -gt 0 ]]; do + if [[ "$1" == '--output' ]]; then + output=$2 + shift 2 + else + shift + fi +done +cat > "$MOCK_GPG_INPUT" +printf 'mock signature\n' > "$output" +EOF + +printf '#!/usr/bin/env bash\n' > "$MOCK_BIN/pinentry" +chmod +x "$MOCK_BIN/gpgconf" "$MOCK_BIN/gpg-connect-agent" "$MOCK_BIN/tty" \ + "$MOCK_BIN/gpg" "$MOCK_BIN/pinentry" + +output=$(HOME="$MOCK_HOME" TMPDIR="$MOCK_TMP" PATH="$MOCK_BIN:$PATH" "$SAGENT" gpg-pin TEST-KEY 2> "$TEST_DIR/stderr") +[[ -z "$output" ]] +grep -Fxq -- '--detach-sign' "$MOCK_GPG_ARGS" +grep -Fxq -- '--local-user' "$MOCK_GPG_ARGS" +grep -Fxq -- 'TEST-KEY' "$MOCK_GPG_ARGS" +grep -Fxq -- 'sagt gpg-pin' "$MOCK_GPG_INPUT" +grep -Fq -- 'gpgconf --launch gpg-agent' "$MOCK_AGENT_LOG" +grep -Fq -- 'gpg-connect-agent updatestartuptty /bye' "$MOCK_AGENT_LOG" +grep -Fq -- 'test signature completed' "$TEST_DIR/stderr" + +output=$(HOME="$MOCK_HOME" TMPDIR="$MOCK_TMP" PATH="$MOCK_BIN:$PATH" "$SAGENT" gpg-pin 2> "$TEST_DIR/stderr") +[[ -z "$output" ]] +! grep -Fxq -- '--local-user' "$MOCK_GPG_ARGS" +[[ -z $(find "$MOCK_TMP" -mindepth 1 -print -quit) ]] + +echo "sagent gpg-pin tests passed" diff --git a/tools/test.zsh b/tools/test.zsh index c555776..63ea27b 100644 --- a/tools/test.zsh +++ b/tools/test.zsh @@ -34,15 +34,17 @@ dogo doll dfs cd tools/test-getopts.sh +tools/test-riot-gpg.sh +tools/test-sagent-gpg-pin.sh tools/common.sh get_os_name test $(echo y | tools/common.sh ask_for_yN "test") = "1" test $(echo n | tools/common.sh ask_for_yN "test") = "0" test $(echo | tools/common.sh ask_for_yN "test") = "0" test $(echo | tools/common.sh ask_for_Yn "test") = "1" test $(DFS_QUIET=1 tools/common.sh ask_for_Yn "test") = "1" -test "$(DFS_TRUST=1 riot time@is.impt:2222/yes@you-r.right/you@are.really.recht./ibd./try@it,another@host scp /tmp/ ./tmp -D 2>/dev/null)" = 'scp -P 12022 -o RequestTTY=yes -o PermitLocalCommand=yes -o ControlMaster=auto -o ControlPath=~/.ssh/master-socket/%C -o ProxyJump=time@is.impt:2222,yes@you-r.right:12022,you@are.really.recht:12022,root@ibd:12022 -r try@it.dxng.net:/tmp/ ./tmp -scp -P 12022 -o RequestTTY=yes -o PermitLocalCommand=yes -o ControlMaster=auto -o ControlPath=~/.ssh/master-socket/%C -o ForwardX11=yes -o ForwardAgent=yes -r another@host.dxng.net:/tmp/ ./tmp' -test "$(riot you@example.com:55 -tD ssh --password -- ping -c 1 2>/dev/null)" = 'ssh -p 55 -o RequestTTY=yes -o ForwardX11=yes -o ForwardAgent=yes -o PasswordAuthentication=yes -o PubkeyAuthentication=no you@example.com ping -c 1' +test "$(DFS_TRUST=1 riot time@is.impt:2222/yes@you-r.right/you@are.really.recht./ibd./try@it,another@host -D scp /tmp/ ./tmp 2>/dev/null)" = 'scp -P 12022 -o ServerAliveInterval=60 -o PermitLocalCommand=yes -o ControlMaster=auto -o ControlPersist=5s -o ControlPath=~/.ssh/master-socket/%C -o ProxyJump=time@is.impt:2222,yes@you-r.right:12022,you@are.really.recht:12022,root@ibd:12022 -r try@it.dxng.net:/tmp/ ./tmp +scp -P 12022 -o ServerAliveInterval=60 -o PermitLocalCommand=yes -o ControlMaster=auto -o ControlPersist=5s -o ControlPath=~/.ssh/master-socket/%C -o ForwardX11=yes -o ForwardAgent=yes -r another@host.dxng.net:/tmp/ ./tmp' +test "$(riot you@example.com:55 -tD --password ssh ping -c 1 2>/dev/null)" = 'ssh -p 55 -o ServerAliveInterval=60 -o ForwardX11=yes -o ForwardAgent=yes -o PasswordAuthentication=yes -o PubkeyAuthentication=no you@example.com ping -c 1' # check alias alias p114