Firewallを超えてHTTPサーバーと時間を同期させる


社内でMacを使っている場合、ファイアウォールでブロックされNTPによる時刻調整ができない場合が多いのではないかと思います。
ファイアウォール内にNTPサーバーを立てるのは大変ですし、Windowsのフリーウェア、TimeTuneにヒントを得て(作者のbeeskneesさんThanks!)、ファイアウォール越しでもアクセスできる場合の多いHTTPサーバーから時刻を取得して時刻調整を行うプログラムを作ってみました。

このプログラムのポイントとなる点は3つです。

(1) HTTPプロトコルのTRACEコマンドで返ってくるヘッダからHTTPサーバの時刻を取得し、NSDateオブジェクトに変換する
(2) システム時刻の設定はsettimeofday(2)で行うが、このシステムコールはroot権限でなければ実行できないので、その方法を実現する必要がある
(3) サーバからの情報取得を同期型のメソッドを使っていること、特権モードで自分自身を新たに起動しその完了を待っていることなどから、サーバからの情報取得と時刻調整は別スレッドで行っている

1.RFC822で規定された日付フォーマットからNSDateオブジェクトを取得する

サーバから取得したHTTPレスポンスのHTTPヘッダ内で、日付はRFC822で規定された以下のような形式で表現するように規定されています。

  Fri, 16 Jun 2006 01:20:48 GMT

そこでNSDateをカテゴリで機能拡張し、上記形式の文字列からNSDateオブジェクトを生成する以下のメソッドを作成しました。

@interface NSDate (DateWithRFC822String)

// RFC822形式の文字列からNSDateオブジェクトを生成する
+ (NSDate *)dateWithRFC822String:(NSString *)pstrDate;

@end

単調な処理なので処理内容はソースファイルを参照してください。
なお、タイムゾーンのサポートはGMTのみなので悪しからず。(ほとんどのサーバはGMTで返してくるので問題ないでしょう)

2.HTTPサーバから時刻を取得する

このプログラムではNSURLConnectionを使って、指定されたHTTPサーバーにHTTPプロトコルのTRACEコマンドを送信し、そのレスポンス中に含まれるDate項目を取得し、取得した日付文字列から先に定義したdateWithRFC822String:でNSDataオブジェクトを生成しています。

上記の一連のコードを以下に示します。

    // HTTPサーバーからTRACEコマンドで時刻を取得する
    NSURL               *poURL = [NSURL URLWithString:目的のHTTPサーバーURL];
    NSMutableURLRequest *poURLReq = [NSMutableURLRequest requestWithURL:poURL];
    [poURLReq setHTTPMethod:@"TRACE"];
    NSHTTPURLResponse   *poURLResp;
    NSError             *poErr;
    [NSURLConnection sendSynchronousRequest:poURLReq
                          returningResponse:&poURLResp
                                      error:&poErr];
    if ( poErr != nil ) {
        エラー処理
        return;
    }

    // HTTP ResponseヘッダからDate項目を取得する
    NSDictionary *poHTTPHeader = [poURLResp allHeaderFields];
    NSDate       *pSvrDate = [NSDate dateWithRFC822String:[poHTTPHeader objectForKey:@"Date"]];


3.時刻設定を特権モードで実行する

時刻設定を特権モードで実行するために、ちょっとトリッキーですがセキュリティAPIのAuthorizationExecuteWithPrivilegesを使用して自分自身を起動することにしました(別ツールにすると配布が面倒くさいので...)。起動時にパラメータを与えることで、自分が通常アプリとして起動されたのか、それども特権モードで時刻設定用に起動されたのかを判断するようにしています。

具体的には通常は触らないmain関数を以下のように改造しました。

int main(int argc, char *argv[])
{
    if ( argc == 3 && strcmp( argv[1], TIME_ADJ_CMD ) == 0 ) {
        setSystemTime( argv[2] );    // 時刻設定用内部関数
        return 0;
    } else {
        return NSApplicationMain(argc,  (const char **) argv);
    }
}

時刻設定時の起動パラメータは以下のようになっています。

 argv[0] = プログラムのパス名
 argv[1] = "TIME_ADJ_CMD"
 argv[2] = サーバ時刻(1970/1/1からの秒数)

setSystemTime関数の処理内容は以下の通りで、時刻のズレが許容範囲を超えていたらsettimeofday(2)でシステム時刻を設定しています。

