Last active
April 5, 2026 11:45
-
-
Save kwmiebach/ecb0b0b3e1ee485168daa6adcd510bd2 to your computer and use it in GitHub Desktop.
Add public keys for a user to ~/.ssh/authorized_keys - previously addkeys bash script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env perl | |
| use strict; | |
| use warnings; | |
| # This file is now tracked as part of the servertools repository | |
| # Only use traditional core Perl — no CPAN or external dependencies. | |
| # Compatible with old Perl versions — do not use features or depend | |
| # on anything that needs a Perl version newer than 5.10.x | |
| # | |
| # See __END__ section at the bottom of this file for full documentation. | |
| use Getopt::Long; | |
| use File::Basename qw(dirname); | |
| use File::Copy qw(copy); | |
| use POSIX qw(strftime); | |
| my $MARKER_TAG = 'GITKEYS'; | |
| my $GITHUB_URL = 'https://github.com'; | |
| # All SSH key types recognized in authorized_keys | |
| my $KEY_TYPE_RE = qr{ | |
| ssh-rsa | |
| | ssh-dss | |
| | ssh-ed25519 | |
| | ecdsa-sha2-nistp(?:256|384|521) | |
| | sk-ssh-ed25519\@openssh\.com | |
| | sk-ecdsa-sha2-nistp256\@openssh\.com | |
| }x; | |
| # --- Functions --- | |
| sub usage { | |
| my ($exit_code) = @_; | |
| $exit_code = 1 if !defined $exit_code; | |
| my $fh = ($exit_code == 0) ? \*STDOUT : \*STDERR; | |
| print $fh <<'USAGE'; | |
| Usage: gitkeys.pl [options] <github-username> | |
| Merge a GitHub user's SSH public keys into an authorized_keys file. | |
| Keys are placed in a managed section marked with GITKEYS:<username> markers. | |
| Duplicate keys found in the unmanaged part of the file are removed. | |
| Options: | |
| -h, --help Show this help message | |
| -n, --dry-run Show what would change, write preview to /tmp | |
| -u <user> Target user by name or uid (resolves home dir and ownership) | |
| -f, --file <path> Target authorized_keys file (default: ~/.ssh/authorized_keys) | |
| USAGE | |
| exit $exit_code; | |
| } | |
| # Parse an authorized_keys line to extract key identity. | |
| # Returns ($key_type, $base64, $has_options) or empty list if not a key line. | |
| sub parse_key_line { | |
| my ($line) = @_; | |
| # Not a key line if empty or a comment | |
| return () if $line =~ /^\s*$/; | |
| return () if $line =~ /^\s*#/; | |
| # Find the key type and base64 blob anywhere in the line. | |
| # If key type is not at the start, the line has options (command=, from=, etc.) | |
| if ($line =~ /(?:^|\s)($KEY_TYPE_RE)\s+(\S+)/) { | |
| my $key_type = $1; | |
| my $base64 = $2; | |
| my $has_options = ($line !~ /^\s*(?:$KEY_TYPE_RE)\s/); | |
| return ($key_type, $base64, $has_options); | |
| } | |
| return (); | |
| } | |
| # Fetch public keys for a GitHub user via curl. Dies on failure. | |
| sub fetch_github_keys { | |
| my ($username) = @_; | |
| die "GitHub username must not be empty\n" if $username eq ''; | |
| die "Invalid GitHub username '$username' — only alphanumeric and hyphens allowed\n" | |
| if $username !~ /^[a-zA-Z0-9][a-zA-Z0-9-]*$/; | |
| my $url = "$GITHUB_URL/$username.keys"; | |
| my $output = `curl -sf "$url"`; | |
| my $exit_code = $? >> 8; | |
| if ($exit_code != 0) { | |
| die "Failed to fetch keys from $url (curl exit code: $exit_code)\n"; | |
| } | |
| my @keys = grep { /\S/ } split(/\n/, $output); | |
| if (scalar @keys == 0) { | |
| die "No keys returned for GitHub user '$username' — aborting to be safe\n"; | |
| } | |
| return @keys; | |
| } | |
| # Resolve a user spec (name or numeric uid) via /etc/passwd. | |
| # Returns ($name, $uid, $gid, $home_dir). Dies if not found. | |
| sub resolve_user { | |
| my ($user_spec) = @_; | |
| die "User spec must not be empty\n" if $user_spec eq ''; | |
| my @pw; | |
| if ($user_spec =~ /^\d+$/) { | |
| @pw = getpwuid($user_spec); | |
| } | |
| else { | |
| @pw = getpwnam($user_spec); | |
| } | |
| if (!@pw) { | |
| die "User '$user_spec' not found in /etc/passwd\n"; | |
| } | |
| # (name, uid, gid, home_dir) | |
| return ($pw[0], $pw[2], $pw[3], $pw[7]); | |
| } | |
| # --- Main --- | |
| sub main { | |
| my $opt_dry_run = 0; | |
| my $opt_help = 0; | |
| my $opt_user; | |
| my $opt_file; | |
| GetOptions( | |
| 'h|help' => \$opt_help, | |
| 'n|dry-run' => \$opt_dry_run, | |
| 'u=s' => \$opt_user, | |
| 'f|file=s' => \$opt_file, | |
| ) or usage(); | |
| if ($opt_help) { | |
| usage(0); | |
| } | |
| if (scalar @ARGV != 1) { | |
| usage(); | |
| } | |
| my $github_user = $ARGV[0]; | |
| # --- Resolve target file and ownership --- | |
| my ($owner_name, $owner_uid, $owner_gid); | |
| my $target_file; | |
| if (defined $opt_user) { | |
| my $home_dir; | |
| ($owner_name, $owner_uid, $owner_gid, $home_dir) = resolve_user($opt_user); | |
| if (defined $opt_file) { | |
| $target_file = $opt_file; | |
| } | |
| else { | |
| $target_file = "$home_dir/.ssh/authorized_keys"; | |
| } | |
| } | |
| elsif (defined $opt_file) { | |
| $target_file = $opt_file; | |
| } | |
| else { | |
| my $home = $ENV{HOME}; | |
| if (!defined $home) { | |
| die "HOME not set — use -f or -u to specify target\n"; | |
| } | |
| $target_file = "$home/.ssh/authorized_keys"; | |
| } | |
| die "Target file path resolved to empty string\n" if $target_file eq ''; | |
| # --- Preflight: curl available --- | |
| system("curl --version >/dev/null 2>&1") == 0 | |
| or die "curl is not installed or not in PATH\n"; | |
| # --- Fetch keys from GitHub --- | |
| print "Fetching keys for $github_user from github.com...\n"; | |
| my @github_keys = fetch_github_keys($github_user); | |
| my $key_count = scalar @github_keys; | |
| # Build lookup of fetched key identities: "type base64" => 1 | |
| my %fetched_ids; | |
| for my $key_line (@github_keys) { | |
| my ($key_type, $base64) = parse_key_line($key_line); | |
| if (defined $key_type) { | |
| $fetched_ids{"$key_type $base64"} = 1; | |
| } | |
| } | |
| die "No valid key lines parsed from GitHub response\n" if scalar keys %fetched_ids == 0; | |
| # --- Preflight: directory, symlink, regular file, ownership --- | |
| my $target_dir = dirname($target_file); | |
| # Parent directory must exist | |
| if (!-d $target_dir) { | |
| # Only auto-create if -u was given without -f (meaning it's ~/.ssh/) | |
| my $can_create = 0; | |
| if (defined $opt_user) { | |
| if (!defined $opt_file) { | |
| $can_create = 1; | |
| } | |
| } | |
| if (!$can_create) { | |
| die "Directory $target_dir does not exist\n"; | |
| } | |
| mkdir $target_dir, 0700 | |
| or die "Cannot create directory $target_dir: $!\n"; | |
| chown $owner_uid, $owner_gid, $target_dir | |
| or die "Cannot set ownership on $target_dir: $!\n"; | |
| print "Created directory $target_dir\n"; | |
| } | |
| # Must not be a symlink | |
| if (-l $target_file) { | |
| die "$target_file is a symlink — aborting\n"; | |
| } | |
| # If it exists, must be a regular file | |
| if (-e $target_file) { | |
| if (!-f $target_file) { | |
| die "$target_file exists but is not a regular file — aborting\n"; | |
| } | |
| } | |
| # If -u and -f both given, verify the file belongs to that user | |
| if (defined $opt_user) { | |
| if (defined $opt_file) { | |
| if (-e $target_file) { | |
| my @stat = stat($target_file); | |
| if ($stat[4] != $owner_uid) { | |
| die "$target_file is owned by uid $stat[4], " | |
| . "not by $owner_name (uid $owner_uid) — aborting\n"; | |
| } | |
| } | |
| } | |
| } | |
| # --- Read existing file --- | |
| my @existing_lines; | |
| if (-f $target_file) { | |
| open my $fh, '<', $target_file | |
| or die "Cannot read $target_file: $!\n"; | |
| @existing_lines = <$fh>; | |
| close $fh; | |
| chomp @existing_lines; | |
| } | |
| # --- Pass 1: scan for conflicts (duplicate keys with options) --- | |
| my $scan_in_section = 0; | |
| my @conflicts; | |
| my $target_section_count = 0; | |
| for my $i (0 .. $#existing_lines) { | |
| my $line = $existing_lines[$i]; | |
| # Track managed sections — skip their contents | |
| if ($line =~ /^#\s*---\s*BEGIN\s+\Q$MARKER_TAG\E:(\S+)\s*---/) { | |
| $scan_in_section = 1; | |
| if ($1 eq $github_user) { | |
| $target_section_count++; | |
| } | |
| next; | |
| } | |
| if ($line =~ /^#\s*---\s*END\s+\Q$MARKER_TAG\E:\S+\s*---/) { | |
| $scan_in_section = 0; | |
| next; | |
| } | |
| next if $scan_in_section; | |
| # Check unmanaged key lines for duplicates with options | |
| my ($key_type, $base64, $has_options) = parse_key_line($line); | |
| next if !defined $key_type; | |
| my $key_id = "$key_type $base64"; | |
| if ($fetched_ids{$key_id}) { | |
| if ($has_options) { | |
| push @conflicts, { | |
| line_num => $i + 1, | |
| text => $line, | |
| }; | |
| } | |
| } | |
| } | |
| if (scalar @conflicts > 0) { | |
| print STDERR "ERROR: Duplicate keys with options found in unmanaged section:\n"; | |
| for my $c (@conflicts) { | |
| print STDERR " Line $c->{line_num}: $c->{text}\n"; | |
| } | |
| print STDERR "\nThese keys have options (command=, from=, etc.) that suggest\n"; | |
| print STDERR "deliberate restrictions. Please resolve manually before running gitkeys.\n"; | |
| exit 1; | |
| } | |
| if ($target_section_count > 1) { | |
| die "Malformed $target_file: found $target_section_count sections for " | |
| . "$MARKER_TAG:$github_user — please remove duplicates manually\n"; | |
| } | |
| # --- Pass 2: build new file content --- | |
| my @output_lines; | |
| my @summary; | |
| my $in_target_section = 0; | |
| my $in_other_section = 0; | |
| my $target_section_done = 0; | |
| my $duplicates_removed = 0; | |
| my $today = strftime("%Y-%m-%d", localtime); | |
| for my $i (0 .. $#existing_lines) { | |
| my $line = $existing_lines[$i]; | |
| # --- Section begin markers --- | |
| if ($line =~ /^#\s*---\s*BEGIN\s+\Q$MARKER_TAG\E:(\S+)\s*---/) { | |
| my $section_user = $1; | |
| if ($section_user eq $github_user) { | |
| $in_target_section = 1; | |
| next; # skip the old begin marker | |
| } | |
| $in_other_section = 1; | |
| push @output_lines, $line; | |
| next; | |
| } | |
| # --- Section end markers --- | |
| if ($line =~ /^#\s*---\s*END\s+\Q$MARKER_TAG\E:(\S+)\s*---/) { | |
| my $section_user = $1; | |
| if ($section_user eq $github_user) { | |
| $in_target_section = 0; | |
| # Insert the new section in place of the old one | |
| push @output_lines, "# --- BEGIN $MARKER_TAG:$github_user ---"; | |
| push @output_lines, @github_keys; | |
| push @output_lines, "# --- END $MARKER_TAG:$github_user ---"; | |
| $target_section_done = 1; | |
| push @summary, "Replaced existing section $MARKER_TAG:$github_user"; | |
| next; | |
| } | |
| $in_other_section = 0; | |
| push @output_lines, $line; | |
| next; | |
| } | |
| # Skip lines inside the target section (being replaced) | |
| if ($in_target_section) { | |
| next; | |
| } | |
| # Pass through lines inside other managed sections | |
| if ($in_other_section) { | |
| push @output_lines, $line; | |
| next; | |
| } | |
| # --- Unmanaged line: check for option-free duplicates --- | |
| my ($key_type, $base64) = parse_key_line($line); | |
| if (defined $key_type) { | |
| my $key_id = "$key_type $base64"; | |
| if ($fetched_ids{$key_id}) { | |
| push @output_lines, | |
| "# Removed by gitkeys (duplicate of $MARKER_TAG:$github_user) on $today:"; | |
| push @output_lines, "# $line"; | |
| $duplicates_removed++; | |
| push @summary, "Removed duplicate key from line " . ($i + 1); | |
| next; | |
| } | |
| } | |
| push @output_lines, $line; | |
| } | |
| # Malformed file: BEGIN without END for our user | |
| if ($in_target_section) { | |
| die "Malformed $target_file: BEGIN marker for $MARKER_TAG:$github_user " | |
| . "has no matching END marker\n"; | |
| } | |
| # Append new section if it didn't already exist | |
| if (!$target_section_done) { | |
| push @output_lines, "# --- BEGIN $MARKER_TAG:$github_user ---"; | |
| push @output_lines, @github_keys; | |
| push @output_lines, "# --- END $MARKER_TAG:$github_user ---"; | |
| push @summary, "Added new section $MARKER_TAG:$github_user"; | |
| } | |
| my $new_content = join("\n", @output_lines) . "\n"; | |
| # --- Dry-run: write preview to temp file --- | |
| if ($opt_dry_run) { | |
| my $preview_file = "/tmp/gitkeys_preview_$$"; | |
| open my $preview_fh, '>', $preview_file | |
| or die "Cannot write $preview_file: $!\n"; | |
| print $preview_fh $new_content; | |
| close $preview_fh; | |
| print "Fetched $key_count keys for $github_user from github.com\n"; | |
| for my $msg (@summary) { | |
| print "$msg\n"; | |
| } | |
| print "Preview written to $preview_file\n"; | |
| print "To inspect: cat $preview_file\n"; | |
| print "To diff: diff $target_file $preview_file\n"; | |
| exit 0; | |
| } | |
| # --- Backup and atomic write --- | |
| # Stat the existing file once — used for backup permissions and new file ownership | |
| my @target_stat; | |
| if (-f $target_file) { | |
| @target_stat = stat($target_file); | |
| } | |
| # Backup existing file with timestamp | |
| if (-f $target_file) { | |
| my $timestamp = strftime("%Y%m%d-%H%M%S", localtime); | |
| my $backup_file = "$target_file.$timestamp"; | |
| copy($target_file, $backup_file) | |
| or die "Cannot create backup $backup_file: $!\n"; | |
| chmod $target_stat[2] & 07777, $backup_file; | |
| chown $target_stat[4], $target_stat[5], $backup_file; | |
| push @summary, "Backup saved to $backup_file"; | |
| } | |
| # Write to temp file in the same directory (so rename is atomic) | |
| my $tmp_file = "$target_file.tmp.$$"; | |
| open my $out_fh, '>', $tmp_file | |
| or die "Cannot write $tmp_file: $!\n"; | |
| print $out_fh $new_content; | |
| close $out_fh | |
| or die "Cannot close $tmp_file: $!\n"; | |
| # Set permissions: copy from original, or 0600 for new files | |
| if (@target_stat) { | |
| chmod $target_stat[2] & 07777, $tmp_file | |
| or do { unlink $tmp_file; die "Cannot set permissions on $tmp_file: $!\n" }; | |
| if (!chown($target_stat[4], $target_stat[5], $tmp_file)) { | |
| unlink $tmp_file; | |
| die "Cannot set ownership on $tmp_file: $! — original file is unchanged\n"; | |
| } | |
| } | |
| else { | |
| chmod 0600, $tmp_file | |
| or do { unlink $tmp_file; die "Cannot set permissions on $tmp_file: $!\n" }; | |
| if (defined $owner_uid) { | |
| if (!chown($owner_uid, $owner_gid, $tmp_file)) { | |
| unlink $tmp_file; | |
| die "Cannot set ownership on $tmp_file: $! — no file was created\n"; | |
| } | |
| } | |
| } | |
| # Atomic rename | |
| rename $tmp_file, $target_file | |
| or do { unlink $tmp_file; die "Cannot rename $tmp_file to $target_file: $!\n" }; | |
| # --- Summary --- | |
| print "Fetched $key_count keys for $github_user from github.com\n"; | |
| for my $msg (@summary) { | |
| print "$msg\n"; | |
| } | |
| print "Updated $target_file\n"; | |
| } | |
| main(); | |
| __END__ | |
| =head1 NAME | |
| gitkeys.pl — merge a GitHub user's SSH public keys into an authorized_keys file | |
| =head1 SYNOPSIS | |
| gitkeys.pl [options] <github-username> | |
| Options: | |
| -h, --help Show help | |
| -n, --dry-run Write preview to /tmp, do not modify the target file | |
| -u <user> Target user by name or uid | |
| -f, --file <path> Target authorized_keys file | |
| =head1 HOW IT WORKS | |
| The script fetches all public SSH keys for a GitHub user from | |
| https://github.com/<username>.keys and merges them into an | |
| authorized_keys file in a managed section: | |
| # --- BEGIN GITKEYS:alice --- | |
| ssh-ed25519 AAAA... | |
| ssh-rsa AAAA... | |
| # --- END GITKEYS:alice --- | |
| Each run replaces the entire section for that user. Keys are never | |
| appended — the section is always rebuilt from the current GitHub state. | |
| Multiple users can each have their own section in the same file. | |
| Run the script once per user. | |
| =head2 Deduplication | |
| Before writing, the script scans the unmanaged part of the file | |
| (everything outside GITKEYS sections) for keys that match the fetched | |
| keys. Matching is done on key-type + base64 blob, ignoring comments. | |
| If a duplicate has no options, it is removed and replaced with a | |
| comment: | |
| # Removed by gitkeys (duplicate of GITKEYS:alice) on 2026-04-05 | |
| If a duplicate has options (command=, from=, etc.), the script aborts. | |
| These lines represent deliberate restrictions and must be resolved by | |
| the operator. The file is not modified. | |
| Other users' managed sections are not scanned for duplicates. | |
| =head2 File writing | |
| The script writes to a temp file in the same directory, then renames | |
| it over the target. This is atomic on POSIX filesystems — a crash | |
| during write cannot leave a half-written file. | |
| Before writing, a timestamped backup is created: | |
| ~/.ssh/authorized_keys.20260405-153012 | |
| Permissions and ownership are copied from the original file. If the | |
| file is new, permissions are set to 0600. | |
| =head2 The -u flag | |
| With -u, the script resolves the user's home directory and uid/gid | |
| from /etc/passwd (via getpwnam or getpwuid for numeric arguments). | |
| Without -f, it targets ~user/.ssh/authorized_keys and creates the | |
| .ssh directory (mode 0700) if it does not exist. | |
| With both -u and -f, the script uses the given file path but verifies | |
| that the file is owned by the specified user. It aborts on mismatch. | |
| =head2 Dry-run mode | |
| With -n/--dry-run, the script writes the preview to /tmp/gitkeys_preview_<pid> | |
| and prints a diff command. The target file is never modified. | |
| Note: with -u (without -f), if ~/.ssh/ does not exist, the directory | |
| IS created even in dry-run mode. This is a known gap — the preflight | |
| directory creation runs before the dry-run check. | |
| =head1 PITFALLS AND DANGERS | |
| =over 4 | |
| =item No concurrent run protection. | |
| Two simultaneous runs targeting the same file will race. The second | |
| rename overwrites the first. Run sequentially or use an external lock. | |
| =item GitHub outage = no key update, not key removal. | |
| If the fetch fails or returns zero keys, the script aborts without | |
| modifying the file. A GitHub outage cannot wipe access. However, if | |
| GitHub returns a partial key list (e.g., due to API issues), the | |
| script has no way to detect this — it will replace the section with | |
| whatever was returned. | |
| =item The backup can fill disk. | |
| Every non-dry-run invocation creates a backup. On a cron schedule, | |
| backups accumulate. The script does not rotate or clean old backups. | |
| =item Symlinks are rejected. | |
| If the target file is a symlink, the script aborts. This is | |
| intentional — following symlinks silently could write to unexpected | |
| locations. | |
| =item Malformed files are rejected. | |
| A BEGIN marker without a matching END marker, or duplicate sections | |
| for the same user, cause the script to abort. The operator must fix | |
| the file manually. | |
| =item The -u directory creation side-effect in dry-run. | |
| As noted above, -u creates ~/.ssh/ even during dry-run. This is the | |
| only side-effect of dry-run mode. | |
| =item No key expiry or rotation. | |
| The script syncs what GitHub has. It does not track when keys were | |
| added or flag stale keys. Key lifecycle management is the operator's | |
| responsibility. | |
| =item curl must be installed. | |
| The script shells out to curl and checks for it at startup. | |
| =back | |
| =head1 REQUIREMENTS | |
| Perl 5.10 or later (core modules only). curl. | |
| =cut |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment