Plan 9とGo言語のブログ

主にPlan 9やGo言語の日々気づいたことを書きます。

サプライチェーン攻撃に怯えないために設定した5つのこと

ここ数ヶ月ほどサプライチェーン攻撃の被害が続いています。手元では各種パッケージマネージャを使いますし、AIエージェントやプラグイン機構のあるアプリケーションを使っているので、攻撃の影響を受けないとは言い切れません。サプライチェーン攻撃に対して完全な対策はおそらく不可能と思われるため、被害を受けたとしても影響を小さくするための対策をいくつか手元環境に実施しました。方針としては以下の通りです。

  • 環境変数やファイルシステム上にむき出しの認証情報をなるべく残さない
  • 避けようがない場合は必ず暗号化を施して残す
  • 可能ならアプリケーション自体を隔離する
  • 多少不便になる程度なら不便を受け入れる
  • 我慢できないほど面倒ならリスクを飲む

GitHubでトークン認証をやめてSSH認証に切り替える

BlueskyでRuss Coxが言及していましたが、トークンが盗まれてしまえばそれだけで攻撃者に強い権限を与えてしまうことになります。

On the topic of tokens, it is very wrong that a project on GitHub or NPM can insist on 2FA for people logging in, but then those same systems allow using these short easily stolen strings as 1FA methods with equivalent power. Recent attacks demonstrate the significant lateral movement this enables.

Russ Cox (@swtch.com) 2026-04-06T13:45:59.823Z
bsky.app

上の発言はCI等で使っているトークンを指しているのだろうとは思うのだけれど、それはそれとして手元のトークンであっても同じものですし、トークン自体は暗号化されていないし、意外と簡単なコマンドで取り出せたりします。トークン認証を使っている人は以下のコマンドを実行してみてください*1

# ENDの前に空行が必要です
cat <<END | git credential fill
protocol=https
host=github.com
username=(お使いのGitHubユーザー名)

END

なのでトークンは絶対に盗まれてはいけないのだけど、現状ではサプライチェーン攻撃によって任意のコード実行が行われる可能性を否定できないため確実にトークンを守れる保証がありません。反面、SSH認証ではファイルシステム上に鍵が置かれることになるけれども、パスフレーズを設定してさえいれば暗号化した状態なので、盗まれても復号の手間があるSSH認証の方が安全と判断しました。以前はSSHのポートにアクセスできない環境でもトークン認証なら使える等がありましたけれど、今はHTTPSポートを通してSSH接続が可能になっているようです。

SSH鍵をTPMで保護する

最近のハードウェア事情ではTPMを使えることが多いです。SSH鍵にパスフレーズを設定しておけばある程度は安全だと思っていますが、TPMで保護すると「保護したTPMがなければ復号できない」といえる程度に強度が上がるそうです*2。一次情報は見つけられませんでしたが「復号したデータはTPMの中だけで扱われる」と「パスフレーズよりもビット長が長いキーで暗号化する」の2点においてパスフレーズよりも強い暗号処理を実現しているとのことです。

ということで、手元の環境はTPMが利用できる状態だったのでSSH鍵をTPMで保護します。具体的には以下のコマンドで利用可能かどうかが分かります。

$ tpm2_getcap algorithms
...
ecc:
  value:      0x23
  asymmetric: 1
  symmetric:  0
  hash:       0
  object:     1
  reserved:   0x0
  signing:    0
  encrypting: 0
  method:     0
...

$ tpm2_getcap ecc-curves
TPM2_ECC_NIST_P256: 0x3
TPM2_ECC_NIST_P384: 0x4
TPM2_ECC_BN_P256: 0x10

NIST P-384に対応していることが分かったので、これで暗号化していきます。手元の環境はGPGエージェントをSSHエージェントとして使っているのでその手順を書きます。GPGエージェントを設定されていない場合は、以前書いた記事を参考にしてください。

またはSSHエージェントをそのまま使いたい場合は、fujiwaraさんがZennの記事を書いていたので、こちらが参考になるかもしれません。

TPM保護の具体例

ではGPGでSSH鍵を作っていきます。マスターキーは既にあるものとしています(以下の例では BF6A9... がマスターキーのフィンガープリントです)。

gpg --edit-key --expert BF6A9F34814124AE28BD01597C63237ED4C24B72

次に認証ロール(A)だけ持った鍵ペアをNIST P-386で作成します。

gpg> addkey
...
  (11) ECC (set your own capabilities)
...
Your selection? 11

...
   (4) NIST P-384
...
Your selection? 4

これであとはGPGの key コマンドで今回作成したサブキーを選択して keytotpm を実行すれば終わりです。

gpg> key 6FECA71FEFEF8777
...
ssb* nistp384/6FECA71FEFEF8777
     created: 2026-05-25  expires: never       usage: A

gpg> keytotpm

ここでパスフレーズを2回聞かれます。最初はもともと鍵に設定していたパスフレーズで、2回目は今後TPMでこの鍵を扱うためのアクセスに必要となるパスフレーズです。TPMで保護された状態になると以下のように TPM-Protected 属性が付いたり、> が付いた表示となります。この記号は「鍵がスマートカードなどの別媒体に存在している」を意味するようです。

$ gpg --edit-key
...
ssb  nistp384/6FECA71FEFEF8777
     created: 2026-05-25  expires: never       usage: A
     card-no: TPM-Protected

$ gpg -K
[keyboxd]
---------
...
ssb>  nistp384 2026-05-25 [A]

keytotpm については以下の記事を参考にしました。

GitHub CLIはどうするか

GitHub CLIはSSHで認証することがおそらくできないのでトークン認証を使うことになってしまいますが、上で書いたようにトークンは使いたくありませんので手元での利用を諦めました。トークンに read 権限しか持たせないようにする方法もあるかもしれませんが、それはそれとして gh auth token で簡単に取り出せてしまうのも怖いため、多少の不便は仕方ないかなと思います。良い方法があれば教えて下さい。

クールダウン期間を設定する

この辺りはよく言われている話ですね。NPMなら min-release-age= を、Dependabotなら cooldown パラメータを設定します。

min-release-age=7

Goにはそのような仕組みがまだありませんが、proposal: cmd/go: support dependency cooldown in Go toolingで実装が進んでいるようです。これが実装されるまではDependabot等を使って更新するとか、もしくは脆弱性の修正を除いて更新しない選択肢もあるかもしれません。

上に貼ったRussのスレッドでも、そのようなことが書かれています。

And speaking of NPM (which GitHub owns), postinstall.js is a decision worth reconsidering. So is "always download the latest version of all dependencies". The justification was to push the latest security fixes immediately, but more often these days it pushes the latest malware immediately instead.

Russ Cox (@swtch.com) 2026-04-06T13:45:59.824Z
bsky.app

postinstallを無効にする

これは賛否あるようですが、怖いので手元では無効にしました。NPMでは ignore-scripts= パラメータを設定します。

ignore-scripts=true

これを設定するとパッケージ開発者が想定している処理を行わないことになるので、それに伴った意図しないエラーが発生する可能性はあります。あるけれども @^1.1.0 などのパッケージ自動更新と組み合わせると任意のコード実行が行われる可能性が上がるため、困ったら一時的に戻すなど考えればいいかなと今は思っています。

アプリケーションからファイルを隔離する

ここまでで、パッケージマネージャを利用する範囲では、侵害されたバージョンが公開されても直ちに影響を受けなくなりましたが、パッケージマネージャ以外では依然として最新バージョンが適用されてしまう状況があります。例えばブラウザやVS Codeの拡張がそうですし、プライベートで使っているObsidianプラグインや(最近使い始めた)Beancountプラグインなどもあります。そこを野放しにしておくと感染源となりかねないので対策をしました。

具体的には、LinuxにはFlatpakというパッケージマネージャ兼アプリケーション実行環境があって、これはアプリケーション実行前にコンテナを作成して環境を閉じ込めることができるので、アプリケーションには必要な部分だけ見せるようにするといいのではないかと考えました。例えばVS Codeならソースコードを置いたディレクトリ以外は見せなくてもいいはずですし、ObsidianなどもVaultだけで良いはずです。ということで以下のようなオプションを設定しておくと、アプリケーション必要な分部木だけを与えられるようになります。以下はVS Codeの例ですが、どのアプリケーションでもだいたい同じです。

# ~/src 以下だけアプリケーションに公開する例
flatpak --user override --nofilesystem=host --filesystem=home/src

正しくファイルシステムの分離が設定できているかは、flatpak run --command=bash でコンテナを起動すれば確認できます。詳細は以下の公式ドキュメントを参照してください(しかしあまり詳しく書かれていない...)。

OSの更新などはどうするか

Arch Linuxを使っているので pacman がそれに該当しますが、Arch Packageの習慣として新バージョンが公開される前にまずはテストリポジトリで公開する運用のようなので、そこで min-release-age= 相当の担保はされているんじゃないかと思っています。

AURに関しては、もともと自分自身の使い方ではAURからインストールしたパッケージは2つ程度なので、人間が最終更新日をみて min-release-age= と同等の運用になるよう注意すればなんとかできるかなと思っていますが、どうでしょうか。yay には該当のイシューが立っていました。

まとめ

ここまでで、なるべく侵害されないように対応できたと思いますし、仮にシークレットを盗まれても復号されるまでの時間的な猶予は得られたので多少は安心できるのではないでしょうか。GitHub CLIを使えなくなった等の不便な状況も起きてしまいますが、さすがに恒久的にこのままとは思わないので、根本対策されるまでは多少の不便は仕方ないですね。

2026年5月時点では以上ですが、この他にもなにかあるかもしれないので、より安全になるように対策をしていきます。

宣伝

所属している会社がインターン参加者を募集していますので、興味のある学生さんがいたら気軽に応募してみてください。

hatena.co.jp

*1:設定によっては生体認証などを求められるかもしれません

*2:暗号の専門家ではないので詳しくは分かりませんが事実そうなのでしょう

Goのエラーハンドリングを集約するライブラリを作った + Goが管理するスタック周りの挙動について

お試しで、Goのエラーハンドリングを省略するための try というライブラリを作っているので紹介します(最後にちょっとした告知があります)。

github.com

これを使うと、よくある if err != nil を次のように記述できます。

// HandleとCheckは必ず同じスコープに書いて下さい

cp, err := try.Handle()
if err != nil {
    // 下のCheckでエラーが発生したらここに飛ぶ
    log.Fatalln("Error:", err)
}
s := try.Check1(os.ReadFile("/tmp/xxx"))(cp)
u := try.Check1(url.Parse(string(s)))(cp)
fmt.Println(u.Path)

try パッケージのAPIデザインはError Handling — Problem Overviewを参考にしています。

動作としては、まず Handle で戻り先を記憶しています。このとき、最初の Handle 呼び出しでは必ず errnil になるように返します。なので次の行にある if err != nil のブロックは実行されません。

その後、Check1 を呼び出していますが、ここで戻り値の最後がエラーであれば cp を生成したときの Handle まで巻き戻り、HandleCheck1 が受け取ったエラーを返します。なので今度は if err != nil のブロックが実行されて、ここでは log.Fatalln を呼んでいるのでプログラムが終了します。

Go Playgroundも作ってみたので実際に動かしてみてください。いくつか既知の問題があるけれどだいたい動いたので、こうして記事を書いていますが、下に実装を書いているように Goチームが保証していない挙動を利用している ので、環境や将来のバージョンで動かなくなる可能性があります。また、実績もまだ手元で動いたものしかないので、安定稼働が求められる場面では絶対に使わないで ください。

どちらかというと、この記事では try を実現するために調べたことを紹介するのが目的です。

tryは何をやっているのか

基本はC言語setjmp または longjmp と同じことをしていますが、前提が多いので順番に説明します。

ABIInternal

Goは amd64arm64 などのCPUアーキテクチャごとにABIを定義しています。Go 1.25では ABIInternal という仕様があり、以下のドキュメントにまとまっています。

この仕様では、例えば amd64 アーキテクチャで関数を実行しているとき、以下のようなスタックのレイアウトになります。

関数を実行しているときのスタックレイアウトです

ここで次の関数を呼び出すと、戻り先のアドレス*1と親フレームのBPが保存されているアドレスを下位のスタックに追加します。

関数呼び出し直後のスタックレイアウトです

これで、あとは実際に記述したコードを実行していきます。実行途中でスタックの操作が行われれば SP レジスタも更新されます。

関数呼び出し前処理が終わった時点のスタックレイアウトです

言い方を変えると、BP レジスタは現在のスタックフレームが管理するスタック領域の底アドレスを保持します。スタックは上位アドレスから下位アドレスに向かって伸びるので、底アドレスはメモリアドレス的には上位アドレスです。BP レジスタの値は関数を実行している間は基本的に変わりません。SP レジスタはスタックフレームの下位アドレスを保持します。こちらは関数が実行されている間、スタックの利用状況に応じて増減します。

ここで try を実装するために重要なのは以下の3点です。

  • BP レジスタより8バイト上のアドレスには、関数がリターンするアドレスが入っている
  • BP レジスタが指すメモリアドレスには、親フレーム(関数呼び出し前)の BP レジスタの値が入っている
  • SP レジスタには、その時点で利用しているスタックの先頭アドレスが入っている

このように BP レジスタを使ったリンクリストになっているので、この構造を使うと呼び出しスタックを順に辿ることができます。ということは、スタックを辿って必要な値を探し、SP レジスタが指すアドレスを書き換えれば任意の場所にジャンプできるわけですね。

なので Handle では、以下5つのレジスタCheckpoint 構造体*2に詰めています。arm64 もだいたい同じレジスタを持っています。

amd64 arm64 意味
SP RSP スタックポインタ
BP R29 ベースポインタ
DX R26 コンテキストポインタ
(PC) LR Handle の戻り先アドレス
(BP) (R29) Handle を呼び出した関数のスタックポインタ

こうしておいて、Check 関数でエラーが発生したら詰めておいたレジスタの値を復元しています。そうすると Check 関数からリターンしたとき(細工しておいた)戻り先になっているので、Handle から戻るように動作するのでした。

ABIInternalとABI0

Goアセンブリについてですが、2025年時点では、値のやりとりをレジスタベースで行う ABIInternal と、スタックベースで行う ABI0 の2種類があります。ABIInternalコンパイラ内部で使うABIで、一般のユーザーが使うものではありません。

アセンブリのコードを手書きする場合は ABI0 を使うことになります。以前書いたGoアセンブリの書き方ABI0 に準拠しています。なので上で説明したレジスタを操作するときは ABI0 として記述しつつ、ABIInternal なレイアウトのメモリを読むことになって非常に厄介ですが、幸い SPBP レジスタの扱いは変わらないようでした。

ところで、ABI0 はスタックベースなので、例えば 0(FP)8(FP) といったスタック上のメモリを使って値の受け渡しをします。しかしGoで書いたコードは ABIInternal なので、AXBX といったレジスタで値をやり取りします。この違いがあるので、そのままではうまく値を渡せません。このギャップを埋めるためにABI Wrapperといって、ABIInternalABI0 を仲介する層が存在するようでしたが、あまり詳しくは調べていません。

実際のコードはたぶんこの辺り。

スタックの伸長と縮小

次に、スタックの伸縮について。Goではゴルーチンごとに固有のスタック領域を持っています。最初は2KBで開始して、必要になれば伸長されていきます。この処理は、関数呼び出しの先頭部分にコンパイラが以下のようなコードを挿入することで行っています。

// 本当はアセンブリ言語で書かれているけど雰囲気は同じはず
currentSP -= (関数に必要なスタックサイズ)
if currentSP < currentGoroutine.stackPointer {
    runtime.morestack_noctxt() // スタックを伸長する
}
// 以下、実際に書いた関数の処理が続く...

このとき、伸長するときにスタック領域そのものが移動するので、当然ですがメモリアドレスも変わります。スタック上にある unsafe.Pointer 型の値なら、スタックが移動するとき runtime が一緒にアドレスを書き換えてくれるのですが、uintptr 型の値やヒープにある unsafe.Pointer は変わらないようでした。

var i int
p := unsafe.Pointer(&i) // pはスタック領域に置かれている
u := uintptr(p)
println("before:", &i, p, u)
grow() // スタック伸長が必要な処理を行う
println("after:", &i, p, u) // &iとpは移動後のスタックを指すがuは古いスタックのまま

なので、スタックが移動したことを検出して、Handle によって退避した SP などの値も修正しなければなりません。このために、Handle では「Handle を呼び出したスタックフレームの BP レジスタ」を保存しておきます。HandleCheck は同じ関数内で呼ばれる想定なので、Check 関数が呼ばれたときにスタックフレームを遡って Handle と同じフレームを探し、そのアドレスを比較することで移動した距離を反映しています。

おわりに

この他にも、Handle 関数がインライン展開されるように、関数実装の複雑さを減らすなど細かい調整を行っていますが、面白い話題としてはこのくらいでしょうか。

こういった黒魔術はあまり使わないほうがいいとは理解しつつ、仮にもしこれが受け入れられるならエラーハンドリング周りの改善に繋がったらいいなと思って実装してみたのでした。もし何か不具合等がみつかったら日本語でもいいのでissueに起票していただけると助かります。実際にいま分かっている問題もあって、-covermode=atomic オプションをつけてテストを実行するとよくわからない落ち方をします。

# まだ調べてないけど不思議な落ち方をする...
go test -race -covermode=atomic