// システム時間の設定
void    setSystemTime( const char *pszTimeFrom1970 )
{
    struct timeval      tNewTime;
    time_t              nNow, nDeltaTime;

    NSLog( @"setSystemTime( %s )\n", pszTimeFrom1970 );

    tNewTime.tv_sec  = atol( pszTimeFrom1970 );
    tNewTime.tv_usec = 0;
    nNow = time( NULL );
    nDeltaTime = nNow - tNewTime.tv_sec;
    NSLog( @"Delta time = %d\n", nDeltaTime );
    if ( abs( nDeltaTime ) >= MIN_DELTA_TIME ) {
        // 許容差分時間を超えていたら時間調整
        settimeofday( &tNewTime, NULL );
    } else {
        // 時間調整なし
        nDeltaTime = 0;
    }

//*** 完了待ち合わせ実験用コード ***
//  sleep(30);
//  NSLog( @"sleep(30)\n" );

    // 呼び出し元に調整した差分時間を通知する
    printf( "%ld", nDeltaTime );
}

呼び出される側はこれで解決ですが、呼び出す側は特権モードで動作させるためにいろいろ処理があります。

コードの概略を以下に示します。

*** 初期化時 ***
- (void)awakeFromNib
{
    // 自分自身を時刻設定用に特権モードで動作させる実行ファイルのパスを求め、
    // メンバ変数に保存
    // *ツールを特権モードで実行させるAuthorizationExecuteWithPrivilegesでは
    //   *.appのパッケージ形式のアプリを起動できないので、パッケージ内の実際の
    //   実行ファイルのパスを求めている
    NSString *pstrExePath = [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"Contents/MacOS/TimeSyncOverFW"];
    _unExePathLength = [pstrExePath lengthOfBytesUsingEncoding:NSUTF8StringEncoding];
    _pszExePath = calloc( 1, _unExePathLength + 1 );
    memcpy( _pszExePath, [pstrExePath UTF8String], _unExePathLength );
}


*** 実際の実行処理部 ***
    OSStatus            nSts;
    AuthorizationRef    tAuthRef;

    // Authorizationハンドル作成
    nSts = AuthorizationCreate(NULL, kAuthorizationEmptyEnvironment, kAuthorizationFlagDefaults, &tAuthRef);
    if ( nSts != errAuthorizationSuccess ) {
        エラー処理
    }

    // 自分自身のroot権限での実行権設定
    AuthorizationItem   tRightItem;
    AuthorizationRights tRights;
    tRightItem.name         = kAuthorizationRightExecute;
    tRightItem.valueLength  = _unExePathLength;
    tRightItem.value        = (void *)_pszExePath;
    tRightItem.flags        = 0;

    tRights.count = 1;
    tRights.items = &tRightItem;

    nSts = AuthorizationCopyRights( tAuthRef, &tRights, kAuthorizationEmptyEnvironment,
                                    kAuthorizationFlagInteractionAllowed | kAuthorizationFlagExtendRights, NULL );
    if ( nSts != errAuthorizationSuccess ) {
        エラー処理
    }

    *** HTTPサーバーからTRACEコマンドで時刻を取得する ***
    *** 「2.HTTPサーバから時刻を取得する」参照      ***

    // 自分自身を時刻設定モードで起動する
    // 起動パラメータ設定
    char        *pszArgv[4];
    char        cTimeFrom1970[64];
    sprintf( cTimeFrom1970, "%ld", (long)[pSvrDate timeIntervalSince1970] );
    pszArgv[0] = TIME_ADJ_CMD;
    pszArgv[1] = cTimeFrom1970;
    pszArgv[2] = NULL;
    FILE        *pfpCmdStdOut;    // 実行したコマンドの完了待ちと調整差分時間の結果受け取り用

    // 特権モードでの実行
    nSts = AuthorizationExecuteWithPrivileges(
                            tAuthRef,
                            _pszExePath,
                            kAuthorizationFlagDefaults,
                            pszArgv,
                            &pfpCmdStdOut );
    if ( nSts != errAuthorizationSuccess ) {
            エラー処理
    }

    // コマンド実行完了待ちと調整差分時間の受け取り
    char    cResult[128];
    fgets( cResult, sizeof(cResult), pfpCmdStdOut );
    fclose( pfpCmdStdOut );
    NSLog( @"Adjust %s secs.\n", cResult );

    //  Authorizationハンドル解放
    AuthorizationFree( tAuthRef, kAuthorizationFlagDefaults );

