個人開発で運用している Web サイトのリポジトリを、定期タスクで更新しています。ある日、そのタスクが最初の clone で止まりました。ログに残っていたのは、この 2 行だけです。
fatal: could not read Username for 'https://github.com': No such device or address
fatal: expected flush after ref listing
同じ日の午前に走った別のタスクでは、push まで通った記録が残っています。何が変わったのかは、今も特定できておりません。ただ、止まった理由そのものは切り分けられました。ここでは、その過程と、最終的に落ち着いた認証の渡し方を書き残します。
結論を先にお伝えします。トークンだけを URL に埋め込む書き方(https://トークン@github.com/…)は、この環境では通らず、ユーザー名を足した書き方(https://x-access-token:トークン@github.com/…)は通りました。 そして、通る方の書き方にも、設定ファイルにトークンが残るという別の問題があります。
何が起きていたのか
「Username を読めない」と言われても、URL にはトークンが入っています。メッセージが当たっていない気がして、git が実際に送った通信を見ることにしました。
# 通信の要点だけを取り出す(トークン本体は git 側でも伏せられます)
GIT_TRACE_CURL = 1 GIT_TRACE_CURL_NO_DATA = 1 \
git ls-remote "https://トークン@github.com/OWNER/REPO.git" HEAD 2>&1 \
| grep -E "Send header: (GET|authorization)|Recv header: HTTP|redirecting|fatal"
トークンだけを埋めた書き方では、次の順に通信していました。
=> GET /OWNER/REPO.git/info/refs?service=git-upload-pack
<= HTTP/2 401
=> GET /OWNER/REPO.git/info/refs?service=git-upload-pack
=> authorization: Basic <伏せ字>
<= HTTP/2 200
warning: redirecting to https://github.com/OWNER/REPO.git/
<= HTTP/2 401
fatal: could not read Username for 'https://github.com'
1 回目の 401 のあとで認証つきの再送が通り、200 が返っています。その直後に git が「redirecting」と警告し、3 回目の要求では authorization ヘッダーが付いていません。 3 回目が 401 になり、Username を尋ねる段階で端末がないために止まった、という流れです。
ユーザー名を足した書き方では、同じ警告が出ても、3 回目に authorization が付いて 200 が返りました。
なぜ前者だけ認証が落ちるのか、「redirecting」の警告がどこから来るのかは、まだ確かめておりません。URL を見ただけでは、リダイレクトを返したのが GitHub なのか、手前の経路なのかも分かりませんでした。確かめられたのは、書き方ひとつで、同じ警告のあとに認証が残るか落ちるかが分かれる という点です。使った git は 2.34.1 です。
6 つの渡し方を並べる
通る書き方を見つけて終わりにしてもよかったのですが、今度はトークンの置き場所が気になりました。手順書には git remote set-url origin https://トークン@github.com/… と書いてあり、これはトークンを .git/config に書き込みます。そこで、渡し方ごとに、通るか、どこに残るかを手元で確かめました。ダミーのトークンで、設定ファイルと実行中のプロセス一覧を見ています。
意外だったのは、よく見かけるトークンだけの書き方が、この表の中で唯一通らなかったことです。そして、通ってかつ何も残さないのは、いちばん書くのが面倒な 5 行目の渡し方だけ でした。
extraHeader の Base64 は、読み取れる形です。実際にプロセス一覧で見えた文字列を戻してみると、x-access-token: に続いてトークンがそのまま出てきます。暗号ではなく、書き換えにすぎないと考えるほうが安全です。
手順1: トークンを引数にも設定にも残さずに git を呼ぶ
私が選んだのは、GIT_ASKPASS にトークンをファイルから読ませる形です。一時ファイルに値を書き、終了時に必ず消します。
#!/bin/bash
# ghgit.sh — トークンを URL にも引数にも載せずに git を呼ぶ薄いラッパー
# 使い方: ghgit.sh "ラベル" <git のサブコマンドと引数...>
set -euo pipefail
LABEL = ${1 :? 使い方 : ghgit.sh "ラベル" <git の引数...> } ; shift
TOKENS = ${TOKENS_FILE :? TOKENS_FILE にトークン台帳のパスを入れてください}
tok = $( mktemp " $HOME /.ghtok.XXXXXX" ); chmod 600 " $tok "
ask = $( mktemp " $HOME /.ghask.XXXXXX" )
trap 'rm -f "$tok" "$ask"' EXIT
# 台帳は「ラベル行の次の行がトークン」の形式。値は標準出力に出さない
{ grep -A1 -F -- " $LABEL " " $TOKENS " || true ; } | tail -n 1 | tr -d "[:space:]" > " $tok "
[ -s " $tok " ] || { echo "ghgit: ラベル ' $LABEL ' のトークンが見つかりません" >&2 ; exit 2 ; }
cat > " $ask " << 'ASK'
#!/bin/sh
case "$1" in
Username*) echo x-access-token ;;
*) cat "$GHGIT_TOKEN_FILE" ;;
esac
ASK
chmod 700 " $ask "
GHGIT_TOKEN_FILE = " $tok " GIT_ASKPASS = " $ask " GIT_TERMINAL_PROMPT = 0 git " $@ "
使い方は、git の前に ghgit.sh "ラベル" を付けるだけです。URL は認証情報のないものを使います。
export TOKENS_FILE = " $HOME /secrets/tokens.txt" # 置き場所は各自の環境に合わせてください
./ghgit.sh "My Repo" clone --depth 1 --branch main https://github.com/OWNER/REPO.git work
./ghgit.sh "My Repo" -C work pull --rebase origin main
./ghgit.sh "My Repo" -C work push origin main
なぜこう書くのかを、行ごとにお伝えします。
GIT_ASKPASS はユーザー名とパスワードを別々に尋ねます。 渡された文言が Username で始まるかで出し分けるため、スクリプトは短くて済みます。GitHub のトークンでは、ユーザー名は x-access-token で通りました
トークンを環境変数に載せず、ファイルの場所だけを渡します。 環境変数に入れた値は、同じユーザーのほかのプロセスから読めます。ファイルなら、mktemp と chmod 600 で読める人を絞れ、trap で終了時に消せます
GIT_TERMINAL_PROMPT=0 を付けます。 無人の実行では、尋ねられても答える人がいません。尋ねる代わりに、すぐ失敗してくれるほうが助かります
作業用のリポジトリの remote は、認証情報のない URL のままです。 設定ファイルをのぞかれても、トークンは見つかりません
実際に確かめた結果です。clone、pull、push --dry-run が通りました(clone した先の remote は認証情報のない URL のままです)。実行中に ps で引数を見ると、git ls-remote https://… という URL だけが見え、トークンを含む行は 0 件でした。実行中の git の環境変数にも、トークンの文字列は入っていませんでした。
手順2: 台帳にラベルがないとき、黙って終わらないようにする
このラッパーは、最初の版では黙って終わりました。ラベルを間違えて呼ぶと、何も表示されず、終了コードが 1 になるだけです。
# 最初の版(問題あり)
grep -A1 -F -- " $LABEL " " $TOKENS " | tail -n 1 | tr -d '[:space:]' > " $tok "
[ -s " $tok " ] || { echo "ghgit: ..." >&2 ; exit 2 ; } # ここに届かない
set -e と pipefail を付けていると、grep が何も見つけなかった時点(終了コード 1)でパイプ全体が失敗し、スクリプトが終わってしまいます。見つからなかったときのために用意したメッセージの行まで、処理が進みません。定期タスクのログに「エラーなしで失敗」が残るので、あとから見たとき、いちばん困る形です。grep だけを波括弧で包んで || true を付け、判定を次の行の空ファイルの確認へ寄せると、メッセージと終了コード 2 が出るようになりました。
私は、「見つからないことも正常な結果のひとつ」として一度受け止めてから、自分の言葉で止めるようにしています。この順にしておくと、ログから原因を読み取れるのだと思います。終了前に成果を確かめる考え方は、終了前アサーション の記事にも書きました。
手順3: URL にトークンが残っているリポジトリを数える
新しい書き方に変えても、これまでに作ったリポジトリの .git/config には、古いトークンが残ったままです。変更する前に、いくつあるのかを読み取り専用で数えます。トークン本体は表示しません。
#!/bin/bash
# 指定したフォルダ直下の git リポジトリで、remote の URL に認証情報が埋め込まれているものを数える。
# 読み取り専用。URL の中身(トークン本体)は表示しない。
root = ${1 :? 使い方 : scan_remote_creds.sh <リポジトリを置いているフォルダ> }
total = 0 ; embedded = 0
for d in " $root "/*/ ; do
[ -d " $d /.git" ] || continue
total = $(( total + 1 ))
# remote を全部見る(origin だけとは限らない)
while read -r name ; do
url = $( git -C " $d " remote get-url " $name " 2> /dev/null ) || continue
case " $url " in
* :// *@* ) embedded = $(( embedded + 1 )); echo "埋め込みあり: ${ d %/ } ( $name )" ;;
esac
done < <( git -C " $d " remote)
done
echo "リポジトリ ${ total } 件中、認証情報つきの remote ${ embedded } 件"
ダミーの 2 リポジトリ(一方はトークンつき、もう一方は認証情報なし)で確かめると、次のように出ました。
埋め込みあり: /…/scan/r1 (origin)
リポジトリ 2 件中、認証情報つきの remote 1 件
見つかったものは、git remote set-url origin https://github.com/OWNER/REPO.git で認証情報のない URL へ戻せます。そのうえで、一度でも URL に書いたトークンは、作り直す ことをお勧めします。設定ファイルは、フォルダを同期しているサービスやバックアップにも写されている可能性があるからです。
落とし穴
「redirecting」の警告は、どの書き方でも出ました。 警告が出ること自体は、失敗の合図ではありません。同じ警告の後で認証が落ちたか残ったかを、通信の記録で見分けてください
ほかの git のバージョンでは確かめておりません。 使ったのは 2.34.1 です。新しい版では、URL に埋め込んだ認証情報の扱いが違う可能性があります
認証情報ヘルパーが設定された環境では、挙動が変わるかもしれません。 この環境にはヘルパーがなく、GIT_ASKPASS が使われました。Mac のキーチェーン連携など、すでにヘルパーがある環境では試しておりません
台帳の形式に依存しています。 「ラベルの次の行がトークン」という前提なので、ラベルが最終行にあると、ラベル行そのものを取り込んでしまいます。ラベルが複数の行に一致する場合も、最後の一致の次の行を使います
trap は強制終了では動きません。 確認の途中で、実行中の git を pkill で止めたとき、私の作業用シェルごと落ちて、ホームに .ghtok.* と .ghask.* が 4 ファイル残りました。中身は本物のトークンです。すぐ消しましたが、強制終了で一時ファイルが残る、という注意書きは実体験になりました。起動時に古い .ghtok.* を消す一行を足すと、さらに安全です
引数が見えるのは、同じマシンを共有している場合に限ります。 私自身の環境は 1 ユーザーなので、extraHeader でも実害は出にくい形でした。それでも、手順書に書く書き方は、いちばん残らないものに揃えるようにしています
線引き
通ったからといって、その書き方を採るとは限りません。 今回は、止まった原因を切り分けるところまでが「通す」作業で、そのあとに「何も残さない」作業が残っていました。
まずは手順3のスクリプトを、リポジトリを置いているフォルダで走らせてみていただければと思います。件数が 0 なら、それが一番よい結果です。1 件でも見つかったときは、URL を戻す前に、そのトークンを作り直すかどうかを決めてください。