宣伝

最後に、筆者が現在所属しているはてなでは、10月31日にhatena.go#2というイベントを開催します。実務でGoを使ったときの面白い話が聞けると思うので、お時間が合う人はぜひ参加をお待ちしています。

connpass.com

*1:関数呼び出しを行った次の命令

*2:上のコードで cp としている値

GitHubでサプライチェーン攻撃を防ぐ設定

ここ数ヶ月でサプライチェーン攻撃に関連していくつかベストプラクティスが出ていたので、GitHubリポジトリに適用しておいたほうがいいものをまとめた。

被害を受けないために

Dependabotにcooldownを設定する

過去のサプライチェーン攻撃では、ほとんどは問題のあるリリースが公開されてから数時間で発見されているので、自分のリポジトリが汚染されないためにリリースから一定期間はアップデートを保留するという手段が取られるようになったと記憶している。もともとRenovateには minimumReleaseAge オプションがあったのだが、Dependabotでも cooldown オプションが使えるので設定する。

このオプションを設定しても、上の記事中に

Key benefits

  • Stay responsive to critical security patches.

とあるように脆弱性の修正は cooldown の設定よりも優先してアップデート対象となるらしいので、ある程度日数を開けても問題ないと思う。設定例は以下の通り。

version: 2
updates:
- package-ecosystem: github-actions
  directory: /
  schedule:
    interval: weekly
  cooldown:
    default-days: 5

上記では、サプライチェーン攻撃に関していえばパッチバージョンだから安全という訳でもないので default-days を設定しているが、この他にもマイナーバージョンやパッチバージョンごとに期間を設定できる。

Actionsのバージョンをハッシュで強制する

GitHub ActionsではGitのタグを使ってバージョンを特定しているので、もともと安全だったタグが攻撃者によって悪意のあるコミットに向けられてしまって汚染されることがある。こういった攻撃を防ぐため、GitHubは公式にSecure use reference#Using third-party actions

You can help mitigate this risk by following these good practices:

  • Pin actions to a full-length commit SHA

のようにハッシュによるバージョン参照を推奨していたのだが、2025年8月にハッシュで参照することを強制する Require actions to be pinned to a full-length commit SHA オプションが追加された。

公式がハッシュによる参照を推奨しているので上記のオプションも有効にするといいと思うけれど、このオプションはワークフローが依存するすべてのアクションでハッシュ指定を強制するので、外部のアクションにタグ指定がひとつでもあるとエラーになる。例えば筆者は一部のワークフローでgovulncheck-actionを使っているのだけど、2025年10月時点では govulncheck-action にはタグを使ってバージョンを参照するコードが含まれているので、上記オプションを有効にするとエラーとなってワークフローを実行できないので状況を確認しつつ有効にしていくといい。

被害を広げないために

CodeQLで検査を行う

CodeQLはGitHubが公式に提供するコードスキャナで、2025年10月時点ではCodeQLの設定は以下2通りの方法がある。

CodeQLはリポジトリの設定画面から Advanced Security タブの Code Scanning セクションで有効にできる。2025年10月時点では、上記で Require actions to be pinned to a full-length commit SHA オプションを設定していると

Code scanning with GitHub Actions is not available for this repository

と警告が出てセットアップを行えない状態となるが、その状態でも自分でワークフローを記述すれば高度なセットアップとしてCodeQLが動作する。高度なセットアップでは以下のようなGitHub Actionsのワークフローとして記述する。

## この内容はブログ記事に掲載するため簡略化しているので、リポジトリの設定から
## Advanced Security/Code Scanningセクションで「Advanced」を選んで出てくるテンプレートを参照してください。

name: "CodeQL Advanced"
on:
  push:
    branches:
    - main
  pull_request:
    branches:
    - main
  schedule:
  - cron: '17 2 * * 6'
jobs:
  analyze:
    name: Analyze (${{ matrix.language }})
    runs-on: ubuntu-latest
    permissions:
      security-events: write
      packages: read
      actions: read
      contents: read
    strategy:
      fail-fast: false
      matrix:
        include:
        - language: actions
          build-mode: none
        - language: go
          build-mode: autobuild
    steps:
    - name: Checkout repository
      uses: actions/checkout@v4
    - name: Initialize CodeQL
      uses: github/codeql-action/init@v3
      with:
        languages: ${{ matrix.language }}
        build-mode: ${{ matrix.build-mode }}
    - name: Perform CodeQL Analysis
      uses: github/codeql-action/analyze@v3
      with:
        category: "/language:${{ matrix.language }}"

ワークフローとして記述する内容については以下のドキュメントにまとまっている。

コード署名を必須にする

名前とアイコンだけでは不安があるのでコード署名も有効にできればより良いが、これは過去に記事を書いた。

blog.lufia.org

Immutable Releaseを設定する

いちどGitHub Releasesで公開したリリースを変更できなくするオプションも追加された。

これを有効にした状態でリリースを作成すると、リリースした内容を変更できなくなる

このオプションを使うには、リポジトリの設定で General タブにある Release セクションで Enable Release Immutability を有効にする。Immutable Releasesを有効にした状態でリリースを作成すると、該当するリリースには鍵付きのアイコンに Immutable というラベルが付与される。

鍵アイコンにImmutableと文字が並んでいます

ただし不変となるのはコミットの内容とリリース成果物だけで、リリースのタイトル、リリースの説明文、プレリリース状態は依然として変更可能となっている。

Immutable Releasesでアセットを追加する

上記の通り、Immutable Releasesが有効になっていると、リリースにファイルのアップロードが行えなくなる。成果物をリリースに含めたい場合にどうするのかといえば、バージョンをいちどドラフトで作成して、成果物のアップロードが完了してから正式リリースに変更する手順を踏む必要がある。GitHub Releasesで各バージョンの状態は

  • Draft
  • Pre-release
  • Release