処理の流れは以下の通りです。

(1) 自分自身の実行ファイルのパスを求め、UTF-8文字列に変換しておく。
ここで注意するのは[[NSBundle mainBundle] bundlePath]で取得されるアプリケーションのパッケージのパス(XXX.app)を指定しても、後述のAuthorizationExecuteWithPrivilegesで実行できないということです。試行錯誤した結果、パッケージ内の実際の実行ファイル(XXX.app/Contents/MacOS/XXX)のパスを指定することで起動できることがわかりました。(Universal BinaryでもOK!!)
(2) AuthorizationCreateでAuthorizationハンドルを作成する。
(3) AuthorizationCopyRightsで(1)で求めた実行ファイルに特権モードで実行できる権限を与える。
このときflagに

kAuthorizationFlagInteractionAllowed | kAuthorizationFlagExtendRights

を指定することでインストーラーなどでおなじみの管理ユーザー認証のダイアログが表示されます。ここでkAuthorizationFlagInteractionAllowedを指定しなかった場合は認証ダイアログが表示されず、特権モードでの実行権を与えることができないのでエラーになります。

(4) HTTPサーバから時刻を取得する。「2.HTTPサーバから時刻を取得する」参照。
(5) 起動パラメータを指定し、AuthorizationExecuteWithPrivilegesで(1)で求めた実行ファイルを特権モードで実行する。

なお、AuthorizationCreateとAuthorizationCopyRightsにAuthorizationEnvironmentという型のパラメータがあり、このプログラムではkAuthorizationEmptyEnvironmentを指定していますが、ここで認証ダイアログに表示されるアイコンやメッセージを指定することができます。また10.4(Tiger)からはユーザー名とパスワードも指定でき、初回入力してもらったものをキーチェーンの自分のアプリのエントリとして保存しておくことで、次回以降その値を指定すれば認証ダイアログを表示することなく特権モードでの実行を行うことができます。(CoreDuoTempがたぶんそうしている)

4.時刻同期処理を別スレッドで実行する

時刻同期処理はシーケンシャルに処理されるため、メインスレッドの延長上で実行すると例えばネットワークの障害やトラフィックの状況でHTTPサーバからの時刻取得に非常に時間がかかった時などにメインスレッドがブロックされているためUIによるキャンセルの処理ができなくなってしまいます。そのためこのサンプルプログラムでは一連の時刻同期処理を別スレッドで実行しています。(実際には同期型のメソッドでHTTPサーバーに接続しているのでキャンセルはできませんけれど、非同期型を使用するように改修したときへの布石です)

別スレッドで処理を実行するときの注意点は以下の通りです。

スレッドとなるメソッドの先頭でそのスレッド専用のNSAutoreleasePoolのインスタンスを作成する
スレッドを終了する(return文)直前に上記Autorelease poolを解放する

これはAutorelease poolはスレッド毎に必要で、NSThreadのメソッドでスレッドが起動された直後はAutorelease poolが存在せず、そのままだとAutoreleaseを期待しているオブジェクトがメモリーリークを起こしてしまうからです。

スレッド側のコードの概要は以下の通りです。

// 時刻同期処理スレッド
- (void)syncThread:(id)pstrURL
{
    NSAutoreleasePool   *poArp = [[NSAutoreleasePool alloc] init];  // 本スレッド用autorelease pool
    OSStatus            nSts;
    AuthorizationRef    tAuthRef;

    *** 時刻同期処理 ***

    // 完了通知
    [self syncDoneWithResult:YES];
    [poArp release];        // Autorelease pool解放
}

スレッドを起動する側のコードは以下の通りです。

    // 時刻同期用スレッド起動
    [NSThread detachNewThreadSelector:@selector(syncThread:) toTarget:self withObject:pstrServerURL];

スレッドへのパラメータとしてはHHTPサーバのURLを渡すようにしています。

以上ですが、このプログラムを実用的なものにするには、起動項目に登録することでログイン後普段はバックグランドで動作し、Preferenceの設定の時に設定画面を呼び出すようなものにする必要がありますが、とりあえずキーとなる課題は解決できたと思います。

サンプルプログラムのダウンロード(56KB)

【作成・確認環境】
MacOS X v10.4.6
Xcode v2.3

一覧に戻る