getmypid()]); $this->session(Mailer::where('name','BINKP')->singleOrFail(),$client,(new Address)); $this->client->close(); exit(0); } return NULL; } /** * BINKD handshake * * @throws \Exception */ private function binkp_hs(): bool { Log::debug(sprintf('%s:+ Starting BINKP handshake',self::LOGKEY)); if (! $this->originate && $this->down) { Log::info(sprintf('%s:! System down for maintenance',self::LOGKEY)); $this->msgs(self::BPM_BSY,'RETRY 0600: Down for maintenance, back soon...'); // @note Sometimes the remote drops the connection when we send the busy while (($this->tx_left || $this->mqueue->count()) && $this->binkp_send()) {} return FALSE; } if (! $this->originate && $this->capGet(self::F_MD,self::O_WANT)) { $random_key = random_bytes(8); $this->md_challenge = md5($random_key,TRUE); $this->msgs(self::BPM_NUL,sprintf('OPT CRAM-MD5-%s',md5($random_key))); } $this->msgs(self::BPM_NUL,sprintf('SYS %s',$this->setup->system->name)); $this->msgs(self::BPM_NUL,sprintf('ZYZ %s',$this->setup->system->sysop)); $this->msgs(self::BPM_NUL,sprintf('LOC %s',$this->setup->system->location)); $this->msgs(self::BPM_NUL,sprintf('NDL %d,TCP,BINKP',$this->client->speed)); $this->msgs(self::BPM_NUL,sprintf('TIME %s',Carbon::now()->toRfc2822String())); $this->msgs(self::BPM_NUL, sprintf('VER %s-%s %s/%s',config('app.name'),$this->setup->version,self::PROT,self::VERSION)); if ($this->originate) { $opt = $this->capGet(self::F_NOREL,self::O_WANT) ? ' NR' : ''; $opt .= $this->capGet(self::F_NODUPE,self::O_WANT) ? ' ND' : ''; $opt .= $this->capGet(self::F_NODUPEA,self::O_WANT) ? ' NDA': ''; $opt .= $this->capGet(self::F_MULTIBATCH,self::O_WANT) ? ' MB' : ''; $opt .= $this->capGet(self::F_CHAT,self::O_WANT) ? ' CHAT' : ''; $opt .= $this->capGet(self::F_COMP,self::O_WANT) ? ' EXTCMD GZ' : ''; $opt .= $this->capGet(self::F_COMP,self::O_WANT) && $this->capGet(self::F_COMP,self::O_EXT) ? ' BZ2' : ''; $opt .= $this->capGet(self::F_CRYPT,self::O_WANT) ? ' CRYPT' : ''; if (strlen($opt)) $this->msgs(self::BPM_NUL,sprintf('OPT%s',$opt)); } // If we are originating, we'll show the remote our address in the same network if ($this->originate) { $addresses = $this->our_addresses(); $this->msgs(self::BPM_ADR,$addresses->pluck('ftn')->join(' ')); } return TRUE; } /** * @return int */ private function binkp_hsdone(): bool { Log::debug(sprintf('%s:+ BINKP handshake complete',self::LOGKEY)); // If the remote doesnt provide a password, or in MD5 mode, then we cant use CRYPT if (! $this->optionGet(self::O_PWD) && (! $this->capGet(self::F_MD,self::O_WE))) { Log::notice(sprintf('%s:= CRYPT disabled, since we have no password or not MD5',self::LOGKEY)); $this->capSet(self::F_CRYPT,self::O_NO); } if ($this->capGet(self::F_CRYPT,self::O_WE)) { $this->capSet(self::F_CRYPT,self::O_YES); Log::info(sprintf('%s:- CRYPT mode initialised',self::LOGKEY)); if ($this->originate) { $this->crypt_out = new Crypt($this->node->password); $this->crypt_in = new Crypt('-'.$this->node->password); } else { $this->crypt_in = new Crypt($this->node->password); $this->crypt_out = new Crypt('-'.$this->node->password); } } // @todo Implement max incoming sessions and max incoming session for the same node // We have no mechanism to support chat if ($this->capGet(self::F_CHAT,self::O_THEY)) Log::warning(sprintf('%s:/ The remote wants to chat, but we cant do chat',self::LOGKEY)); /* if ($this->capGet(self::F_CHAT,self::O_WE)) $this->capSet(self::F_CHAT,self::O_YES); */ // No dupes mode is preferred on BINKP 1.1 if ($this->capGet(self::F_NODUPE,self::O_WE) || ($this->originate && $this->capGet(self::F_NOREL,self::O_WANT) && $this->node->get_versionint() > 101)) { Log::debug(sprintf('%s:/ NR mode enabled, because we are in NDA mode, or I want NDA and the remote is version [%d]',self::LOGKEY,$this->node->get_versionint())); $this->capSet(self::F_NOREL,self::O_YES); } if ((($this->node->get_versionint() > 100) && $this->capGet(self::F_MULTIBATCH,self::O_WANT)) || $this->capGet(self::F_MULTIBATCH,self::O_WE)) { Log::debug(sprintf('%s:/ MB mode enabled, because we agree to MB mode, or I want MB and the remote is version [%d]',self::LOGKEY,$this->node->get_versionint())); $this->capSet(self::F_MULTIBATCH,self::O_YES); } if (($this->node->get_versionint() > 100) && (! $this->capGet(self::F_MULTIBATCH,self::O_YES))) $this->sessionClear(self::SE_DELAYEOB); $this->mib = 0; $this->sessionClear(self::SE_INIT); Log::info(sprintf('%s:= Session: BINKP/%d.%d - NR:%d, ND:%d, NDA:%d, MD:%d, MB:%d, CR:%d, CO:%d, CH:%d', self::LOGKEY, $this->node->ver_major, $this->node->ver_minor, $this->capGet(self::F_NOREL,self::O_YES), $this->capGet(self::F_NODUPE,self::O_WE), $this->capGet(self::F_NODUPEA,self::O_WE), $this->capGet(self::F_MD,self::O_WE), $this->capGet(self::F_MULTIBATCH,self::O_YES), $this->capGet(self::F_CRYPT,self::O_YES), $this->capGet(self::F_COMP,self::O_WE), $this->capGet(self::F_CHAT,self::O_WE), )); return TRUE; } private function binkp_init(): int { $this->sessionSet(self::SE_INIT); $this->is_msg = -1; $this->mib = 0; $this->error = 0; $this->mqueue = collect(); $this->rx_size = -1; $this->tx_buf = ''; $this->tx_left = 0; // @todo can we replace this with strlen($tx_buf)? $this->tx_ptr = 0; // @todo is this required? // Setup our default capabilities $this->md_challenge = ''; // We cant do chat $this->capSet(self::F_CHAT,self::O_NO); // Compression if ($this->setup->optionGet(self::F_COMP,'binkp_options')) $this->capSet(self::F_COMP,self::O_WANT|self::O_EXT); // CRAM-MD5 session if ($this->setup->optionGet(self::F_MD,'binkp_options')) { $this->capSet(self::F_MD,self::O_WANT); if ($this->setup->optionGet(self::F_MDFORCE,'binkp_options')) $this->capSet(self::F_MD,self::O_NEED); } // Crypt Mode if ($this->setup->optionGet(self::F_CRYPT,'binkp_options')) $this->capSet(self::F_CRYPT,self::O_WANT); // Multibatch if ($this->setup->optionGet(self::F_MULTIBATCH,'binkp_options')) $this->capSet(self::F_MULTIBATCH,self::O_WANT); // Non reliable mode if ($this->setup->optionGet(self::F_NOREL,'binkp_options')) { $this->capSet(self::F_NOREL,self::O_WANT); // No dupes if ($this->setup->optionGet(self::F_NODUPE,'binkp_options')) { $this->capSet(self::F_NODUPE,self::O_WANT); // No dupes asymmetric if ($this->setup->optionGet(self::F_NODUPEA,'binkp_options')) $this->capSet(self::F_NODUPEA,self::O_WANT); } } return self::OK; } /** * Receive data from the remote * * @throws \Exception */ private function binkp_recv(): bool { $blksz = $this->rx_size === -1 ? BinkpMessage::BLK_HDR_SIZE : $this->rx_size; Log::debug(sprintf('%s:+ BINKP receive, reading [%d] chars',self::LOGKEY,$blksz)); if ($blksz !== 0) { try { Log::debug(sprintf('%s:- We need [%d] more chars, buffer currently has [%d] chars',self::LOGKEY,$blksz,strlen($this->rx_buf))); $rx_buf = $this->client->read(0,$blksz-strlen($this->rx_buf)); Log::debug(sprintf('%s:- Got [%d] more chars for the read buffer',self::LOGKEY,strlen($rx_buf))); } catch (SocketException $e) { if ($e->getCode() === 11) { // @todo We maybe should count these and abort if there are too many? if (static::DEBUG) Log::debug(sprintf('%s:- Got a socket EAGAIN',self::LOGKEY)); return TRUE; } $this->error = 1; Log::error(sprintf('%s:! Reading we got an EXCEPTION [%d-%s]',self::LOGKEY,$e->getCode(),$e->getMessage())); return FALSE; } if (strlen($rx_buf) === 0) { // @todo Check that this is correct. Log::debug(sprintf('%s:- Was the socket closed by the remote?',self::LOGKEY)); $this->error = -2; return FALSE; } if ($this->capGet(self::F_CRYPT,self::O_YES)) { Log::debug(sprintf('%s:%% Decrypting data from remote.',self::LOGKEY)); $this->rx_buf .= $this->crypt_in->decrypt($rx_buf); } else { $this->rx_buf .= $rx_buf; } } Log::debug(sprintf('%s:- Read buffer has [%d] chars to process.',self::LOGKEY,strlen($this->rx_buf))); /* Received complete block */ if (strlen($this->rx_buf) === $blksz) { /* Header */ if ($this->rx_size === -1 ) { $this->is_msg = ord(substr($this->rx_buf,0,1)) >> 7; // If compression is used, then this needs to be &0x3f, since the 2nd high bit is the compression flag // @todo Need to see what happens, if we are receiving a block higher than 0x3fff. Possible? $this->rx_size = ((ord(substr($this->rx_buf,0,1))&0x7f) << 8)+ord(substr($this->rx_buf,1,1)); Log::debug(sprintf('%s:- BINKP receive HEADER, is_msg [%d], rx_size [%d]',self::LOGKEY,$this->is_msg,$this->rx_size)); if ($this->rx_size === 0) goto ZeroLen; $rc = TRUE; /* Next block */ } else { ZeroLen: if ($this->is_msg) { $this->mib++; /* Handle zero length block */ if ($this->rx_size === 0 ) { Log::debug(sprintf('%s:- Received a ZERO length msg - dropped',self::LOGKEY)); $this->rx_size = -1; $this->rx_buf = ''; return TRUE; } if (static::DEBUG) Log::debug(sprintf('%s: - binkp_recv BUFFER [%d]',self::LOGKEY,strlen($this->rx_buf))); $msg = ord(substr($this->rx_buf,0,1)); if ($msg > self::BPM_MAX) { Log::error(sprintf('%s:! Unknown message received [%d] (%d-%s)',self::LOGKEY,$msg,strlen($this->rx_buf),$this->rx_buf)); $rc = TRUE; } else { // http://ftsc.org/docs/fts-1026.001 - frames may be NULL terminated $data = rtrim(substr($this->rx_buf,1),"\x00"); switch ($msg) { case self::BPM_ADR: Log::debug(sprintf('%s:- ADR:Address [%s]',self::LOGKEY,$data)); // @note It seems taurus may pad data with nulls at the end (esp BPM_ADR), so we should trim that. $rc = $this->M_adr(trim($data)); break; case self::BPM_EOB: Log::debug(sprintf('%s:- EOB:We got an EOB message with [%d] chars in the buffer',self::LOGKEY,strlen($data))); if (strlen($data)) Log::critical(sprintf('%s:! EOB but we have data?',self::LOGKEY),['data'=>$data]); $rc = $this->M_eob(); break; case self::BPM_NUL: Log::debug(sprintf('%s:- NUL:Message [%s]',self::LOGKEY,$data)); $rc = $this->M_nul($data); break; case self::BPM_PWD: Log::debug(sprintf('%s:- PWD:We got a password [%s]',self::LOGKEY,$data)); $rc = $this->M_pwd(ltrim($data)); break; case self::BPM_ERR: Log::debug(sprintf('%s:- ERR:We got an error [%s]',self::LOGKEY,$data)); $rc = $this->M_err($data); break; case self::BPM_FILE: Log::debug(sprintf('%s:- FIL:We are receiving a file [%s]',self::LOGKEY,$data)); $rc = $this->M_file($data); break; case self::BPM_GET: Log::debug(sprintf('%s:- GET:We are sending a file [%s]',self::LOGKEY,$data)); $rc = $this->M_get($data); break; case self::BPM_GOTSKIP: Log::debug(sprintf('%s:- GOT:Remote received, or already has a file [%s]',self::LOGKEY,$data)); $rc = $this->M_gotskip($data); break; case self::BPM_OK: Log::debug(sprintf('%s:- OK:Got an OK [%s]',self::LOGKEY,$data)); $rc = $this->M_ok(ltrim($data)); break; case self::BPM_CHAT: Log::debug(sprintf('%s:- CHT:Remote sent a message [%s]',self::LOGKEY,$data)); $rc = $this->M_chat($data); break; default: Log::error(sprintf('%s:! BINKP command not implemented [%d]',self::LOGKEY,$msg)); $rc = TRUE; } } } else { if ($this->recv->fd) { try { $this->recv->write($this->rx_buf); } catch (FileGrewException $e) { // Retry the file without compression Log::error(sprintf('%s:! %s',self::LOGKEY,$e->getMessage())); $this->msgs(self::BPM_GET,sprintf('%s %ld NZ',$this->recv->name_size_time,$this->recv->pos)); } catch (\Exception $e) { Log::error(sprintf('%s:! %s',self::LOGKEY,$e->getMessage())); $this->recv->close(); $this->msgs(self::BPM_SKIP,$this->recv->name_size_time); } $rc = TRUE; if ($this->recv->pos === $this->recv->recvsize) { Log::info(sprintf('%s:- Finished receiving file [%s] with size [%d]',self::LOGKEY,$this->recv->nameas,$this->recv->recvsize)); $this->msgs(self::BPM_GOTSKIP,$this->recv->name_size_time); $this->recv->close(); } } else { Log::critical(sprintf('%s:- Ignoring data block, we dont have a received FD open?', self::LOGKEY)); $rc = TRUE; } } $this->rx_size = -1; } $this->rx_buf = ''; } else { $rc = TRUE; } if (static::DEBUG) Log::debug(sprintf('%s:= binkp_recv [%d]',self::LOGKEY,$rc)); return $rc; } /** * @throws \Exception */ private function binkp_send(): int { Log::debug(sprintf('%s:+ BINKP send, TX buffer has [%d] chars (%d), and [%d] messages queued',self::LOGKEY,strlen($this->tx_buf),$this->tx_left,$this->mqueue->count())); if ($this->tx_left === 0 ) { /* tx buffer is empty */ $this->tx_ptr = 0; if ($this->mqueue->count()) { /* there are unsent messages */ while ($msg=$this->mqueue->shift()) { if ($msg instanceof BinkpMessage) { if (($msg->len+$this->tx_left) > self::MAX_BLKSIZE) { Log::alert(sprintf('%s:! MSG [%d] would overflow our buffer [%d]',self::LOGKEY,$msg->len,$this->tx_left)); break; } Log::debug(sprintf('%s:- TX buffer empty, adding [%d] chars from the queue',self::LOGKEY,$msg->len)); $this->tx_buf .= $msg->msg; $this->tx_left += $msg->len; } else { $this->tx_buf .= $msg; $this->tx_left += strlen($msg); } } } elseif ($this->sessionGet(self::SE_SENDFILE) && $this->send->fd && (! $this->sessionGet(self::SE_WAITGET))) { try { $buf = $this->send->read(self::BLOCKSIZE); } catch (UnreadableFileEncountered) { $this->send->close(FALSE,$this->node); $this->sessionClear(self::SE_SENDFILE); } catch (\Exception $e) { Log::error(sprintf('%s:! BINKP send unexpected ERROR [%s]',self::LOGKEY,$e->getMessage())); throw new \Exception($e->getMessage()); } if ($buf) { $data = BinkpMessage::mkheader(strlen($buf)); $data .= $buf; if ($this->capGet(self::F_CRYPT,self::O_YES)) { $enc = $this->crypt_out->encrypt($data); $this->tx_buf .= $enc; $this->tx_left = strlen($enc); } else { $this->tx_buf .= $data; $this->tx_left = strlen($buf)+BinkpMessage::BLK_HDR_SIZE; } } // @todo should this be less than BLOCKSIZE? Since a read could return a blocksize and it could be the end of the file? if ($this->send->pos === $this->send->size) { $this->sessionSet(self::SE_WAITGOT); $this->sessionClear(self::SE_SENDFILE); } } } else { try { Log::debug(sprintf('%s:- Sending [%d] chars to remote: tx_buf [%d], tx_ptr [%d]',self::LOGKEY,$this->tx_left,strlen($this->tx_buf),$this->tx_ptr)); $rc = $this->client->send(substr($this->tx_buf,$this->tx_ptr,$this->tx_left),self::TIMEOUT_TIME); Log::debug(sprintf('%s:- Sent [%d] chars to remote',self::LOGKEY,$rc)); } catch (\Exception $e) { if ($e->getCode() === 11) { Log::error(sprintf('%s:! Got a socket EAGAIN',self::LOGKEY)); return 1; } Log::error(sprintf('%s:! Sending we got an EXCEPTION [%d-%s]',self::LOGKEY,$e->getCode(),$e->getMessage())); return 0; } $this->tx_ptr += $rc; $this->tx_left -= $rc; if (! $this->tx_left) { $this->tx_buf = ''; $this->tx_ptr = 0; } } return 1; } private function file_parse(string $str): ?array { $name = $this->strsep($str,' '); $size = (int)$this->strsep($str,' '); $time = (int)$this->strsep($str,' '); $offs = (int)$this->strsep($str,' '); $flags = $this->strsep($str,' '); if ($name && $size && $time) { return [ 'file'=>['name'=>$name,'size'=>$size,'mtime'=>$time], 'offs'=>$offs, 'flags'=>$flags, ]; } return NULL; } /** * Add a BINKP control message to the queue * * @param string $id * @param string $msg_body * @return void */ private function msgs(string $id,string $msg_body): void { Log::debug(sprintf('%s:+ Queueing message to remote [%d:%s]',self::LOGKEY,$id,$msg_body)); $msg = new BinkpMessage($id,$msg_body); // If encryption is enabled, we need to queue the encrypted version of the message // @todo rework this so queue only has data, not objects if ($this->capGet(self::F_CRYPT,self::O_YES)) { $enc = $this->crypt_out->encrypt($msg->msg); $this->mqueue->push($enc); } else { $this->mqueue->push($msg); } $this->mib++; } /** * @throws \Exception */ private function M_adr(string $buf): bool { $rc = 0; while ($rem_aka=$this->strsep($buf,' ')) { try { if (! ($o=Address::findFTN($rem_aka,TRUE))) { // @todo when we have multiple inactive records, this returns more than 1, so pluck the active record if there is one Log::alert(sprintf('%s:? AKA is UNKNOWN [%s]',self::LOGKEY,$rem_aka)); $this->node->ftn_other = $rem_aka; continue; // If we only present limited AKAs dont validate password against akas outside of the domains we present } elseif (is_null(our_address($o))) { Log::alert(sprintf('%s:/ AKA domain [%s] is not in our domain(s) [%s] - ignoring',self::LOGKEY,$o->zone->domain->name,our_address()->pluck('zone.domain.name')->unique()->join(','))); $this->node->ftn_other = $rem_aka; continue; } elseif (! $o->active) { Log::alert(sprintf('%s:/ AKA is not active [%s] - ignoring',self::LOGKEY,$rem_aka)); $this->node->ftn_other = $rem_aka; continue; } else { Log::info(sprintf('%s:- Got AKA [%s]',self::LOGKEY,$rem_aka)); // We'll update this address status $o->validated = TRUE; $o->role &= ~(Address::NODE_HOLD|Address::NODE_DOWN); $o->save(); } } catch (InvalidFTNException $e) { Log::error(sprintf('%s:! AKA is INVALID [%s] (%s) - ignoring',self::LOGKEY,$rem_aka,$e->getMessage())); continue; } catch (\Exception $e) { Log::error(sprintf('%s:! AKA is INVALID [%s] (%d:%s-%s)',self::LOGKEY,$rem_aka,$e->getLine(),$e->getFile(),$e->getMessage())); $this->msgs(self::BPM_ERR,sprintf('Bad address %s',$rem_aka)); $this->rc = self::S_FAILURE; return FALSE; } // Check if the remote has our AKA if ($this->setup->system->addresses->pluck('ftn')->search($rem_aka) !== FALSE) { Log::error(sprintf('%s:! Remote\'s AKA is mine [%s]?',self::LOGKEY,$rem_aka)); $this->msgs(self::BPM_ERR,sprintf('Sorry that is my AKA [%s], who are you?',$rem_aka)); $this->rc = self::S_FAILURE; return FALSE; } // @todo lock nodes $this->node->ftn = $o; $rc = $this->node->aka_num; } if ($rc === 0) { Log::error(sprintf('%s:! All AKAs [%d] are busy',self::LOGKEY,$this->node->aka_num)); $this->msgs( self::BPM_BSY,'All AKAs are busy, nothing to do :('); $this->rc = self::S_BUSY; return FALSE; } if ($this->originate) { if (! $this->node->originate_check()) { Log::error(sprintf('%s:! We didnt get who we called?',self::LOGKEY)); $this->msgs( self::BPM_ERR,'Sorry, you are not who I expected'); $this->rc = self::S_FAILURE; return 0; } /** * http://ftsc.org/docs/fts-1026.001 * M_NUL "TRF netmail_bytes arcmail_bytes" * traffic prognosis (in bytes) for the netmail * (netmail_bytes) and arcmail + files (arcmail_bytes), * both are decimal ASCII strings */ // @todo This is affectively redundant, because we are not determining our mail until later $this->msgs(self::BPM_NUL,sprintf('TRF %lu %lu',$this->send->mail_size,$this->send->files_size)); if ($this->md_challenge) { Log::info(sprintf('%s:! Sending MD5 challenge',self::LOGKEY)); $this->msgs(self::BPM_PWD,sprintf('CRAM-MD5-%s',$this->node->get_md5chal($this->md_challenge))); } elseif ($this->capGet(self::F_MD,self::O_NEED)) { Log::error(sprintf('%s:! Node wants plaintext, but we insist on MD5 challenges',self::LOGKEY)); $this->msgs(self::BPM_ERR,'Can\'t use plaintext password'); $this->rc = self::S_FAILURE; return 0; } else { Log::info(sprintf('%s:! Sending plain text password',self::LOGKEY)); $this->msgs(self::BPM_PWD,$this->node->password ?: ''); } } if (! $this->node->aka_num) $this->optionClear(self::O_PWD); else $this->optionSet(self::O_PWD); // If we are not the originator, we'll show our addresses in common. if (! $this->originate) $this->msgs(self::BPM_ADR,$this->our_addresses()->pluck('ftn')->join(' ')); return TRUE; } private function M_chat(string $buf): bool { if ($this->capGet(self::F_CHAT,self::O_YES)) { Log::error(sprintf('%s:! We cannot do chat',self::LOGKEY)); } else { Log::error(sprintf('%s:! We got a chat message, but chat is disabled (%s)',self::LOGKEY,strlen($buf)),['buf'=>$buf]); } return TRUE; } /** * We received EOB from the remote. * * @throws \Exception */ private function M_eob(): bool { if ($this->recv->fd) { Log::info(sprintf('%s:= Closing receiving file.',self::LOGKEY)); $this->recv->close(); } $this->sessionSet(self::SE_RECVEOB); $this->sessionClear(self::SE_DELAYEOB); if (! $this->send->togo_count && $this->sessionGet(self::SE_NOFILES) && $this->capGet(self::F_MULTIBATCH,self::O_YES)) { $this->getFiles($this->node); if ($this->send->togo_count) $this->sessionClear(self::SE_NOFILES|self::SE_SENTEOB); } return TRUE; } /** * @throws \Exception */ private function M_err(string $buf): bool { Log::error(sprintf('%s:! We got an error, there are [%d] chars in the buffer (%s)',self::LOGKEY,strlen($buf),$buf)); $this->error_close(); $this->rc = self::S_FAILURE; return TRUE; } /** * @throws \Exception */ private function M_file(string $buf): bool { Log::info(sprintf('%s:+ About to receive a file [%s]',self::LOGKEY,$buf)); if ($this->sessionGet(self::SE_SENTEOB) && $this->sessionGet(self::SE_RECVEOB)) $this->sessionClear(self::SE_SENTEOB); $this->sessionClear(self::SE_RECVEOB); //if ($this->recv->fd) // $this->recv->close(); if (! ($file=$this->file_parse($buf))) { Log::error(sprintf('%s:! UNPARSABLE file info [%s]',self::LOGKEY,$buf)); $this->msgs(self::BPM_ERR,sprintf('M_FILE: unparsable file info: "%s", what are you on?',$buf)); if ($this->sessionGet(self::SE_SENDFILE)) $this->send->close(FALSE,$this->node); $this->rc = self::S_FAILURE; return FALSE; } // In NR mode, when we got -1 for the file offsite, the reply to our get will confirm our requested offset. if ($this->recv->ready && $this->recv->nameas && (! strncasecmp(Arr::get($file,'file.name'),$this->recv->nameas,self::MAX_PATH)) && $this->recv->recvmtime === Arr::get($file,'file.mtime') && $this->recv->recvsize === Arr::get($file,'file.size') && $this->recv->pos === $file['offs']) { $this->recv->open($file['offs']<0,$file['flags']); return TRUE; } $this->recv->new($file['file'],$this->node->address,$this->force_queue); try { switch ($this->recv->open($file['offs']<0,$file['flags'])) { case self::FOP_ERROR: Log::error(sprintf('%s:! File ERROR',self::LOGKEY)); case self::FOP_SUSPEND: case self::FOP_SKIP: Log::info(sprintf('%s:- File Skipped',self::LOGKEY)); $this->msgs(self::BPM_SKIP,$this->recv->name_size_time); // Close the file, since we are skipping it. $this->recv->close(); break; case self::FOP_GOT: Log::info(sprintf('%s:- File skipped, we already have it',self::LOGKEY)); $this->msgs(self::BPM_GOTSKIP,$this->recv->name_size_time); // Close the file, since we already have it. $this->recv->close(); break; case self::FOP_CONT: Log::debug(sprintf('%s:- Continuing file [%s] from (%ld)',self::LOGKEY,$this->recv->name,$file['offs'])); case self::FOP_OK: Log::debug(sprintf('%s:- Getting file from offset [%ld]',self::LOGKEY,$file['offs'])); if (((int)$file['offs'] === -1) && $this->capGet(self::F_NOREL,self::O_WANT)) { Log::debug(sprintf('%s:- Assuming the remote wants NR mode, since offset is [%d] and they didnt specify an OPT with it',self::LOGKEY,$file['offs'])); $this->capSet(self::F_NOREL,self::O_YES); } if ($this->capGet(self::F_NOREL,self::O_YES)) $this->msgs(self::BPM_GET,sprintf('%s %ld',$this->recv->name_size_time,($file['offs'] < 0) ? 0 : $file['offs'])); break; } } catch (\Exception $e) { Log::error(sprintf('%s:! File Open ERROR [%s]',self::LOGKEY,$e->getMessage())); $this->msgs(self::BPM_SKIP,$this->recv->name_size_time); // Close the file, since we had an error opening it. if ($this->recv->fd) $this->recv->close(); } return TRUE; } /** * @throws \Exception */ private function M_get(string $buf): bool { Log::debug(sprintf('%s:+ Sending file [%s]',self::LOGKEY,$buf)); if ($file=$this->file_parse($buf)) { if ($this->sessionGet(self::SE_SENDFILE) && $this->send->nameas && ! strncasecmp(Arr::get($file,'file.name'),$this->send->nameas,self::MAX_PATH) && $this->send->mtime === Arr::get($file,'file.mtime') && $this->send->size === Arr::get($file,'file.size')) { if (! $this->send->seek($file['offs'])) { Log::error(sprintf('%s:! Cannot send file from requested offset [%d]',self::LOGKEY,$file['offs'])); $this->msgs(self::BPM_ERR,'Can\'t send file from requested offset'); $this->send->close(FALSE,$this->node); $this->sessionClear(self::SE_SENDFILE); } else { $this->sessionClear(self::SE_WAITGET); Log::debug(sprintf('%s:Sending file [%s] as [%s]',self::LOGKEY,$this->send->name,$this->send->nameas)); $this->msgs(self::BPM_FILE,sprintf('%s %lu %ld %lu %s',$this->send->nameas,$this->send->size,$this->send->mtime,$file['offs'],$file['flags'])); } } else { Log::error(sprintf('%s:! Remote requested an unknown file [%s]',self::LOGKEY,$buf)); } } else { Log::error(sprintf('%s:! UNPARSABLE file info [%s]',self::LOGKEY,$buf)); } return TRUE; } /** * M_GOT/M_SKIP commands * * @param string $buf * @return bool * @throws \Exception */ private function M_gotskip(string $buf): bool { Log::debug(sprintf('%s:+ Remote confirms receipt for file [%s]',self::LOGKEY,$buf)); if ($file = $this->file_parse($buf)) { if ($this->send->nameas && ! strncasecmp(Arr::get($file,'file.name'),$this->send->nameas,self::MAX_PATH) && $this->send->mtime === Arr::get($file,'file.mtime') && $this->send->size === Arr::get($file,'file.size')) { if ((! $this->sessionGet(self::SE_SENDFILE)) && (! $this->sessionGet(self::SE_WAITGOT))) { Log::error(sprintf('%s:! M_got[skip] for unknown file [%s]',self::LOGKEY,$buf)); } else { Log::info(sprintf('%s:= Packet/File [%s], type [%d] sent.',self::LOGKEY,$this->send->nameas,$this->send->type)); $this->sessionClear(self::SE_WAITGOT|self::SE_SENDFILE); $this->send->close(TRUE,$this->node); } } else { Log::error(sprintf('%s:! M_got[skip] not for our file? [%s]',self::LOGKEY,$buf)); } } else { Log::error(sprintf('%s:! UNPARSABLE file info [%s]',self::LOGKEY,$buf)); } return TRUE; } /** * @throws \Exception */ private function M_nul(string $buf): bool { Log::info(sprintf('%s:+ M_NUL [%s]',self::LOGKEY,$buf)); if (! strncmp($buf,'SYS ',4)) { $this->node->system = ltrim(substr($buf,4)); } elseif (! strncmp($buf, 'ZYZ ',4)) { $this->node->sysop = ltrim(substr($buf,4)); } elseif (! strncmp($buf,'LOC ',4)) { $this->node->location = ltrim(substr($buf,4)); } elseif (! strncmp($buf,'NDL ',4)) { $data = ltrim(substr($buf,4)); $comma = strpos($data,','); $spd = substr($data,0,$comma); if ($comma) $this->node->flags = substr($data,$comma+1); if ($spd >= 300) { $this->client->speed = $spd; } else { $comma = ltrim(substr($buf,4)); $c = 0; while (($x=substr($comma,$c,1)) && is_numeric($x)) $c++; $comma = substr($comma,0,$c); if (! $comma) { $this->client->speed = self::TCP_SPEED; } elseif (strtolower(substr($comma,$c+1,1)) === 'k') { $this->client->speed = $spd * 1024; } elseif (strtolower(substr($comma,$c+1,1)) === 'm') { $this->client->speed = $spd * 1024 * 1024; } else { $this->client->speed = self::TCP_SPEED; } } } elseif (! strncmp($buf,'TIME ',5)) { $this->node->node_time = ltrim(substr($buf,5)); } elseif (! strncmp($buf,'VER ',4)) { $data = ltrim(substr($buf,4)); $matches = []; preg_match('#^(.+)\s+\(?binkp/([0-9]+)\.([0-9]+)\)?$#',$data,$matches); if (count($matches) === 4) { $this->node->software = $matches[1]; $this->node->ver_major = $matches[2]; $this->node->ver_minor = $matches[3]; } else { $this->node->software = 'Unknown'; $this->node->ver_major = 0; $this->node->ver_minor = 0; } } elseif (! strncmp($buf,'TRF ',4)) { $data = ltrim(substr($buf,4)); $matches = []; preg_match('/^([0-9]+)\s+([0-9]+)$/',$data,$matches); $this->node->netmail = isset($matches[1]) ?: 0; $this->node->files = isset($matches[2]) ?: 0; if ($this->node->netmail + $this->node->files) $this->sessionSet(self::SE_DELAYEOB); } elseif (! strncmp($buf,'FREQ',4)) { $this->sessionSet(self::SE_DELAYEOB); } elseif (! strncmp($buf,'PHN ',4)) { $this->node->phone = ltrim(substr($buf,4)); } elseif (! strncmp($buf,'OPM ',4)) { $this->node->message = ltrim(substr($buf,4)); } elseif (! strncmp($buf,'OPT ',4)) { $data = ltrim(substr($buf,4)); while ($data && ($p = $this->strsep($data,' '))) { if (! strcmp($p,'MB')) { Log::info(sprintf('%s:- Remote wants MULTIBATCH mode',self::LOGKEY)); $this->capSet(self::F_MULTIBATCH,self::O_THEY); } elseif (! strcmp($p,'ND')) { Log::info(sprintf('%s:- Remote wants NO DUPES mode',self::LOGKEY)); $this->capSet(self::F_NOREL,self::O_THEY); $this->capSet(self::F_NODUPE,self::O_THEY); } elseif (! strcmp($p,'NDA')) { Log::info(sprintf('%s:- Remote wants NO DUPES ASYMMETRIC mode',self::LOGKEY)); $this->capSet(self::F_NOREL, self::O_THEY); $this->capSet(self::F_NODUPE, self::O_THEY); $this->capSet(self::F_NODUPEA, self::O_THEY); } elseif (! strcmp($p,'NR')) { Log::info(sprintf('%s:- Remote wants NON RELIABLE MODE mode',self::LOGKEY)); $this->capSet(self::F_NOREL,self::O_THEY); } elseif (! strcmp($p,'CHAT')) { Log::info(sprintf('%s:- Remote wants CHAT mode',self::LOGKEY)); $this->capSet(self::F_CHAT,self::O_THEY); } elseif (! strcmp($p,'CRYPT')) { Log::info(sprintf('%s:- Remote wants CRYPT mode',self::LOGKEY)); $this->capSet(self::F_CRYPT,self::O_THEY); } elseif (! strcmp($p,'GZ')) { Log::info(sprintf('%s:- Remote wants GZ compression',self::LOGKEY)); $this->capSet(self::F_COMP,self::O_THEY); } elseif (! strcmp($p,'BZ2')) { Log::info(sprintf('%s:- Remote wants BZ2 compression',self::LOGKEY)); $this->capSet(self::F_COMP,self::O_THEY|self::O_EXT); } elseif (! strncmp($p,'CRAM-MD5-',9) && $this->originate && $this->capGet(self::F_MD,self::O_WANT)) { if (strlen($hex=substr($p,9)) > 64 ) { Log::error(sprintf('%s:! The challenge string is TOO LONG [%d] (%s)',self::LOGKEY,strlen($hex),$p)); } elseif (strlen($hex)%2) { Log::error(sprintf('%s:! The challenge string is an odd size [%d] (%s)',self::LOGKEY,strlen($hex),$hex)); } else { Log::info(sprintf('%s:- Remote wants MD5 auth with [%s]',self::LOGKEY,$hex)); $this->md_challenge = hex2bin($hex); if ($this->md_challenge) $this->capSet(self::F_MD,self::O_THEY); } if ($this->capGet(self::F_MD,self::O_WE)) $this->capSet(self::F_MD,self::O_YES); } else { Log::warning(sprintf('%s:/ Ignoring UNSUPPORTED option [%s]',self::LOGKEY,$p)); } } } else { Log::warning(sprintf('%s:/ M_nul Got UNKNOWN NUL [%s]',self::LOGKEY,$buf)); } return TRUE; } /** * Remote accepted our password * * @throws \Exception */ private function M_ok(string $buf): bool { Log::debug(sprintf('%s:+ M_ok [%s]',self::LOGKEY,$buf)); if (! $this->originate) { Log::error(sprintf('%s:! UNEXPECTED M_OK [%s] from remote on incoming call',self::LOGKEY,$buf)); $this->rc = self::S_FAILURE; return FALSE; } if ($this->optionGet(self::O_PWD) && $buf) { while (($t=$this->strsep($buf," \t"))) if (strcmp($t,'non-secure') === 0) { Log::info(sprintf('%s:- NOT secure',self::LOGKEY)); $this->capSet(self::F_CRYPT,self::O_NO); $this->optionClear(self::O_PWD); break; } else { Log::debug(sprintf('%s:? Got unknown string from M_ok [%s]',self::LOGKEY,$t)); } } if ($this->optionGet(self::O_PWD)) Log::info(sprintf('%s:- SECURE',self::LOGKEY)); return $this->binkp_hsdone(); } /** * @todo It appears when we poll a node, we dont ask for passwords, but we still send echomail and files. */ private function M_pwd(string $buf): bool { $have_CRAM = !strncasecmp($buf,'CRAM-MD5-',9); $have_pwd = $this->optionGet(self::O_PWD); if ($this->originate) { Log::error(sprintf('%s:! Unexpected password [%s] from remote on OUTGOING call',self::LOGKEY,$buf)); $this->rc = self::S_FAILURE; return FALSE; } if ($this->md_challenge) { if ($have_CRAM) { // Loop to match passwords $x = $this->node->auth(substr($buf,9),$this->md_challenge); $this->capSet(self::F_MD,self::O_THEY); Log::info(sprintf('%s:- We authed [%d] akas',self::LOGKEY,$x)); } elseif ($this->capGet(self::F_MD,self::O_NEED)) { Log::error(sprintf('%s:! Remote doesnt support MD5, but we want it',self::LOGKEY)); $this->msgs( self::BPM_ERR,'You must support MD5 auth to talk to me'); $this->rc = self::S_FAILURE; return FALSE; } } if (! $this->md_challenge || (! $have_CRAM && (! $this->capGet(self::F_MD,self::O_NEED)))) { // Loop to match passwords $x = $this->node->auth($buf); Log::info(sprintf('%s:- We authed [%d] akas',self::LOGKEY,$x)); } if ($have_pwd) { // If no passwords matched (ie: aka_authed is 0), but we know this system if ((! $this->node->aka_authed) && ($this->node->aka_remote->count())) { Log::error(sprintf('%s:! Bad password [%s]',self::LOGKEY,$buf)); $this->optionSet(self::O_BAD); $this->rc = self::S_FAILURE; return FALSE; } } elseif (! $this->node->aka_authed) { Log::notice(sprintf('%s:= Remote proposed password for us [%s]',self::LOGKEY,$buf)); } // We dont use crypt if we dont have an MD5 sessions if (! $have_pwd && (! $this->capGet(self::F_MD,self::O_YES))) { Log::notice(sprintf('%s:= CRYPT disabled, since we have no password or not MD5',self::LOGKEY)); $this->capSet(self::F_CRYPT,self::O_NO); } $opt = ''; if ($this->capGet(self::F_NOREL,self::O_WE) && $this->capGet(self::F_NODUPE,self::O_WE) && $this->capGet(self::F_NODUPEA,self::O_YES)) $opt .= ' NDA'; elseif ($this->capGet(self::F_NOREL,self::O_WE) && $this->capGet(self::F_NODUPE,self::O_WE)) $opt .= ' ND'; elseif ($this->capGet(self::F_NOREL,self::O_WE)) $opt .= ' NR'; $opt .= $this->capGet(self::F_MULTIBATCH,self::O_WE) ? ' MB' : ''; $opt .= $this->capGet(self::F_CHAT,self::O_WE) ? ' CHAT' : ''; if ($this->capGet(self::F_COMP,self::O_WE) && $this->capGet(self::F_COMP,self::O_EXT)) { $this->comp_mode = 'BZ2'; $opt .= ' EXTCMD BZ2'; } elseif ($this->capGet(self::F_COMP,self::O_WE)) { $this->comp_mode = 'GZ'; $opt .= ' EXTCMD GZ'; } $opt .= $this->capGet(self::F_CRYPT,self::O_WE) ? ' CRYPT' : ''; if (strlen($opt)) $this->msgs(self::BPM_NUL,sprintf('OPT%s',$opt)); // @todo This is effectively redundant, because we are not getting files until later $this->msgs(self::BPM_NUL,sprintf('TRF %lu %lu',$this->send->mail_size,$this->send->files_size)); if ($this->node->aka_authed) { $this->msgs(self::BPM_OK,sprintf('%ssecure',$have_pwd ? '' : 'non-')); } else { $this->msgs(self::OK,'non-secure'); } return $this->binkp_hsdone(); } protected function protocol_init(): int { // Not Used return 0; } /** * Set up our BINKP session * * @param bool $force_queue * @return int * @throws \Exception */ protected function protocol_session(bool $force_queue=FALSE): int { if ($this->binkp_init() !== self::OK) return self::S_FAILURE; $this->force_queue = $force_queue; if (! $this->binkp_hs()) return self::S_FAILURE; while (TRUE) { if ((! $this->sessionGet(self::SE_INIT)) && (! $this->sessionGet(self::SE_SENDFILE)) && (! $this->sessionGet(self::SE_SENTEOB)) && (! $this->sessionGet(self::SE_NOFILES)) && (! $this->send->fd)) { if (! $this->send->togo_count) $this->getFiles($this->node); // Open our next file to send if ($this->send->togo_count && ! $this->send->fd) { Log::info(sprintf('%s:- Opening next file to send - we have [%d] left',self::LOGKEY,$this->send->togo_count)); $this->send->open(); } // We have an open file descriptor, set our mode to send if ($this->send->fd) { $this->sessionSet(self::SE_SENDFILE); // NR mode, we wait for an M_GET before sending if ($this->capGet(self::F_NOREL,self::O_YES)) { $this->sessionSet(self::SE_WAITGET); Log::debug(sprintf('%s:- NR mode, waiting for M_GET',self::LOGKEY)); } $this->msgs(self::BPM_FILE, sprintf('%s %lu %lu %ld %s', $this->send->nameas, $this->send->size, $this->send->mtime, $this->sessionGet(self::SE_WAITGET) ? -1 : 0, /*$this->send->comp ?:*/ '')); $this->sessionClear(self::SE_SENTEOB); // We dont have anything to send } else { Log::info(sprintf('%s:- Nothing left to send in this batch',self::LOGKEY)); // @todo We should look for more mail/files before thinking about sending an EOB // IE: When we are set to only send X messages, but we have > X to send, get the next batch. $this->sessionSet(self::SE_NOFILES); } } if ((! $this->sessionGet(self::SE_INIT)) && (! $this->sessionGet(self::SE_WAITGOT)) && (! $this->sessionGet(self::SE_SENTEOB)) && (! $this->sessionGet(self::SE_DELAYEOB)) && $this->sessionGet(self::SE_NOFILES)) { Log::info(sprintf('%s:- Sending EOB',self::LOGKEY)); $this->msgs(self::BPM_EOB,''); $this->sessionSet(self::SE_SENTEOB); } $this->rc = self::S_OK; if ($this->sessionGet(self::SE_SENTEOB) && $this->sessionGet(self::SE_RECVEOB)) { Log::info(sprintf('%s:- EOBs sent and received',self::LOGKEY),['m'=>$this->mib,'remote_version'=>$this->node->get_versionint()]); if ($this->mib < 3 || $this->node->get_versionint() <= 100) { break; } Log::info(sprintf('%s:- EOBs sent and received CLEARED',self::LOGKEY)); $this->mib = 0; $this->sessionClear(self::SE_RECVEOB|self::SE_SENTEOB); $this->sessionSet(self::SE_DELAYEOB); } $wd = ($this->mqueue->count() || $this->tx_left || ($this->sessionGet(self::SE_SENDFILE) && $this->send->fd && ! $this->sessionGet(self::SE_WAITGET))); $rd = TRUE; try { Log::debug(sprintf('%s:- Checking if there more data (ttySelect), timeout [%d]',self::LOGKEY,self::TIMEOUT_TIME)); // @todo we need to catch a timeout if there are no reads/writes $rc = $this->client->ttySelect($rd,$wd,self::TIMEOUT_TIME); Log::debug(sprintf('%s:- ttySelect returned [%d]',self::LOGKEY,$rc)); } catch (\Exception) { $this->error_close(); $this->error = -2; break; } $this->rc = ($this->originate ? (self::S_REDIAL|self::S_ADDTRY) : self::S_BUSY); if ($rc === 0) { $this->error_close(); $this->error = -1; break; } if ($rd && ! $this->binkp_recv()) { Log::info(sprintf('%s:- BINKP finished reading',self::LOGKEY)); break; } if (($this->mqueue->count() || $wd) && ! $this->binkp_send() && (! $this->send->togo_count)) { Log::info(sprintf('%s:- BINKP finished sending',self::LOGKEY)); break; } } if ($this->error === -1) Log::error(sprintf('%s:! protocol_session TIMEOUT',self::LOGKEY)); elseif ($this->error > 0) Log::error(sprintf('%s:! During our protocol session we got ERROR [%d]',self::LOGKEY,$this->error)); while (! $this->error) { try { Log::debug(sprintf('%s:- BINKP reading [%d]',self::LOGKEY,self::MAX_BLKSIZE)); $buf = $this->client->read(0,self::MAX_BLKSIZE); Log::debug(sprintf('%s:- BINKP got [%d] chars',self::LOGKEY,strlen($buf))); } catch (\Exception $e) { if ($e->getCode() !== 11) { Log::error(sprintf('%s:! Got an exception [%d] while reading (%s)',self::LOGKEY,$e->getCode(),$e->getMessage())); $this->error = 1; } break; } if (strlen($buf) === 0) break; Log::warning(sprintf('%s:- Purged [%d] bytes from input stream (%s) ',self::LOGKEY,strlen($buf),hex_dump($buf))); } Log::debug(sprintf('%s:- We have [%d] messages and [%d] data left to send',self::LOGKEY,$this->mqueue->count(),strlen($this->tx_left))); while (! $this->error && ($this->mqueue->count() || $this->tx_left) && $this->binkp_send()) {} return $this->rc; } public function getFiles(Node $node): void { // Add our mail to the queue if we have authenticated if ($node->aka_authed) { Log::info(sprintf('%s:- We have authed these AKAs [%s]',self::LOGKEY,$node->aka_remote_authed->pluck('ftn')->join(','))); foreach ($node->aka_remote_authed as $ao) { Log::debug(sprintf('%s:- Checking for any new mail and files to [%s]',self::LOGKEY,$ao->ftn)); if (! $ao->validated) { Log::alert(sprintf('%s:! Address [%s] is not validated, so we wont bundle mail for it',self::LOGKEY,$ao->ftn)); continue; } $this->send->mail($ao); $this->send->files($ao); $this->send->dynamic($ao); /* * Add "dynamic files", eg: nodelist, nodelist segment, status reports. * Dynamic files are built on the fly * * query "dynamic" for items for the address * * column 'method' identifies the method that will be called, with the $ao as the argument * * a 'new Item' is added to the queue * * when it its ready to be sent, the __tostring() is called that renders it * * when sent, the dynamic table is updated with the sent_at */ } Log::info(sprintf('%s:- We have [%d] items to send to [%s]',self::LOGKEY,$this->send->togo_count,$ao->system->name)); } else { // @todo We should only send netmail if unauthenticated - netmail that is direct to this node (no routing) Log::debug(sprintf('%s:- Not AUTHed so not looking for mail, but we know these akas [%s]',self::LOGKEY,$node->aka_remote->pluck('ftn')->join(','))); } } /** * Return the string delimited by char and shorten the input to the remaining characters * * @param string $str * @param string $char * @return string */ private function strsep(string &$str,string $char): string { if ($x=strpos($str,$char)) { $return = substr($str,0,$x); $str = substr($str,$x+1); } else { $return = $str; $str = ''; } return $return; } }