の3つで推移するので、手動でリリース成果物を追加した後で Pre-release または Release に変更すればいい。

手動またはGitHub CLI等で人間がアップロードする場合はそれでいいのだけれども、GitHub Actionsのワークフローを使って成果物のアップロードを行っていた場合は問題がある。具体的には以下のワークフローについて

on:
  release:
    types:
    - published # リリースが公開されたときにワークフローをトリガーする

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v5
    - run: make zip
    - uses: actions/upload-artifact@v4
      with:
        name: artifacts
        path: "*.zip"
  deploy:
    needs: build
    permissions:
      contents: write
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v5
    - uses: actions/download-artifact@v5
      with:
        path: artifacts
        merge-multiple: true
    - name: release
      run: |
        gh release upload "$TAG" artifacts/*.zip
      env:
        TAG: ${{ github.ref_name }}
        GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

このとき、Immutable Releasesが有効になっていると gh release upload によるアップロードが行えなくなる。上にも書いたようにドラフトであれば成果物の追加が行えるけれども、ドラフトの状態でワークフローをトリガーするイベント が、少なくとも今の時点では存在しない*1

ではどうするのかというと、公式ドキュメントにUsing immutable releases and tags to manage your action's releasesがあって、このドキュメントではGitのタグとして作る方法が紹介されていたのでこの方法でリリースを行うことにした。ワークフローの差分は以下の通り。

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index ef97f43..c1db329 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -1,9 +1,9 @@
 name: Release
 
 on:
-  release:
-    types:
-    - published
+  push:
+    tags:
+    - v[0-9]+.[0-9]+**
 
 jobs:
   build:
@@ -47,7 +46,10 @@ jobs:
         merge-multiple: true
     - name: release
       run: |
-        gh release upload "$TAG" artifacts/*.zip
+        gh release create "$TAG" \
+          --title="$TAG" \
+          --generate-notes \
+          --draft artifacts/*.zip
       env:
         TAG: ${{ github.ref_name }}
         GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

リリースを行いたいときGitのタグをプッシュすれば、ワークフローによってドラフトのリリースが作られる。このとき、上記ワークフローではリリースノート生成と成果物のアップロードまで行うように変更しているが、リリースノートの内容は後からGitHubのUIで変更できるし、リリースと関連付けられていないタグなら削除できるので、タグ付けを間違えた場合はドラフトリリースとGitのタグそのものを削除してやり直せばいい。

ただ、誤って先にGitHubのUIでリリースとして作ってしまった場合は(もう消せないしアップロードもできないので)新しくタグを作るしかなくなってしまうけれども、Immutable Releasesを使わないよりはメリットが大きいだろうから諦める。

*1:書きかけのリリースを自動バックアップする機能と競合するためらしい

Plan 9でTailscaleネットワークに参加する

Plan 9にはパスワード等シークレットを一元管理するための認証エージェントとしてfactotum(4)が存在しています。factotum はデータを永続化しませんし、そのメモリ領域はカーネルによって保護された状態にあって、デバッガは当然アタッチできないしプロセスの kill(1) さえできません*1。代わりに、暗号化した状態で永続化するためのsecstore(8)があって、この2つが協調して動作します。

筆者は現在、Google Cloud上でCompute Engineを使って3台モデルのPlan 9システムを運用していますが、普段はLinux上で plan9port も使っています。そうしたとき、plan9portfactotum からGCPにある secstored を参照したくなるのだけども、secstored には機密情報が入っているのでインターネットからアクセス可能な場所に置きたくはありません。なので今までは二重管理も仕方ないと思っていたのですが、2025年4月のエイプリルフールネタでTailscaleがPlan 9に対応したとあった*2ので試してみました。

エイプリルフールの記事は以下の2つです。9fansもとても盛り上がっていました。

必要なもの

Tailscaleアカウント

なければ取得しておきましょう。

Goコンパイラ

TailscaleはGoで実装されているのでPlan 9用のGoコンパイラが必要です。他OSでクロスコンパイルする方法がいちばん早いと思いますが、Plan 9の9k(64bit)カーネルでGoを最初からビルドするで紹介したのように最初から順に更新してもいいと思います。

カーネルの更新

9legacyを使っている場合は、Tailscale対応のために不動小数点の扱いと ndb/dns の修正が入っているので、最新のコードに更新しておきましょう。古いPlan 9を使っている場合は、tailscaled を実行したときに以下のようなエラーとなって動作しません。

cpu% tailscaled
logtail started
Program starting: v1.82.0-ERR-BuildInfo, Go 1.24.2: []string{"tailscaled"}
logpolicy: using UserCacheDir, "/usr/lufia/lib/cache/Tailscale"
logpolicy.ConfigFromFile /usr/lufia/lib/cache/Tailscale/tailscaled.log.conf: open /usr/lufia/lib/cache/Tailscale/tailscaled.log.conf: '/usr/lufia/lib/cache/Tailscale' does not exist
logpolicy.Config.Validate for /usr/lufia/lib/cache/Tailscale/tailscaled.log.conf: config is nil
dns: using dns.noopManager

インストール

準備ができたらTailscaleをソースからビルドします。

go install tailscale.com/cmd/tailscaled@latest
go install tailscale.com/cmd/tailscale@latest

tailscaleネットワークに参加

ビルドが終わればあとは tailscaled を実行するだけなのですが、オプションが色々あって難しいので、Arch Linuxtailscale パッケージを参考にオプションを与えました。具体的にはsystemdのユニットファイルが以下のようになっていたので

EnvironmentFile=/etc/default/tailscaled
ExecStart=/usr/sbin/tailscaled --state=/var/lib/tailscale/tailscaled.state --socket=/run/tailscale/tailscaled.sock --port=${PORT} $FLAGS
ExecStopPost=/usr/sbin/tailscaled --cleanup

なのでPlan 9でも次のようなで起動します。ここでは --socket オプションと --port オプションを渡していませんがデフォルトで動作します。

tailscaled --state $home/lib/tailscaled/tailscaled.state

デーモンが起動したら、あとは tailscale up とするとログインURLが出力されるので、それにアクセスすればネットワークに参加できます。

tailscale up

これで比較的安全に secstored とローカルの factotum が連携できるようになって嬉しいですね。

Plan 9端末からtailscaleネットワークに参加した画面です

*1:本当に誰もなにもできなくなる

*2:この少し前にRuss CoxがGitHubで9legacyをForkしていて、何しているんだろうねと社内のSlackで話をしていたのだった

Plan 9の9k(64bit)カーネルでGoを最初からビルドする

はじめに

  • この記事は趣味の記録みたいなものです
  • 他OSでPlan 9用にクロスコンパイルする方が圧倒的に早いので普通はそれがおすすめです

ブートストラップ可能な下限バージョン

GoはもともとCで実装されていましたが、Go 1.5以降はセルフホストされています。公式ドキュメントのInstalling Go from sourceにビルド可能なバージョンが書かれていますが、現時点までのバージョンをまとめると

バージョン どこまでビルド可能か
C 〜 Go 1.4
Go 1.4 Go 1.5 〜 1.19
Go 1.17 Go 1.20 〜 1.21
Go 1.20 Go 1.22 〜 1.23

これ以降は、ターゲットのマイナーバージョンが

  • 偶数の場合: N-2
  • 奇数の場合: N-3

という制約になります。例えばGo 1.25をビルドしたい場合、マイナーバージョンが奇数なので下限はGo 1.22です。

Plan 9(64bit)の場合

基本的には上記の通りなのですが、Plan 9でビルドする場合は上記の通りにはいかないケースがいくつかあります。なのでインストールした直後のPlan 9で2025年夏時点の最新バージョンに到達する手順をまとめます。

Go 1.4.3

まずはCコンパイラで1.4.3をビルドします。素のPlan 9では証明書の検証がうまくいかないので、別の方法でダウンロードしておいてください。

curl -LO https://go.dev/dl/go1.4.3.src.tar.gz

あとはPlan 9go1.4 をビルドします。

gunzip -c go1.4.3.src.tar.gz | tar x
mv go go1.4
cd go1.4/src
./make.rc

Go 1.9.7

次に、公式にはGo 1.4があれば1.17までビルドできるとなっていますが、Plan 9(64bit)の場合はGo 1.17やGo 1.11まで上げると runtime: garbage collector found invalid heap pointer というエラーで失敗します。なので、一旦Go 1.9を経由する必要があります。これはGo 1.4.3を使ってビルドします。

curl -LO https://go.dev/dl/go1.9.7.src.tar.gz

ビルドの手順はGo 1.4と同じなので省略。

Go 1.11.13

Go 1.9.7を使ってGo 1.11をビルドします。これは飛ばしてGo 1.17まで上げても動くかもしれないけれど試していません。

curl -LO https://go.dev/dl/go1.11.13.src.tar.gz

GOROOT_BOOTSTRAP 環境変数で指示する必要があります。

gunzip -c go1.11.13.src.tar.gz | tar x
mv go go1.11
cd go1.11/src
GOROOT_BOOTSTRAP=$home/go1.9 ./make.rc -v

ここで make.rc-v オプションを与えていますが、これはビルドの状況を詳しく出力するためのオプションです。Go 1.10前後で make.rc の出力が

Building Go cmd/dist using /usr/glenda/go1.9. (go1.9.7 plan9/amd64)
Building Go toolchain1 using /usr/glenda/go1.9.
Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.

のようにシンプルなスタイルに変わりましたが、Plan 9でのビルドはネットワーク次第で遅くなるので細かい進捗が出力される方が嬉しいのです。とはいえ無くても困ったりはしません。

Go 1.17.13

Go 1.11を使ってビルドします。

curl -LO https://go.dev/dl/go1.17.13.src.tar.gz

ビルド方法は1.11と同じなので省略。

Go 1.21.13

Go 1.17を使ってビルドします。次の1.22をビルドするために必要な最小バージョンは1.20だけど、新しい方がなにかと良いだろうと思って、Go 1.17でビルド可能な中での最も新しいバージョンを選択しました。

curl -LO https://go.dev/dl/go1.21.13.src.tar.gz

ビルド方法は1.11と同じなので省略。

Go 1.22.12

Go 1.21を使ってビルドします。

curl -LO https://go.dev/dl/go1.22.12.src.tar.gz

ビルド方法は1.11と同じなので省略。

Go 1.24.5

この記事を書いている時点で最新バージョンは1.24.5なのでGo 1.22を使ってビルドしたいのですが、このバージョンは64bit Plan 9環境では問題があるらしく、以下のエラーで失敗します。

Building Go toolchain3 using go_bootstrap and Go toolchain2.
M structure uses sizeclass 1792/0x700 bytes; incompatible with mutex flag mask 0x3ff
fatal error: runtime.m memory alignment too small for spinbit mutex
runtime: panic before malloc heap initialized

runtime stack:
runtime.throw({0xc675e7?, 0x0?})
        runtime/panic.go:1101 +0x49 fp=0x7fffffffee78 sp=0x7fffffffee48 pc=0x273529
runtime.lockVerifyMSize()
        runtime/lock_spinbit.go:97 +0xc5 fp=0x7fffffffeea0 sp=0x7fffffffee78 pc=0x2175c5
runtime.schedinit()
        runtime/proc.go:818 +0x34 fp=0x7fffffffef00 sp=0x7fffffffeea0 pc=0x244c74
runtime.rt0_go()
        runtime/asm_amd64.s:349 +0x128 fp=0x7fffffffef08 sp=0x7fffffffef00 pc=0x278a28
go: error obtaining buildID for go tool compile: exit status: 'compile 17771: 2'
go tool dist: FAILED: /usr/glenda/go/pkg/tool/plan9_amd64/go_bootstrap install -v -a cmd/asm cmd/cgo cmd/compile cmd/link cmd/preprofile: exit status: 'go_bootstrap 17767: 1'

この問題はruntime: panic before malloc heap initialized on plan9/amd64で報告されていて、新しいバージョンでは解決しているようなので1.25相当まで上げましょう。

Go 1.25rc2相当

上記の通り1.24がビルドできないので、GitHubから最新のソースコードを拾ってきてこれで代用します。もう少し経てば正式版が出ると思うので、それを使ってもいいかもしれません。

unzip go-master.zip
mv go-master go
cd go/src

ここで VERSION ファイルを作るのですが、ソースをzipでダウンロードした場合は .git がなくて VERSION の作成が上手く動きません。なので手で以下のような内容のファイルを作成します。

go1.25rc2
time 2025-07-29T12:08:00Z

これであとは make.rc するだけです。

GOROOT_BOOTSTRAP=$home/go1.22 ./make.rc -v

9legacy/9front/plan9portのrcシェルで`delim{cmd}構文が利用可能になりました

タイトル通りなんですが、主なPlan 9派生の rc シェルで `delim{cmd} 構文が使えるようになりました。この構文は9atomで最初に実装されたのですが、用途としては一時的に $ifs を置き換えたい場合に利用できます。

# 例です
newline='
'
a=`$newline{cat file}

$ifs を変更することに比べて、変更が子孫プロセスに影響するかどうかが異なります。$ifs環境変数*1なので子孫プロセスに引き継がれてしまいますが、この構文は書いたコマンドの出力にだけ影響します。

例えば、あまり良い例ではありませんが、次のような platform.rc ファイルがあったとします。実行するとOSとCPUアーキテクチャ: で区切って出力します。

#!/usr/lib/plan9/bin/rc

os=`{uname}
arch=`{uname -m}
switch($os){
case Linux
    echo -n linux:$arch
case *
    echo -n other:$arch
}

そして platform.rc を呼び出す build.rc があったとします。このとき、build.rcplatform.rc の出力を分割しようとして $ifs: に設定しています。

#!/usr/lib/plan9/bin/rc

# ifsを一時的に変更するため var=val {cmd} スタイルを利用している
ifs=: {a=`{rc ./platform.rc}}

os=$a(1)
arch=$a(2)

一見うまく動きそうですが、実際は親プロセスで変更した $ifsplatform.rc にも影響してしまい ifs=: として uname の出力を扱います。これでは uname の出力にある改行が残ってしまうため、意図した動作にならず問題ですね。この例では改行が残る程度ですが、もっとひどい結果になる場合はあるでしょう。

そこで `delim{cmd} では、環境変数$ifs は変更せずに、コマンド出力を分割する部分でだけ delim を使うようになっています。基本的に、$ifs を子孫プロセスに引き継ぎたいケースはほとんど無いと思いますし、今までの書き方よりもこちらの方が便利なので、積極的に利用すると良いんじゃないかなと思います。

# これまでの書き方
ifs=: {a=`{rc ./platform.rc}}

# 新しい構文では記述も簡単になる
a=`:{rc ./platform.rc}

*1:厳密にはUnix相当の環境変数ではないけれど分かりやすさのためにそう表現しています

systemdユニットの依存関係と実行順序を理解する: Wants=, Requires=, Requisite=, PartOf=, BindsTo=, Upholds=の違い

systemdには、依存関係を記述するためのディレクティブとして Wants=, Requires=, BindsTo= などがあり、順序を記述するために Before=After= が用意されています。systemd.unit(5)に説明は書かれていますが、具体的にどう動くのか分からなかったので、ユニットの起動処理と停止処理に注目して挙動を調べました。

Unitセクション

まずは各ディレクティブの概要から。マニュアルから起動、停止に関連する部分を抜き出して意訳したものを載せます。

Wants=

弱い依存関係を示す。a.service の実行に b.service が必要なとき Wants=b.service と記述する。ユニットファイルに記述する他に、a.service へのシンボリックリンクb.service.wants/ ディレクトリ以下に配置することでも依存関係を注入できる。

Wants= で指定した依存先ユニットの起動に失敗しても、要求したユニット自体は起動する。また、依存関係はサービスの起動や停止の順序に影響しない。After= または Before= を設定しない限り依存元と依存先は同時に起動する。

Requires=

強い依存関係を示す。Wants= に似て、ユニットファイルに記述する他に a.service へのシンボリックリンクb.service.requires/ ディレクトリ以下に配置することでも依存関係を注入できる。

Requires=After= を同時に指定している場合、依存先ユニットの起動に失敗すると要求したユニットも起動しない。ただし ConditionXxx= による条件チェックに失敗した場合、依存先ユニットが正常に停止した場合などは要求したユニットを実行する。

BindsTo=

Requires= よりも強い依存を意味する。Requires= の挙動に加えて、依存ユニットが突然非アクティブ状態になった場合、要求したユニットも一緒に停止する。After= と同時に使うと、上記に加えて ConditionXxx= の条件チェックに失敗した場合でも要求したユニットを停止する。

PartOf=

Requires= と同様の依存関係となるが、影響はユニットの停止と再起動に限定される。依存先→依存元の単方向な依存となる。

Upholds=

Wants= に似ているが、これを設定したユニットが動作している間、Wants= にリストされたユニットも維持される。Wants= は起動時だけに影響するがだが、これは要求したユニットが実行中の間は永続的に影響する。

Before=

ユニットに Before=b.service と記述された場合、このユニットが起動完了するまで b.service の起動を遅延する。複数のユニットを記述する必要がある場合はスペース区切りで列挙する。

ユニットを停止する際には順序が逆になる。a.service の後に b.service を起動した場合、停止するときは b.service の後に a.service を停止する。

After=

Before= の順序が逆になる。

実際の動作検証

上記を読んだとき、なんとなく分かりそうだけど PartOf= の動作は全然わからないと感じました。なので以下のユニットを用意して手元で実験してみました。この手の例は a.serviceb.service がよく使われる印象ですが、どっちが依存元なのか分かりづらいので app.servicedb.service で例を書きます。まずは他ユニットを要求する app.service のユニットファイルです。

[Unit]
Description=app
Wants=db.service

[Service]
Type=simple
ExecStart=/bin/sleep inf

次に依存先となる db.service のユニットファイルです。

[Unit]
Description=db

[Service]
Type=simple
ExecStart=/bin/sleep inf

この2つを使って、app.serviceWants= を変更したり、After= を設定したりと変化させつつ動作を確認します。

ユニットを起動したときの結果

app.servicedb.service どちらも実行していない状態からそれぞれを起動したときは、表のように動作しました。余談ですが、Requires= + BindsTo= + PartOf= のように、1つのユニットに複数のディレクティブを記述した場合、制約の強いものが反映されるようです。

ディレクティブ appを起動したとき appを起動したとき(After=あり) dbを起動したとき
Wants= appdb を同時に起動する db, app の順に起動する db だけ起動する
Requires= appdb を同時に起動する db, app の順に起動する db だけ起動する
Requisite= app だけ起動する app は起動に失敗する db だけ起動する
BindsTo= appdb を同時に起動する db, app の順に起動する db だけ起動する
PartOf= app だけ起動する app だけ起動する db だけ起動する
Upholds= appdb を同時に起動する db, app の順に起動する db だけ起動する

After= を設定しない場合は、Requisite=PartOf= において依存先が起動していなくても app.service を実行中にします。それ以外は db.service を同時に起動します。このとき After= を設定していないので、Requisite= であってもユニットは実行中状態になります。また、「同時に」と書いてはいますが、プロセスIDを見る限りでは、保証はされていないと思うけれども app.service を先に起動しているようです。

After= を設定した場合は挙動が変化して、実行順序を保証しつつ Requisite= の起動が失敗するようになります。

表の右端でみるように db.service 単体で起動した場合は app.service に影響を与えません。db.service を起動したとき同時に app.service を起動したければ、下でみるように Install セクションの RequiredBy= または WantedBy= を使います。

依存先ユニットの起動に失敗したときの結果

次に依存先ユニットの起動に失敗したときの動作をみます。失敗させるため db.service はすぐに失敗するようなユニットにしておきます。

 [Unit]
 Description=db
 
 [Service]
 Type=simple
-ExecStart=/bin/sleep inf
+ExecStart=/bin/false

上の実験と同様に、app.serviceWants= を変更しつつ、依存先として db.service を起動したときの様子を確認します。

ディレクティブ dbの起動に失敗
Wants= app だけ起動する
Requires= app だけ起動するが数分後に app を停止する(1)
Requisite= app もすぐに停止する
BindsTo= app もすぐに停止する
PartOf= app だけ起動する
Upholds= app を起動したまま db もリトライし続ける(2)

表として表現するのが困難な事柄がいくつかあったので補足です。

(1)で数分後というのは、db.service が失敗してすぐに app.service も停止する訳ではありませんでした。手元の環境ではしばらく app.service だけで実行し続けて、だいたい2分後に app.service も停止しました。systemdのユニットにおけるrequiresが機能してないように見えますの回答によると、この挙動は Type=simple だから「起動と同時に成功した」扱いとなっているようです。2分という時間の出どころは不明ですが、Restart= ディレクティブなどで変化するのかもしれませんが試していません。

(2)では、Upholds=app.service を実行中にしつつ、db.service をリトライし続けます。10分待ってみたけどリトライが止まらないので、それ以上は確認していません。

ユニットを停止したときの結果

最後に、停止するときの動作は次のようになります。After= の有無による変化はありません。

ディレクティブ appの正常/異常終了 dbの正常終了 dbの異常終了
Wants= db は継続する app は継続する app は継続する
Requires= db は継続する app も停止する app は継続する
Requisite= db は継続する app も停止する app は継続する
BindsTo= db は継続する app も停止する app も停止する
PartOf= db は継続する app も停止する app は継続する
Upholds= db は継続する db が再起動される db が再起動される

Unitセクションのまとめ

After= の有無や Type= の値によって挙動が変わるので難しいですが、誤解を気にせず簡単に言えば Wants=Requires= は「ユニットを起動するとき依存先ユニットも一緒に起動するか」「依存先ユニットが停止したときどう振る舞うか」がディレクティブ毎に異なると言えそうです。

Installセクション

Install セクションは systemctl enable または systemctl disable したときに影響します。Unit セクションの内容が分かっていれば、依存の向きが逆になる(自身に依存させるような書き方をする)だけで動作としては同じなので理解しやすいと思います。

WantedBy=

WantedBy= にユニットを記述とすると、そのユニットに Wants= を記述した動作と同等になります。例えば acme.serviceWantedBy=graphical-session.target を記述した場合、

# acme.service
[Unit]
Requires=graphical-session.target
After=graphical-session.target

[Install]
WantedBy=graphical-session.target

上記の acme.servicesystemctl enable で有効化すると graphical-session.target.wants/ ディレクトリに acme.service へのシンボリックリンクが作成されて、結果的に graphical-session.targetacme.service に依存した状態になります。このとき、acme.servicegraphical-session.target に依存して、graphical-session.targetacme.service に依存する状態となって循環するのでは、と危惧するかもしれませんが、上記でみたように Wants=Requires= は依存先ユニットを起動または停止するかどうかでしかないので問題なく動作します。

ところで graphical-session.targetacme.service に依存すると書くと、依存関係として逆じゃないかと思います。個人的にはずっと気持ち悪さを感じていましたが、.target なユニットは「あるべき状態」を定義するものなので「graphical-session.target としてあるべき状態は xxx.service を含む」と解釈すれば違和感は解消されるのではないでしょうか。

RequiredBy=

WantedBy= と似ていますが Requires= 相当となります。