Created
March 3, 2026 22:46
-
-
Save azjezz/adcb0cefc7b5a51103f6cdb2bb77f497 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?php | |
| declare(strict_types=1); | |
| require_once __DIR__ . '/../vendor/autoload.php'; | |
| use Psl\Ansi; | |
| use Psl\Ansi\Color; | |
| use Psl\Ansi\Style; | |
| use Psl\Async; | |
| use Psl\Binary; | |
| use Psl\Channel; | |
| use Psl\Crypto\KeyExchange as CryptoKeyExchange; | |
| use Psl\Crypto\Signing; | |
| use Psl\Crypto\StreamCipher; | |
| use Psl\DateTime; | |
| use Psl\Encoding\Hex; | |
| use Psl\Hash; | |
| use Psl\IO; | |
| use Psl\Iter; | |
| use Psl\Network; | |
| use Psl\SecureRandom; | |
| use Psl\Str; | |
| use Psl\Str\Byte; | |
| use Psl\TCP; | |
| use Psl\Terminal; | |
| use Psl\Terminal\Event; | |
| use Psl\Terminal\Layout; | |
| use Psl\Terminal\Widget; | |
| const SSH_VERSION = 'SSH-2.0-PslSSH_1.0'; | |
| const SSH_HOST = '127.0.0.1'; | |
| const SSH_PORT = 2222; | |
| const SSH_MSG_SERVICE_REQUEST = 5; | |
| const SSH_MSG_SERVICE_ACCEPT = 6; | |
| const SSH_MSG_KEXINIT = 20; | |
| const SSH_MSG_NEWKEYS = 21; | |
| const SSH_MSG_KEX_ECDH_INIT = 30; | |
| const SSH_MSG_KEX_ECDH_REPLY = 31; | |
| const SSH_MSG_EXT_INFO = 7; | |
| const SSH_MSG_USERAUTH_REQUEST = 50; | |
| const SSH_MSG_USERAUTH_FAILURE = 51; | |
| const SSH_MSG_USERAUTH_SUCCESS = 52; | |
| const SSH_MSG_USERAUTH_PK_OK = 60; | |
| const SSH_MSG_CHANNEL_OPEN = 90; | |
| const SSH_MSG_CHANNEL_OPEN_CONFIRMATION = 91; | |
| const SSH_MSG_CHANNEL_WINDOW_ADJUST = 93; | |
| const SSH_MSG_CHANNEL_DATA = 94; | |
| const SSH_MSG_CHANNEL_EOF = 96; | |
| const SSH_MSG_CHANNEL_CLOSE = 97; | |
| const SSH_MSG_CHANNEL_REQUEST = 98; | |
| const SSH_MSG_CHANNEL_SUCCESS = 99; | |
| const SSH_KEX_ALGORITHM = 'curve25519-sha256'; | |
| const SSH_HOST_KEY_ALGORITHM = 'ssh-ed25519'; | |
| const SSH_CIPHER = 'aes256-ctr'; | |
| const SSH_MAC = 'hmac-sha2-256'; | |
| const SSH_COMPRESSION = 'none'; | |
| const SSH_INITIAL_WINDOW_SIZE = 1_048_576; | |
| const SSH_MAX_PACKET_SIZE = 32_768; | |
| const SSH_MAX_CHANNEL_DATA = 16_384; | |
| const SSH_CIPHER_BLOCK_SIZE = 16; | |
| const SSH_MIN_PADDING = 4; | |
| const SSH_MAX_PACKET_READ = 35_000; | |
| const SSH_KEY_SIZE = 32; | |
| const SSH_IV_SIZE = 16; | |
| const SSH_MAC_SIZE = 32; | |
| const SSH_AUTH_ALGORITHMS = [ | |
| 'ssh-ed25519', | |
| 'ssh-rsa', | |
| 'rsa-sha2-256', | |
| 'rsa-sha2-512', | |
| 'ecdsa-sha2-nistp256', | |
| 'ecdsa-sha2-nistp384', | |
| 'ecdsa-sha2-nistp521', | |
| ]; | |
| final class CryptoState | |
| { | |
| public int $sequence_number = 0; | |
| public readonly StreamCipher\Context $cipher; | |
| /** | |
| * @param non-empty-string $mac_key | |
| * @param non-empty-string $encryption_key | |
| */ | |
| public function __construct( | |
| public readonly string $mac_key, | |
| string $encryption_key, | |
| string $iv, | |
| ) { | |
| $this->cipher = new StreamCipher\Context( | |
| new StreamCipher\Key($encryption_key), | |
| $iv, | |
| StreamCipher\Algorithm::Aes256Ctr, | |
| ); | |
| } | |
| } | |
| final class TransportState | |
| { | |
| public null|CryptoState $encryptor = null; | |
| public null|CryptoState $decryptor = null; | |
| public int $send_sequence = 0; | |
| public int $recv_sequence = 0; | |
| public readonly IO\Reader $reader; | |
| public function __construct( | |
| public readonly Network\StreamInterface $stream, | |
| ) { | |
| $this->reader = new IO\Reader($stream); | |
| } | |
| } | |
| final readonly class ChatMessage | |
| { | |
| public function __construct( | |
| public string $sender, | |
| public string $text, | |
| public bool $system = false, | |
| ) {} | |
| } | |
| final readonly class ChatUser | |
| { | |
| public function __construct( | |
| public int $id, | |
| public string $name, | |
| public Channel\SenderInterface $sender, | |
| ) {} | |
| } | |
| final class ChatRoom | |
| { | |
| /** @var array<int, ChatUser> */ | |
| public array $users = []; | |
| /** @var array<int, Terminal\Application> */ | |
| public array $apps = []; | |
| public int $next_id = 0; | |
| } | |
| final class ChatState | |
| { | |
| public string $input_value = ''; | |
| /** @var non-negative-int */ | |
| public int $input_cursor = 0; | |
| /** @var list<ChatMessage> */ | |
| public array $messages = []; | |
| public string $user_name = ''; | |
| /** @var list<string> */ | |
| public array $online_users = []; | |
| public int $frame_count = 0; | |
| public DateTime\Timestamp $last_fps_time; | |
| public int $fps = 0; | |
| } | |
| function crypto_compute_mac(CryptoState $state, string $packet): string | |
| { | |
| $seq_bytes = new Binary\Writer()->u32($state->sequence_number)->toString(); | |
| $hex = Hash\Hmac\hash($seq_bytes . $packet, Hash\Hmac\Algorithm::Sha256, $state->mac_key); | |
| return Hex\decode($hex); | |
| } | |
| function crypto_encrypt(CryptoState $state, string $packet): string | |
| { | |
| $mac = crypto_compute_mac($state, $packet); | |
| $encrypted = $state->cipher->apply($packet); | |
| $state->sequence_number++; | |
| return $encrypted . $mac; | |
| } | |
| function crypto_decrypt_remaining( | |
| CryptoState $state, | |
| string $decrypted_length, | |
| string $encrypted_rest, | |
| string $mac, | |
| ): string { | |
| $decrypted_rest = $state->cipher->apply($encrypted_rest); | |
| $full_packet = $decrypted_length . $decrypted_rest; | |
| $expected_mac = crypto_compute_mac($state, $full_packet); | |
| if (!Hash\equals($expected_mac, $mac)) { | |
| throw new RuntimeException('MAC verification failed'); | |
| } | |
| $state->sequence_number++; | |
| return $full_packet; | |
| } | |
| /** | |
| * @param string $shared_secret | |
| * @param string $exchange_hash | |
| * @param string $session_id | |
| * | |
| * @return array{ | |
| * client_to_server_iv: non-empty-string, | |
| * server_to_client_iv: non-empty-string, | |
| * client_to_server_key: non-empty-string, | |
| * server_to_client_key: non-empty-string, | |
| * client_to_server_mac: non-empty-string, | |
| * server_to_client_mac: non-empty-string, | |
| * } | |
| */ | |
| function crypto_derive_keys( | |
| #[SensitiveParameter] string $shared_secret, | |
| string $exchange_hash, | |
| string $session_id, | |
| ): array { | |
| return [ | |
| 'client_to_server_iv' => crypto_derive_key( | |
| $shared_secret, | |
| $exchange_hash, | |
| letter: 'A', | |
| session_id: $session_id, | |
| needed: SSH_IV_SIZE, | |
| ), | |
| 'server_to_client_iv' => crypto_derive_key( | |
| $shared_secret, | |
| $exchange_hash, | |
| letter: 'B', | |
| session_id: $session_id, | |
| needed: SSH_IV_SIZE, | |
| ), | |
| 'client_to_server_key' => crypto_derive_key( | |
| $shared_secret, | |
| $exchange_hash, | |
| letter: 'C', | |
| session_id: $session_id, | |
| needed: SSH_KEY_SIZE, | |
| ), | |
| 'server_to_client_key' => crypto_derive_key( | |
| $shared_secret, | |
| $exchange_hash, | |
| letter: 'D', | |
| session_id: $session_id, | |
| needed: SSH_KEY_SIZE, | |
| ), | |
| 'client_to_server_mac' => crypto_derive_key( | |
| $shared_secret, | |
| $exchange_hash, | |
| letter: 'E', | |
| session_id: $session_id, | |
| needed: SSH_MAC_SIZE, | |
| ), | |
| 'server_to_client_mac' => crypto_derive_key( | |
| $shared_secret, | |
| $exchange_hash, | |
| letter: 'F', | |
| session_id: $session_id, | |
| needed: SSH_MAC_SIZE, | |
| ), | |
| ]; | |
| } | |
| /** | |
| * @param non-negative-int $needed | |
| * @return non-empty-string | |
| */ | |
| function crypto_derive_key(string $k, string $h, string $letter, string $session_id, int $needed): string | |
| { | |
| $key = Hex\decode(Hash\hash($k . $h . $letter . $session_id, Hash\Algorithm::Sha256)); | |
| while (Byte\length($key) < $needed) { | |
| $key .= Hex\decode(Hash\hash($k . $h . $key, Hash\Algorithm::Sha256)); | |
| } | |
| /** @var non-empty-string */ | |
| return Byte\slice($key, offset: 0, length: $needed); | |
| } | |
| function crypto_encode_mpint(string $bytes): string | |
| { | |
| $bytes = Byte\trim_left($bytes, char_mask: "\x00"); | |
| if ($bytes === '') { | |
| $bytes = "\x00"; | |
| } | |
| if (Byte\ord($bytes[0]) & 0x80) { | |
| $bytes = "\x00" . $bytes; | |
| } | |
| return new Binary\Writer() | |
| ->u32PrefixedBytes($bytes) | |
| ->toString(); | |
| } | |
| function transport_read_line(TransportState $t): string | |
| { | |
| $line = $t->reader->readUntil("\r\n"); | |
| if ($line === null) { | |
| throw new RuntimeException('Connection closed before version exchange'); | |
| } | |
| return $line; | |
| } | |
| function transport_read_packet(TransportState $t): string | |
| { | |
| if ($t->decryptor !== null) { | |
| $encrypted_length = $t->reader->readFixedSize(4); | |
| $decrypted_length = $t->decryptor->cipher->apply($encrypted_length); | |
| $packet_length = new Binary\Reader($decrypted_length)->u32(); | |
| if ($packet_length === 0) { | |
| throw new RuntimeException('Connection closed before version exchange'); | |
| } | |
| if ($packet_length > SSH_MAX_PACKET_READ) { | |
| throw new RuntimeException("Packet too large: {$packet_length}"); | |
| } | |
| $encrypted_rest = $t->reader->readFixedSize($packet_length); | |
| $mac = $t->reader->readFixedSize(SSH_MAC_SIZE); | |
| $full_packet = crypto_decrypt_remaining($t->decryptor, $decrypted_length, $encrypted_rest, $mac); | |
| $padding_length = Byte\ord($full_packet[4]); | |
| /** @var non-negative-int $packet_length */ | |
| $packet_length = $packet_length - $padding_length - 1; | |
| return Byte\slice($full_packet, offset: 5, length: $packet_length); | |
| } | |
| $header = $t->reader->readFixedSize(4); | |
| $packet_length = new Binary\Reader($header)->u32(); | |
| if ($packet_length === 0) { | |
| throw new RuntimeException('Connection closed before version exchange'); | |
| } | |
| if ($packet_length > SSH_MAX_PACKET_READ) { | |
| throw new RuntimeException("Packet too large: {$packet_length}"); | |
| } | |
| $data = $t->reader->readFixedSize($packet_length); | |
| $padding_length = Byte\ord($data[0]); | |
| $t->recv_sequence++; | |
| /** @var non-negative-int $data_length */ | |
| $data_length = $packet_length - $padding_length - 1; | |
| return Byte\slice($data, offset: 1, length: $data_length); | |
| } | |
| function transport_write_packet(TransportState $t, string $payload): void | |
| { | |
| $payload_length = Byte\length($payload); | |
| $block_size = $t->encryptor !== null ? SSH_CIPHER_BLOCK_SIZE : 8; | |
| $min_size = 1 + $payload_length + SSH_MIN_PADDING; | |
| $padding = $block_size - ($min_size % $block_size); | |
| if ($padding < SSH_MIN_PADDING) { | |
| $padding += $block_size; | |
| } | |
| $packet = new Binary\Writer() | |
| ->u32(1 + $payload_length + $padding) | |
| ->u8($padding) | |
| ->bytes($payload) | |
| ->bytes(SecureRandom\bytes($padding)) | |
| ->toString(); | |
| if ($t->encryptor !== null) { | |
| $t->stream->writeAll(crypto_encrypt($t->encryptor, $packet)); | |
| return; | |
| } | |
| $t->stream->writeAll($packet); | |
| $t->send_sequence++; | |
| } | |
| /** | |
| * @return array{public: non-empty-string, secret: non-empty-string} | |
| */ | |
| function host_key_generate(): array | |
| { | |
| $kp = Signing\generate_key_pair(); | |
| return ['public' => $kp->publicKey->bytes, 'secret' => $kp->secretKey->bytes]; | |
| } | |
| function kex_build_init(): string | |
| { | |
| return new Binary\Writer() | |
| ->u8(SSH_MSG_KEXINIT) | |
| ->bytes(SecureRandom\bytes(16)) | |
| ->u32PrefixedBytes(SSH_KEX_ALGORITHM) | |
| ->u32PrefixedBytes(SSH_HOST_KEY_ALGORITHM) | |
| ->u32PrefixedBytes(SSH_CIPHER) | |
| ->u32PrefixedBytes(SSH_CIPHER) | |
| ->u32PrefixedBytes(SSH_MAC) | |
| ->u32PrefixedBytes(SSH_MAC) | |
| ->u32PrefixedBytes(SSH_COMPRESSION) | |
| ->u32PrefixedBytes(SSH_COMPRESSION) | |
| ->u32PrefixedBytes('') | |
| ->u32PrefixedBytes('') | |
| ->u8(0) | |
| ->u32(0) | |
| ->toString(); | |
| } | |
| function kex_send_init(TransportState $t): string | |
| { | |
| $payload = kex_build_init(); | |
| transport_write_packet($t, $payload); | |
| return $payload; | |
| } | |
| function kex_receive_init(TransportState $t): string | |
| { | |
| $payload = transport_read_packet($t); | |
| if (Byte\ord($payload[0]) !== SSH_MSG_KEXINIT) { | |
| throw new RuntimeException('Expected KEXINIT, got message type ' . Byte\ord($payload[0])); | |
| } | |
| return $payload; | |
| } | |
| function kex_compute_exchange_hash( | |
| string $client_version, | |
| string $server_version, | |
| string $client_kex_payload, | |
| string $server_kex_payload, | |
| string $host_key_blob, | |
| string $client_ephemeral, | |
| string $server_ephemeral, | |
| string $shared_secret_mpint, | |
| ): string { | |
| $data = new Binary\Writer() | |
| ->u32PrefixedBytes($client_version) | |
| ->u32PrefixedBytes($server_version) | |
| ->u32PrefixedBytes($client_kex_payload) | |
| ->u32PrefixedBytes($server_kex_payload) | |
| ->u32PrefixedBytes($host_key_blob) | |
| ->u32PrefixedBytes($client_ephemeral) | |
| ->u32PrefixedBytes($server_ephemeral) | |
| ->bytes($shared_secret_mpint) | |
| ->toString(); | |
| return Hex\decode(Hash\hash($data, Hash\Algorithm::Sha256)); | |
| } | |
| /** | |
| * @param array{public: non-empty-string, secret: non-empty-string} $host_key | |
| * | |
| * @return array{exchange_hash: string, shared_secret_mpint: string} | |
| */ | |
| function kex_server_exchange( | |
| TransportState $t, | |
| array $host_key, | |
| string $client_version, | |
| string $server_version, | |
| string $client_kex_payload, | |
| string $server_kex_payload, | |
| ): array { | |
| $payload = transport_read_packet($t); | |
| if (Byte\ord($payload[0]) !== SSH_MSG_KEX_ECDH_INIT) { | |
| throw new RuntimeException('Expected KEX_ECDH_INIT, got message type ' . Byte\ord($payload[0])); | |
| } | |
| /** @var non-empty-string $client_ephemeral */ | |
| $client_ephemeral = new Binary\Reader(Byte\slice($payload, offset: 1))->u32PrefixedBytes(); | |
| $server_kp = CryptoKeyExchange\generate_key_pair(); | |
| $server_ephemeral = $server_kp->publicKey->bytes; | |
| $shared_secret = CryptoKeyExchange\agree($server_kp->secretKey, new CryptoKeyExchange\PublicKey($client_ephemeral)); | |
| $shared_secret_mpint = crypto_encode_mpint($shared_secret->bytes); | |
| $host_key_blob = new Binary\Writer() | |
| ->u32PrefixedBytes('ssh-ed25519') | |
| ->u32PrefixedBytes($host_key['public']) | |
| ->toString(); | |
| $exchange_hash = kex_compute_exchange_hash( | |
| $client_version, | |
| $server_version, | |
| $client_kex_payload, | |
| $server_kex_payload, | |
| $host_key_blob, | |
| $client_ephemeral, | |
| $server_ephemeral, | |
| $shared_secret_mpint, | |
| ); | |
| /** @var non-empty-string $signature_blob */ | |
| $signature_blob = new Binary\Writer() | |
| ->u32PrefixedBytes('ssh-ed25519') | |
| ->u32PrefixedBytes(Signing\sign($exchange_hash, new Signing\SecretKey($host_key['secret']))->bytes) | |
| ->toString(); | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_KEX_ECDH_REPLY) | |
| ->u32PrefixedBytes($host_key_blob) | |
| ->u32PrefixedBytes($server_ephemeral) | |
| ->u32PrefixedBytes($signature_blob) | |
| ->toString(), | |
| ); | |
| return ['exchange_hash' => $exchange_hash, 'shared_secret_mpint' => $shared_secret_mpint]; | |
| } | |
| function kex_send_newkeys(TransportState $t): void | |
| { | |
| transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_NEWKEYS)->toString()); | |
| } | |
| function kex_receive_newkeys(TransportState $t): void | |
| { | |
| $payload = transport_read_packet($t); | |
| if (Byte\ord($payload[0]) !== SSH_MSG_NEWKEYS) { | |
| throw new RuntimeException('Expected NEWKEYS, got message type ' . Byte\ord($payload[0])); | |
| } | |
| } | |
| function send_ext_info(TransportState $t): void | |
| { | |
| $sig_algs = Str\join(SSH_AUTH_ALGORITHMS, glue: ','); | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_EXT_INFO) | |
| ->u32(1) | |
| ->u32PrefixedBytes('server-sig-algs') | |
| ->u32PrefixedBytes($sig_algs) | |
| ->toString(), | |
| ); | |
| } | |
| function auth_server(TransportState $t, string $session_id): string | |
| { | |
| $payload = transport_read_packet($t); | |
| if (Byte\ord($payload[0]) !== SSH_MSG_SERVICE_REQUEST) { | |
| throw new RuntimeException('Expected SERVICE_REQUEST, got message type ' . Byte\ord($payload[0])); | |
| } | |
| $service = new Binary\Reader(Byte\slice($payload, offset: 1))->u32PrefixedBytes(); | |
| if ($service !== 'ssh-userauth') { | |
| throw new RuntimeException("Unexpected service request: {$service}"); | |
| } | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_SERVICE_ACCEPT) | |
| ->u32PrefixedBytes('ssh-userauth') | |
| ->toString(), | |
| ); | |
| while (true) { | |
| $payload = transport_read_packet($t); | |
| if (Byte\ord($payload[0]) !== SSH_MSG_USERAUTH_REQUEST) { | |
| throw new RuntimeException('Expected USERAUTH_REQUEST, got message type ' . Byte\ord($payload[0])); | |
| } | |
| $reader = new Binary\Reader(Byte\slice($payload, offset: 1)); | |
| $username = $reader->u32PrefixedBytes(); | |
| $auth_service = $reader->u32PrefixedBytes(); | |
| $method = $reader->u32PrefixedBytes(); | |
| if ($method !== 'publickey') { | |
| auth_send_failure($t); | |
| continue; | |
| } | |
| $has_signature = $reader->u8() !== 0; | |
| $algorithm = $reader->u32PrefixedBytes(); | |
| $public_key_blob = $reader->u32PrefixedBytes(); | |
| if (!Iter\contains(SSH_AUTH_ALGORITHMS, $algorithm)) { | |
| auth_send_failure($t); | |
| continue; | |
| } | |
| if (!$has_signature) { | |
| auth_send_pk_ok($t, $algorithm, $public_key_blob); | |
| continue; | |
| } | |
| $signature_blob = $reader->u32PrefixedBytes(); | |
| $signed_data = auth_build_signed_data($session_id, $username, $auth_service, $algorithm, $public_key_blob); | |
| if (auth_verify_signature($algorithm, $public_key_blob, $signature_blob, $signed_data)) { | |
| transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_USERAUTH_SUCCESS)->toString()); | |
| return $username; | |
| } | |
| auth_send_failure($t); | |
| } | |
| } | |
| function auth_send_failure(TransportState $t): void | |
| { | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_USERAUTH_FAILURE) | |
| ->u32PrefixedBytes('publickey') | |
| ->u8(0) | |
| ->toString(), | |
| ); | |
| } | |
| function auth_send_pk_ok(TransportState $t, string $algorithm, string $public_key_blob): void | |
| { | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_USERAUTH_PK_OK) | |
| ->u32PrefixedBytes($algorithm) | |
| ->u32PrefixedBytes($public_key_blob) | |
| ->toString(), | |
| ); | |
| } | |
| function auth_build_signed_data( | |
| string $session_id, | |
| string $username, | |
| string $service, | |
| string $algorithm, | |
| string $public_key_blob, | |
| ): string { | |
| return new Binary\Writer() | |
| ->u32PrefixedBytes($session_id) | |
| ->u8(SSH_MSG_USERAUTH_REQUEST) | |
| ->u32PrefixedBytes($username) | |
| ->u32PrefixedBytes($service) | |
| ->u32PrefixedBytes('publickey') | |
| ->u8(1) | |
| ->u32PrefixedBytes($algorithm) | |
| ->u32PrefixedBytes($public_key_blob) | |
| ->toString(); | |
| } | |
| function auth_verify_signature( | |
| string $algorithm, | |
| string $public_key_blob, | |
| string $signature_blob, | |
| string $signed_data, | |
| ): bool { | |
| $sig_reader = new Binary\Reader($signature_blob); | |
| $sig_reader->u32PrefixedBytes(); // algorithm name | |
| /** @var non-empty-string $raw_signature */ | |
| $raw_signature = $sig_reader->u32PrefixedBytes(); | |
| return match ($algorithm) { | |
| 'ssh-ed25519' => auth_verify_ed25519($public_key_blob, $raw_signature, $signed_data), | |
| 'rsa-sha2-256' => auth_verify_rsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA256), | |
| 'rsa-sha2-512' => auth_verify_rsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA512), | |
| 'ssh-rsa' => auth_verify_rsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA1), | |
| 'ecdsa-sha2-nistp256' => auth_verify_ecdsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA256), | |
| 'ecdsa-sha2-nistp384' => auth_verify_ecdsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA384), | |
| 'ecdsa-sha2-nistp521' => auth_verify_ecdsa($public_key_blob, $raw_signature, $signed_data, OPENSSL_ALGO_SHA512), | |
| default => false, | |
| }; | |
| } | |
| /** | |
| * @param non-empty-string $signature | |
| */ | |
| function auth_verify_ed25519(string $public_key_blob, string $signature, string $data): bool | |
| { | |
| $reader = new Binary\Reader($public_key_blob); | |
| $reader->u32PrefixedBytes(); // "ssh-ed25519" | |
| /** @var non-empty-string $public_key */ | |
| $public_key = $reader->u32PrefixedBytes(); | |
| return Signing\verify(new Signing\Signature($signature), $data, new Signing\PublicKey($public_key)); | |
| } | |
| function auth_verify_rsa(string $public_key_blob, string $signature, string $data, int $algo): bool | |
| { | |
| $reader = new Binary\Reader($public_key_blob); | |
| $reader->u32PrefixedBytes(); // "ssh-rsa" | |
| $e = $reader->u32PrefixedBytes(); | |
| $n = $reader->u32PrefixedBytes(); | |
| $pem = der_rsa_public_key_to_pem($e, $n); | |
| $key = openssl_pkey_get_public($pem); | |
| if ($key === false) { | |
| return false; | |
| } | |
| return openssl_verify($data, $signature, $key, $algo) === 1; | |
| } | |
| function auth_verify_ecdsa(string $public_key_blob, string $ssh_signature, string $data, int $algo): bool | |
| { | |
| $reader = new Binary\Reader($public_key_blob); | |
| $reader->u32PrefixedBytes(); // "ecdsa-sha2-nistpXXX" | |
| $curve = $reader->u32PrefixedBytes(); | |
| $point = $reader->u32PrefixedBytes(); | |
| $sig_reader = new Binary\Reader($ssh_signature); | |
| $r = $sig_reader->u32PrefixedBytes(); | |
| $s = $sig_reader->u32PrefixedBytes(); | |
| $der_sig = der_sequence(der_integer($r) . der_integer($s)); | |
| $pem = der_ecdsa_public_key_to_pem($point, $curve); | |
| $key = openssl_pkey_get_public($pem); | |
| if ($key === false) { | |
| return false; | |
| } | |
| return openssl_verify($data, $der_sig, $key, $algo) === 1; | |
| } | |
| function der_length(int $length): string | |
| { | |
| if ($length < 0x80) { | |
| return chr($length); | |
| } | |
| if ($length < 0x100) { | |
| return "\x81" . chr($length); | |
| } | |
| return "\x82" . pack('n', $length); | |
| } | |
| function der_sequence(string $contents): string | |
| { | |
| return "\x30" . der_length(Byte\length($contents)) . $contents; | |
| } | |
| function der_integer(string $bytes): string | |
| { | |
| $bytes = Byte\trim_left($bytes, char_mask: "\x00"); | |
| if ($bytes === '' || Byte\ord($bytes[0]) & 0x80) { | |
| $bytes = "\x00" . $bytes; | |
| } | |
| return "\x02" . der_length(Byte\length($bytes)) . $bytes; | |
| } | |
| function der_bit_string(string $contents): string | |
| { | |
| $data = "\x00" . $contents; | |
| return "\x03" . der_length(Byte\length($data)) . $data; | |
| } | |
| function der_rsa_public_key_to_pem(string $e, string $n): string | |
| { | |
| $rsa_key = der_sequence(der_integer($n) . der_integer($e)); | |
| $algorithm_id = der_sequence("\x06\x09\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01\x05\x00"); | |
| $spki = der_sequence($algorithm_id . der_bit_string($rsa_key)); | |
| return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), length: 64) . '-----END PUBLIC KEY-----'; | |
| } | |
| function der_ecdsa_public_key_to_pem(string $point, string $curve): string | |
| { | |
| $ec_oid = "\x06\x07\x2a\x86\x48\xce\x3d\x02\x01"; | |
| $curve_oid = match ($curve) { | |
| 'nistp256' => "\x06\x08\x2a\x86\x48\xce\x3d\x03\x01\x07", | |
| 'nistp384' => "\x06\x05\x2b\x81\x04\x00\x22", | |
| 'nistp521' => "\x06\x05\x2b\x81\x04\x00\x23", | |
| default => throw new RuntimeException("Unsupported curve: {$curve}"), | |
| }; | |
| $spki = der_sequence(der_sequence($ec_oid . $curve_oid) . der_bit_string($point)); | |
| return "-----BEGIN PUBLIC KEY-----\n" . chunk_split(base64_encode($spki), length: 64) . '-----END PUBLIC KEY-----'; | |
| } | |
| function channel_accept(TransportState $t): void | |
| { | |
| $payload = transport_read_packet($t); | |
| if (Byte\ord($payload[0]) !== SSH_MSG_CHANNEL_OPEN) { | |
| throw new RuntimeException('Expected CHANNEL_OPEN, got message type ' . Byte\ord($payload[0])); | |
| } | |
| $reader = new Binary\Reader(Byte\slice($payload, offset: 1)); | |
| $type = $reader->u32PrefixedBytes(); | |
| $remote_channel = $reader->u32(); | |
| if ($type !== 'session') { | |
| throw new RuntimeException("Unsupported channel type: {$type}"); | |
| } | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_CHANNEL_OPEN_CONFIRMATION) | |
| ->u32($remote_channel) | |
| ->u32(0) | |
| ->u32(SSH_INITIAL_WINDOW_SIZE) | |
| ->u32(SSH_MAX_PACKET_SIZE) | |
| ->toString(), | |
| ); | |
| } | |
| /** | |
| * @param TransportState $t | |
| * | |
| * @return array{ | |
| * type: string, | |
| * command: string, | |
| * channel: int, | |
| * pty: null|array{term: string, cols: int, rows: int} | |
| * } | |
| */ | |
| function channel_receive_requests(TransportState $t): array | |
| { | |
| $pty = null; | |
| while (true) { | |
| $payload = transport_read_packet($t); | |
| $msg_type = Byte\ord($payload[0]); | |
| if ($msg_type === SSH_MSG_CHANNEL_WINDOW_ADJUST) { | |
| continue; | |
| } | |
| if ($msg_type !== SSH_MSG_CHANNEL_REQUEST) { | |
| throw new RuntimeException('Expected CHANNEL_REQUEST, got message type ' . $msg_type); | |
| } | |
| $reader = new Binary\Reader(Byte\slice($payload, offset: 1)); | |
| $channel = $reader->u32(); | |
| $request_type = $reader->u32PrefixedBytes(); | |
| $want_reply = $reader->u8() !== 0; | |
| if ($request_type === 'pty-req') { | |
| $term = $reader->u32PrefixedBytes(); | |
| $cols = $reader->u32(); | |
| $rows = $reader->u32(); | |
| $reader->u32(); | |
| $reader->u32(); | |
| $reader->u32PrefixedBytes(); | |
| $pty = ['term' => $term, 'cols' => $cols, 'rows' => $rows]; | |
| if ($want_reply) { | |
| channel_send_success($t, $channel); | |
| } | |
| continue; | |
| } | |
| if ($request_type === 'env') { | |
| $reader->u32PrefixedBytes(); | |
| $reader->u32PrefixedBytes(); | |
| if ($want_reply) { | |
| channel_send_success($t, $channel); | |
| } | |
| continue; | |
| } | |
| if ($request_type === 'shell') { | |
| if ($want_reply) { | |
| channel_send_success($t, $channel); | |
| } | |
| return ['type' => 'shell', 'command' => '', 'channel' => $channel, 'pty' => $pty]; | |
| } | |
| if ($request_type === 'exec') { | |
| $command = $reader->u32PrefixedBytes(); | |
| if ($want_reply) { | |
| channel_send_success($t, $channel); | |
| } | |
| return ['type' => 'exec', 'command' => $command, 'channel' => $channel, 'pty' => $pty]; | |
| } | |
| if ($want_reply) { | |
| channel_send_success($t, $channel); | |
| } | |
| } | |
| } | |
| function channel_send_success(TransportState $t, int $remote_channel): void | |
| { | |
| transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_CHANNEL_SUCCESS)->u32($remote_channel)->toString()); | |
| } | |
| function channel_send_data(TransportState $t, int $remote_channel, string $data): void | |
| { | |
| $offset = 0; | |
| $length = Byte\length($data); | |
| while ($offset < $length) { | |
| /** @var non-negative-int $chunk_size */ | |
| $chunk_size = min(SSH_MAX_CHANNEL_DATA, $length - $offset); | |
| $chunk = Byte\slice($data, $offset, $chunk_size); | |
| transport_write_packet( | |
| $t, | |
| new Binary\Writer() | |
| ->u8(SSH_MSG_CHANNEL_DATA) | |
| ->u32($remote_channel) | |
| ->u32PrefixedBytes($chunk) | |
| ->toString(), | |
| ); | |
| $offset += $chunk_size; | |
| } | |
| } | |
| /** @return null|array{channel: int, type: 'data', data: string}|array{channel: int, type: 'window-change', cols: int, rows: int} */ | |
| function channel_receive_data(TransportState $t): null|array | |
| { | |
| $payload = transport_read_packet($t); | |
| $type = Byte\ord($payload[0]); | |
| if ($type === SSH_MSG_CHANNEL_DATA) { | |
| $reader = new Binary\Reader(Byte\slice($payload, offset: 1)); | |
| return ['channel' => $reader->u32(), 'type' => 'data', 'data' => $reader->u32PrefixedBytes()]; | |
| } | |
| if ($type === SSH_MSG_CHANNEL_EOF || $type === SSH_MSG_CHANNEL_CLOSE) { | |
| return null; | |
| } | |
| if ($type === SSH_MSG_CHANNEL_REQUEST) { | |
| $reader = new Binary\Reader(Byte\slice($payload, offset: 1)); | |
| $channel = $reader->u32(); | |
| $request_type = $reader->u32PrefixedBytes(); | |
| $reader->u8(); | |
| if ($request_type === 'window-change') { | |
| return [ | |
| 'channel' => $channel, | |
| 'type' => 'window-change', | |
| 'cols' => $reader->u32(), | |
| 'rows' => $reader->u32(), | |
| ]; | |
| } | |
| return channel_receive_data($t); | |
| } | |
| if ($type === SSH_MSG_CHANNEL_WINDOW_ADJUST) { | |
| return channel_receive_data($t); | |
| } | |
| throw new RuntimeException("Unexpected message type during channel data: {$type}"); | |
| } | |
| function channel_send_eof(TransportState $t, int $remote_channel): void | |
| { | |
| transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_CHANNEL_EOF)->u32($remote_channel)->toString()); | |
| } | |
| function channel_send_close(TransportState $t, int $remote_channel): void | |
| { | |
| transport_write_packet($t, new Binary\Writer()->u8(SSH_MSG_CHANNEL_CLOSE)->u32($remote_channel)->toString()); | |
| } | |
| final class ChannelWriteHandle implements IO\WriteHandleInterface | |
| { | |
| private bool $closed = false; | |
| public function __construct( | |
| private readonly TransportState $transport, | |
| private readonly int $remote_channel, | |
| ) {} | |
| public function tryWrite(string $bytes): int | |
| { | |
| if ($this->closed) { | |
| throw new IO\Exception\AlreadyClosedException('Channel write handle is closed.'); | |
| } | |
| channel_send_data($this->transport, $this->remote_channel, $bytes); | |
| return Byte\length($bytes); | |
| } | |
| public function write(string $bytes, null|DateTime\Duration $timeout = null): int | |
| { | |
| return $this->tryWrite($bytes); | |
| } | |
| public function writeAll(string $bytes, null|DateTime\Duration $timeout = null): void | |
| { | |
| $this->tryWrite($bytes); | |
| } | |
| public function close(): void | |
| { | |
| $this->closed = true; | |
| } | |
| } | |
| function chat_join(ChatRoom $room, string $name, Channel\SenderInterface $sender): int | |
| { | |
| $id = $room->next_id++; | |
| $room->users[$id] = new ChatUser($id, $name, $sender); | |
| chat_system_message($room, "{$name} joined the chat"); | |
| return $id; | |
| } | |
| function chat_leave(ChatRoom $room, int $id): void | |
| { | |
| $user = $room->users[$id] ?? null; | |
| if ($user === null) { | |
| return; | |
| } | |
| unset($room->users[$id]); | |
| $user->sender->close(); | |
| chat_system_message($room, "{$user->name} left the chat"); | |
| } | |
| function chat_broadcast(ChatRoom $room, int $from_id, string $text): void | |
| { | |
| $user = $room->users[$from_id] ?? null; | |
| if ($user === null) { | |
| return; | |
| } | |
| $message = new ChatMessage($user->name, $text); | |
| foreach ($room->users as $u) { | |
| try { | |
| $u->sender->trySend($message); | |
| } catch (Channel\Exception\ClosedChannelException|Channel\Exception\FullChannelException) { | |
| continue; | |
| } | |
| } | |
| } | |
| function chat_system_message(ChatRoom $room, string $text): void | |
| { | |
| $message = new ChatMessage('system', $text, system: true); | |
| foreach ($room->users as $u) { | |
| try { | |
| $u->sender->trySend($message); | |
| } catch (Channel\Exception\ClosedChannelException|Channel\Exception\FullChannelException) { | |
| continue; | |
| } | |
| } | |
| } | |
| /** @return list<string> */ | |
| function chat_user_names(ChatRoom $room): array | |
| { | |
| $names = []; | |
| foreach ($room->users as $u) { | |
| $names[] = $u->name; | |
| } | |
| return $names; | |
| } | |
| function handle_chat_input(Event\Key $event, ChatState $state, ChatRoom $room, null|int $user_id): void | |
| { | |
| if ($event->is('enter') && $user_id !== null) { | |
| $text = Str\trim($state->input_value); | |
| if ($text !== '') { | |
| chat_broadcast($room, $user_id, $text); | |
| $state->input_value = ''; | |
| $state->input_cursor = 0; | |
| } | |
| return; | |
| } | |
| handle_text_input($event, $state); | |
| } | |
| function handle_text_input(Event\Key $event, ChatState $state): void | |
| { | |
| if ($event->char !== null) { | |
| $before = Str\slice($state->input_value, offset: 0, length: $state->input_cursor); | |
| $after = Str\slice($state->input_value, $state->input_cursor); | |
| $state->input_value = $before . $event->char . $after; | |
| $state->input_cursor += Str\length($event->char); | |
| return; | |
| } | |
| if ($event->is('backspace')) { | |
| if ($state->input_cursor > 0) { | |
| $before = Str\slice($state->input_value, offset: 0, length: $state->input_cursor - 1); | |
| $after = Str\slice($state->input_value, $state->input_cursor); | |
| $state->input_value = $before . $after; | |
| $state->input_cursor--; | |
| } | |
| return; | |
| } | |
| if ($event->is('delete')) { | |
| if ($state->input_cursor < Str\length($state->input_value)) { | |
| $before = Str\slice($state->input_value, offset: 0, length: $state->input_cursor); | |
| $after = Str\slice($state->input_value, offset: $state->input_cursor + 1); | |
| $state->input_value = $before . $after; | |
| } | |
| return; | |
| } | |
| if ($event->is('left')) { | |
| if ($state->input_cursor > 0) { | |
| $state->input_cursor--; | |
| } | |
| return; | |
| } | |
| if ($event->is('right')) { | |
| if ($state->input_cursor < Str\length($state->input_value)) { | |
| $state->input_cursor++; | |
| } | |
| return; | |
| } | |
| if ($event->is('home')) { | |
| $state->input_cursor = 0; | |
| return; | |
| } | |
| if ($event->is('end')) { | |
| $state->input_cursor = Str\length($state->input_value); | |
| return; | |
| } | |
| } | |
| function render_chat_screen(Terminal\Frame $frame, ChatState $state): void | |
| { | |
| $area = $frame->rect(); | |
| $buffer = $frame->buffer(); | |
| $regions = Layout\vertical($area, [ | |
| Layout\fill(), | |
| Layout\fixed(3), | |
| Layout\fixed(1), | |
| ]); | |
| $lines = []; | |
| foreach ($state->messages as $msg) { | |
| if ($msg->system) { | |
| $lines[] = Widget\Line::new([ | |
| Widget\Span::styled(' [system] ', Ansi\foreground(Color\yellow()), Style\bold()), | |
| Widget\Span::styled($msg->text, Ansi\foreground(Color\yellow()), Style\dim()), | |
| ]); | |
| continue; | |
| } | |
| $lines[] = Widget\Line::new([ | |
| Widget\Span::styled(' [' . $msg->sender . '] ', Ansi\foreground(Color\cyan()), Style\bold()), | |
| Widget\Span::raw($msg->text), | |
| ]); | |
| } | |
| if ($lines === []) { | |
| $lines[] = Widget\Line::new([Widget\Span::styled(' No messages yet. Say hello!', Style\dim())]); | |
| } | |
| Widget\Block::new() | |
| ->title(' Chat Room ') | |
| ->border(Widget\Border::rounded(Ansi\foreground(Color\cyan()))) | |
| ->render($regions[0], Widget\Paragraph::new($lines)->wrap(Widget\Wrap::Word)->scroll(PHP_INT_MAX), $buffer); | |
| Widget\Block::new()->border(Widget\Border::rounded(Ansi\foreground(Color\green())))->render( | |
| $regions[1], | |
| Widget\TextInput::new() | |
| ->value($state->input_value) | |
| ->cursor($state->input_cursor) | |
| ->placeholder('Type a message...'), | |
| $buffer, | |
| ); | |
| $state->frame_count++; | |
| $now = DateTime\Timestamp::monotonic(); | |
| $elapsed = $now->getSeconds() - $state->last_fps_time->getSeconds(); | |
| if ($elapsed >= 1) { | |
| $state->fps = $state->frame_count; | |
| $state->frame_count = 0; | |
| $state->last_fps_time = $now; | |
| } | |
| $users = $state->online_users !== [] ? Str\join($state->online_users, glue: ', ') : 'none'; | |
| $status = " Users: {$users} | Ctrl+C to quit | "; | |
| $buffer->setString($regions[2]->x, $regions[2]->y, $status, [Ansi\foreground(Color\bright_white()), Style\dim()]); | |
| $buffer->setString($regions[2]->x + Str\length($status), $regions[2]->y, "{$state->fps} FPS", [ | |
| Ansi\foreground(Color\green()), | |
| Style\bold(), | |
| ]); | |
| } | |
| /** | |
| * @param array{public: non-empty-string, secret: non-empty-string} $host_key | |
| */ | |
| function handle_client(TCP\StreamInterface $stream, array $host_key, ChatRoom $room): void | |
| { | |
| $t = new TransportState($stream); | |
| $t->stream->writeAll(SSH_VERSION . "\r\n"); | |
| $client_version = transport_read_line($t); | |
| $server_kex = kex_send_init($t); | |
| $client_kex = kex_receive_init($t); | |
| $result = kex_server_exchange($t, $host_key, $client_version, SSH_VERSION, $client_kex, $server_kex); | |
| $session_id = $result['exchange_hash']; | |
| $keys = crypto_derive_keys($result['shared_secret_mpint'], $result['exchange_hash'], $session_id); | |
| kex_send_newkeys($t); | |
| kex_receive_newkeys($t); | |
| $t->encryptor = new CryptoState( | |
| $keys['server_to_client_mac'], | |
| $keys['server_to_client_key'], | |
| $keys['server_to_client_iv'], | |
| ); | |
| $t->encryptor->sequence_number = $t->send_sequence; | |
| $t->decryptor = new CryptoState( | |
| $keys['client_to_server_mac'], | |
| $keys['client_to_server_key'], | |
| $keys['client_to_server_iv'], | |
| ); | |
| $t->decryptor->sequence_number = $t->recv_sequence; | |
| send_ext_info($t); | |
| $username = auth_server($t, $session_id); | |
| channel_accept($t); | |
| $request = channel_receive_requests($t); | |
| if ($request['type'] !== 'shell') { | |
| channel_send_data( | |
| $t, | |
| $request['channel'], | |
| 'This is a chat server. Connect with: ssh -p ' . SSH_PORT . ' user@' . SSH_HOST . "\n", | |
| ); | |
| channel_send_eof($t, $request['channel']); | |
| channel_send_close($t, $request['channel']); | |
| return; | |
| } | |
| $cols = $request['pty']['cols'] ?? 80; | |
| $rows = $request['pty']['rows'] ?? 24; | |
| $remote_channel = $request['channel']; | |
| [$pipe_read, $pipe_write] = IO\pipe(); | |
| $channel_writer = new ChannelWriteHandle($t, $remote_channel); | |
| /** @var Channel\ReceiverInterface<ChatMessage> $receiver */ | |
| /** @var Channel\SenderInterface<ChatMessage> $sender */ | |
| [$receiver, $sender] = Channel\bounded(100); | |
| $state = new ChatState(); | |
| $state->user_name = $username; | |
| $state->last_fps_time = DateTime\Timestamp::monotonic(); | |
| $user_id = chat_join($room, $username, $sender); | |
| $state->online_users = chat_user_names($room); | |
| $app = Terminal\Application::custom( | |
| state: $state, | |
| input: $pipe_read, | |
| output: $channel_writer, | |
| width: $cols, | |
| height: $rows, | |
| title: 'PSL Chat', | |
| tickInterval: DateTime\Duration::milliseconds(8), | |
| ); | |
| $room->apps[$user_id] = $app; | |
| $app->on(Event\Key::class, static function (Event\Key $event, ChatState $state) use ($app, $room, &$user_id): void { | |
| if ($event->is('ctrl+c')) { | |
| $app->stop(); | |
| return; | |
| } | |
| handle_chat_input($event, $state, $room, $user_id); | |
| }); | |
| $ssh_reader = Async\run(static function () use ($t, $pipe_write, $app): void { | |
| try { | |
| while (true) { | |
| $packet = channel_receive_data($t); | |
| if ($packet === null) { | |
| $app->stop(); | |
| break; | |
| } | |
| if ($packet['type'] === 'window-change') { | |
| $app->dispatch(new Event\Resize($packet['cols'] ?? 0, $packet['rows'] ?? 0)); | |
| continue; | |
| } | |
| if ($packet['type'] === 'data') { | |
| $pipe_write->writeAll($packet['data']); | |
| } | |
| } | |
| } catch (Throwable) { | |
| $app->stop(); | |
| } finally { | |
| $pipe_write->close(); | |
| } | |
| }); | |
| $msg_receiver = Async\run(static function () use ($receiver, $state, $room): void { | |
| while (true) { | |
| try { | |
| $state->messages[] = $receiver->receive(); | |
| } catch (Channel\Exception\ClosedChannelException) { | |
| break; | |
| } | |
| $state->online_users = chat_user_names($room); | |
| } | |
| }); | |
| $app->run(static function (Terminal\Frame $frame, ChatState $state): void { | |
| render_chat_screen($frame, $state); | |
| }); | |
| unset($room->apps[$user_id]); | |
| chat_leave($room, $user_id); | |
| $receiver->close(); | |
| $pipe_write->close(); | |
| $channel_writer->close(); | |
| try { | |
| channel_send_eof($t, $remote_channel); | |
| channel_send_close($t, $remote_channel); | |
| } catch (Throwable) { | |
| // @mago-expect lint:no-empty-catch-clause | |
| } | |
| $ssh_reader->ignore(); | |
| $msg_receiver->ignore(); | |
| } | |
| Async\main(static function (): int { | |
| $host_key = host_key_generate(); | |
| $room = new ChatRoom(); | |
| $listener = TCP\listen(SSH_HOST, SSH_PORT); | |
| /** @var bool $shutting_down */ | |
| $shutting_down = false; | |
| $shutdown = static function () use ($room, $listener, &$shutting_down): void { | |
| if ($shutting_down) { | |
| return; | |
| } | |
| $shutting_down = true; | |
| IO\write_line('Shutting down...'); | |
| $listener->close(); | |
| foreach ($room->apps as $app) { | |
| $app->stop(); | |
| } | |
| }; | |
| Async\Scheduler::unreference(Async\Scheduler::onSignal(SIGINT, $shutdown)); | |
| Async\Scheduler::unreference(Async\Scheduler::onSignal(SIGTERM, $shutdown)); | |
| IO\write_line('SSH Chat server listening on %s:%d', SSH_HOST, SSH_PORT); | |
| IO\write_line('Connect with: ssh -p %d %s', SSH_PORT, SSH_HOST); | |
| while (true) { | |
| try { | |
| $connection = $listener->accept(); | |
| } catch (Network\Exception\AlreadyStoppedException) { | |
| break; | |
| } | |
| Async\run(static function () use ($connection, $host_key, $room): void { | |
| try { | |
| handle_client($connection, $host_key, $room); | |
| } catch (Throwable $e) { | |
| IO\write_error_line('Client error: %s', $e->getMessage()); | |
| } finally { | |
| $connection->close(); | |
| } | |
| })->ignore(); | |
| } | |
| return 0; | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment