Skip to content

Instantly share code, notes, and snippets.

@kwmiebach
Last active April 5, 2026 11:45
Show Gist options
  • Select an option

  • Save kwmiebach/ecb0b0b3e1ee485168daa6adcd510bd2 to your computer and use it in GitHub Desktop.

Select an option

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
#!/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