[0x00,'v',2], // Originating Node 'dnode' => [0x02,'v',2], // Destination Node 'y' => [0x04,'v',2], // Year 'm' => [0x06,'v',2], // Month 'd' => [0x08,'v',2], // Day 'H' => [0x0a,'v',2], // Hour 'M' => [0x0c,'v',2], // Minute 'S' => [0x0e,'v',2], // Second 'baud' => [0x10,'v',2], // Baud 'pktver' => [0x12,'v',2], // Packet Version 'onet' => [0x14,'v',2], // Originating Net (0xffff when origPoint !=0 2+) 'dnet' => [0x16,'v',2], // Destination Net 'prodcode-lo' => [0x18,'C',1], 'prodrev-maj' => [0x19,'C',1], // Product Version Major (serialNum 2) 'password' => [0x1a,'Z8',8], // Packet Password 'qozone' => [0x22,'v',2], 'qdzone' => [0x24,'v',2], 'filler' => [0x26,'v',2], // Reserved (auxnet 2+ - contains Orignet if Origin is a point) fsc-0048.001 'capvalid' => [0x28,'n',2], // fsc-0039.004 (Not used 2) (copy of 0x2c) 'prodcode-hi' => [0x2a,'C',1], // (Not used 2) 'prodrev-min' => [0x2b,'C',1], // (Not used 2) 'capword' => [0x2c,'v',2], // fsc-0039.001 (Not used 2) 'ozone' => [0x2e,'v',2], // Originating Zone (Not used 2) 'dzone' => [0x30,'v',2], // Destination Zone (Not used 2) 'opoint' => [0x32,'v',2], // Originating Point (Not used 2) 'dpoint' => [0x34,'v',2], // Destination Point (Not used 2) 'proddata' => [0x36,'a4',4], // ProdData (Not used 2) // FSC-39/FSC-48 ]; private array $header; // Packet Header public File $file; // Packet filename public Collection $messages; // Messages in the Packet public Collection $errors; // Messages that fail validation private string $name; // Packet name public bool $use_cache = TRUE; // Use a cache for messages. private int $index; // Our array index /** * Number of messages in this packet */ public function count(): int { return $this->messages->count(); } public function current(): Message { return $this->use_cache ? unserialize(Cache::pull($this->key())) : $this->messages->get($this->index); } public function key(): mixed { return $this->use_cache ? $this->messages->get($this->index) : $this->index; } public function next(): void { $this->index++; } public function rewind(): void { $this->index = 0; } public function valid(): bool { return (! is_null($this->key())) && ($this->use_cache ? Cache::has($this->key()) : $this->messages->has($this->key())); } /** * @param Address|NULL $oo Origin Address * @param Address|NULL $o Destination Address */ public function __construct(Address $oo=NULL,Address $o=NULL) { $this->messages = collect(); $this->errors = collect(); $this->domain = NULL; // If we are creating an outbound packet, we need to set our header if ($oo && $o) { $this->name = sprintf('%08x',timew()); Log::debug(sprintf('%s:Creating packet [%s]',self::LOGKEY,$this->name)); $this->newHeader($oo,$o); } } /** * Process a packet file * * @param mixed $f * @param string $name * @param int $size * @param System|null $system * @param bool $use_cache * @return Packet * @throws InvalidPacketException */ public static function process(mixed $f,string $name,int $size,System $system=NULL,bool $use_cache=TRUE): self { Log::debug(sprintf('%s:+ Opening Packet [%s] with size [%d]',self::LOGKEY,$name,$size)); $read_ptr = 0; // PKT Header $header = fread($f,self::HEADER_LEN); $read_ptr += strlen($header); // Could not read header if (strlen($header) != self::HEADER_LEN) throw new InvalidPacketException(sprintf('Length of header [%d] too short'.strlen($header))); // Not a type 2 packet $version = Arr::get(unpack('vv',substr($header,self::VERSION_OFFSET)),'v'); if ($version != 2) throw new InvalidPacketException('Not a type 2 packet: '.$version); $o = new self; $o->use_cache = $use_cache; $o->name = $name; $o->header = unpack(self::unpackheader(self::v2header),$header); $x = fread($f,2); $read_ptr += strlen($x); // End of Packet? if (strlen($x) == 2 and $x == "\00\00") return new self; // Messages start with 02H 00H if (strlen($x) == 2 AND $x != "\02\00") throw new InvalidPacketException('Not a valid packet: '.bin2hex($x)); // No message attached else if (! strlen($x)) throw new InvalidPacketException('No message in packet: '.bin2hex($x)); $o->zone = $system?->zones->firstWhere('zone_id',$o->fz); // If zone is null, we'll take the zone from the packet if (! $o->zone) $o->zone = Zone::where('zone_id',$o->fz)->where('default',TRUE)->single(); $buf_ptr = 0; $message = ''; $readbuf = ''; $last = ''; while ($buf_ptr || (! feof($f) && ($readbuf=fread($f,self::BLOCKSIZE)))) { if (! $buf_ptr) $read_ptr += strlen($readbuf); // Could use ftell() if (strlen($message) < self::PACKED_MSG_HEADER_LEN) { $addchars = self::PACKED_MSG_HEADER_LEN-strlen($message); $message .= substr($readbuf,$buf_ptr,$addchars); $buf_ptr += $addchars; // If our buffer wasnt big enough... if ($buf_ptr >= strlen($readbuf)) { $buf_ptr = 0; continue; } } // Take 2 chars from the buffer and check if we have our end packet signature if ($last && ($buf_ptr == 0)) { $last .= substr($readbuf,0,2); if (($end=strpos($last,"\x00\x02\x00",$buf_ptr)) !== FALSE) { $o->parseMessage(substr($message,0,$end-2)); $last = ''; $message = ''; $buf_ptr = 1+$end; // Loop to rebuild our header for the next message continue; } $last = ''; } if (($end=strpos($readbuf,"\x00\x02\x00",$buf_ptr)) === FALSE) { // Just in case our packet break is at the end of the buffer $last = substr($readbuf,-2); if ((str_contains($last,"\x00")) && ($size-$read_ptr > 2)) { $message .= substr($readbuf,$buf_ptr); $buf_ptr = 0; continue; } $last = ''; $end = strpos($readbuf,"\x00\x00\x00",$buf_ptr); } // See if we have found the end of the packet, if not read more. if ($end === FALSE && ($read_ptr < $size)) { $message .= substr($readbuf,$buf_ptr); $buf_ptr = 0; continue; } else { $message .= substr($readbuf,$buf_ptr,$end-$buf_ptr); $buf_ptr = $end+3; if ($buf_ptr >= strlen($readbuf)) $buf_ptr = 0; } // Look for the next message $o->parseMessage($message); $message = ''; } // If our message is still set, then we have an unprocessed message if ($message) $o->parseMessage($message); return $o; } /** * @throws \Exception */ public function __get($key) { switch ($key) { // From Addresses case 'fz': return Arr::get($this->header,'ozone'); case 'fn': return Arr::get($this->header,'onet'); case 'ff': return Arr::get($this->header,'onode'); case 'fp': return Arr::get($this->header,'opoint'); case 'fd': return Arr::get($this->header,'odomain'); // To Addresses case 'tz': return Arr::get($this->header,'dzone'); case 'tn': return Arr::get($this->header,'dnet'); case 'tf': return Arr::get($this->header,'dnode'); case 'tp': return Arr::get($this->header,'dpoint'); case 'td': return Arr::get($this->header,'ddomain'); case 'date': return Carbon::create( Arr::get($this->header,'y'), Arr::get($this->header,'m')+1, Arr::get($this->header,'d'), Arr::get($this->header,'H'), Arr::get($this->header,'M'), Arr::get($this->header,'S') ); case 'capability': return Arr::get($this->header,'capword') == Arr::get($this->header,'capvalid') ? sprintf('%016b',Arr::get($this->header,'capword')) : 'FTS-1'; case 'password': return Arr::get($this->header,$key); case 'fftn': case 'fftn_o': case 'tftn': case 'tftn_o': return parent::__get($key); case 'software': $code = Arr::get($this->header,'prodcode-hi')<<8|Arr::get($this->header,'prodcode-lo'); Software::unguard(); $o = Software::singleOrNew(['code'=>$code,'type'=>Software::SOFTWARE_TOSSER]); Software::reguard(); return $o; case 'software_ver': return sprintf('%d.%d',Arr::get($this->header,'prodrev-maj'),Arr::get($this->header,'prodrev-min')); // Packet Type case 'type': if ((Arr::get($this->header,'onet') == 0xffff) && (Arr::get($this->header,'opoint') != 0) && Arr::get($this->header,'filler')) return '2+'; elseif (Arr::get($this->header,'prodrev-maj') && ! Arr::get($this->header,'capword')) return '2'; else return '2e'; // Packet name: case 'name': return $this->{$key}; default: throw new \Exception('Unknown key: '.$key); } } /** * Return the packet * * @return string * @throws \Exception */ public function __toString(): string { $return = $this->createHeader(); foreach ($this->messages as $o) { if ($o->packed) $return .= "\02\00".(string)$o; } $return .= "\00\00"; return $return; } /** * Create our message packet header */ private function createHeader(): string { try { $a = pack(collect(self::v2header)->merge(['password' => [0x1a,'a8',8]])->pluck(1)->join(''), $this->ff, $this->tf, Arr::get($this->header,'y'), Arr::get($this->header,'m'), Arr::get($this->header,'d'), Arr::get($this->header,'H'), Arr::get($this->header,'M'), Arr::get($this->header,'S'), Arr::get($this->header,'baud',0), Arr::get($this->header,'pktver',2), $this->fn, // @todo if point, this needs to be 0xff $this->tn, Arr::get($this->header,'prodcode-lo',(Setup::PRODUCT_ID & 0xff)), Arr::get($this->header,'prodrev-maj',Setup::PRODUCT_VERSION_MAJ), $this->password, $this->fz, $this->tz, Arr::get($this->header,'filler',''), Arr::get($this->header,'capvalid',1<<0), Arr::get($this->header,'prodcode-hi',(Setup::PRODUCT_ID >> 8) & 0xff), Arr::get($this->header,'prodrev-min',Setup::PRODUCT_VERSION_MIN), Arr::get($this->header,'capword',1<<0), $this->fz, $this->tz, $this->fp, $this->tp, Arr::get($this->header,'proddata','AB8D'), ); return $a; } catch (\Exception $e) { return $e->getMessage(); } } /** * Add a netmail message to this packet * * @param Message $o */ public function addMail(Message $o): void { $this->messages->push($o); } /** * When creating a new packet, set the header. * * @param Address $oo * @param Address $o */ private function newHeader(Address $oo,Address $o): void { $date = Carbon::now(); // Create Header $this->header = [ 'onode' => $oo->node_id, // Originating Node 'dnode' => $o->node_id, // Destination Node 'y' => $date->format('Y'), // Year 'm' => $date->format('m')-1, // Month 'd' => $date->format('d'), // Day 'H' => $date->format('H'), // Hour 'M' => $date->format('i'), // Minute 'S' => $date->format('s'), // Second 'onet' => $oo->host_id ?: $oo->region_id, // Originating Net (0xffff when origPoint !=0 2+) 'dnet' => $o->host_id ?: $o->region_id, // Destination Net 'password' => $o->session('pktpass'), // Packet Password 'qozone' => $oo->zone->zone_id, 'qdzone' => $o->zone->zone_id, 'ozone' => $oo->zone->zone_id, // Originating Zone (Not used 2) 'dzone' => $o->zone->zone_id, // Destination Zone (Not used 2) 'opoint' => $oo->point_id, // Originating Point (Not used 2) 'dpoint' => $o->point_id, // Destination Point (Not used 2) ]; } /** * Parse a message in a mail packet * * @param string $message * @throws InvalidPacketException|\Exception */ private function parseMessage(string $message): void { Log::info(sprintf('%s:Processing message [%d] bytes',self::LOGKEY,strlen($message))); $msg = Message::parseMessage($message,$this->zone); // If the message is invalid, we'll ignore it if ($msg->errors) { Log::info(sprintf('%s:- Message [%s] has errors',self::LOGKEY,$msg->msgid)); // If the from address doenst exist, we'll create a new entry if ($msg->errors->messages()->has('to')) { $e = NULL; try { Address::unguard(); $ao = Address::firstOrNew([ 'zone_id' => $msg->tzone->id, 'region_id' => 0, 'host_id' => $msg->tn, 'node_id' => $msg->tf, 'point_id' => $msg->tp, ]); Address::reguard(); } catch (\Exception $e) { Log::error(sprintf('%s:! Error finding/creating TO address [%s] for message',self::LOGKEY,$msg->tboss),['error'=>$e->getMessage()]); } // This shouldnt happen if ($e || $ao->exists) { Log::error(sprintf('%s:! Unexpected error attempting to create TO address [%s]',self::LOGKEY,$msg->tboss)); $this->errors->push($msg); return; } $ao->active = TRUE; $ao->role = Address::NODE_UNKNOWN; System::unguard(); $so = System::firstOrCreate([ 'name' => 'Discovered System', 'sysop' => 'Unknown', 'location' => '', 'active' => TRUE, ]); System::reguard(); $so->addresses()->save($ao); Log::alert(sprintf('%s: - To FTN is not defined, creating new entry for [%s] (%d)',self::LOGKEY,$msg->tboss,$ao->id)); } if ($msg->errors->messages()->has('from')) { $e = NULL; try { Address::unguard(); $ao = Address::firstOrNew([ 'zone_id' => $msg->fzone->id, 'region_id' => 0, 'host_id' => $msg->fn, 'node_id' => $msg->ff, 'point_id' => $msg->fp, ]); Address::reguard(); } catch (\Exception $e) { Log::error(sprintf('%s:! Error finding/creating FROM address [%s] for message',self::LOGKEY,$msg->fboss),['error'=>$e->getMessage()]); } // This shouldnt happen if ($e || $ao->exists) { Log::error(sprintf('%s:! Unexpected error attempting to create FROM address [%s]',self::LOGKEY,$msg->fboss)); $this->errors->push($msg); return; } $ao->active = TRUE; $ao->role = Address::NODE_UNKNOWN; System::unguard(); $so = System::firstOrCreate([ 'name' => 'Discovered System', 'sysop' => 'Unknown', 'location' => '', 'active' => TRUE, ]); System::reguard(); $so->addresses()->save($ao); Log::alert(sprintf('%s: - From FTN is not defined, creating new entry for [%s] (%d)',self::LOGKEY,$msg->fboss,$ao->id)); } if ($msg->errors->messages()->has('user_from') || $msg->errors->messages()->has('user_to')) { Log::error(sprintf('%s:! Skipping message [%s] due to errors (%s)...',self::LOGKEY,$msg->msgid,join(',',$msg->errors->messages()->keys()))); $this->errors->push($msg); return; } } if ($this->use_cache) { $key = urlencode($msg->msgid ?: sprintf('%s %s',$msg->fftn,Carbon::now()->timestamp)); Cache::forever($key,serialize($msg)); $this->messages->push($key); } else { $this->messages->push($msg); } } }