github.com/la5nta/wl2k-go@v0.11.8/fbb/b2f.go (about) 1 // Copyright 2015 Martin Hebnes Pedersen (LA5NTA). All rights reserved. 2 // Use of this source code is governed by the MIT-license that can be 3 // found in the LICENSE file. 4 5 package fbb 6 7 import ( 8 "bufio" 9 "bytes" 10 "errors" 11 "fmt" 12 "io" 13 "log" 14 "mime" 15 "strconv" 16 "strings" 17 "time" 18 19 "github.com/la5nta/wl2k-go/transport" 20 ) 21 22 var ErrOffsetLimitExceeded error = errors.New("Protocol does not support offset larger than 6 digits") 23 24 const ( 25 ProtocolOffsetSizeLimit = 999999 26 MaxBlockSize = 5 27 28 // Paclink-unix uses 250, protocol maximum is 255, but we use 125 to allow use of AX.25 links with a paclen of 128. 29 // TODO: Consider setting this dynamically. 30 MaxMsgLength = 125 31 ) 32 33 const ( 34 cmdPrefix = 'F' 35 cmdPrompt = '>' 36 37 cmdNoMoreMessages = 'F' 38 cmdQuit = 'Q' 39 cmdPropAnswer = 'S' 40 41 cmdPropA = 'A' 42 cmdPropB = 'B' 43 cmdPropC = 'C' // Wl2k extended B2 message 44 45 cmdPropD = 'D' // Gzip compressed B2 message (GZIP_EXPERIMENT) 46 ) 47 48 const ( 49 _CHRNUL byte = 0 50 _CHRSOH = 1 51 _CHRSTX = 2 52 _CHREOT = 4 53 ) 54 55 func (s *Session) handleOutbound(rw io.ReadWriter) (quitSent bool, err error) { 56 var sent map[string]bool 57 58 // Send outbound messages 59 if len(s.outbound()) > 0 { 60 sent, err = s.sendOutbound(rw) 61 if err != nil { 62 return 63 } 64 } 65 66 // Report rejected now, they can safely be omitted even if an error occures 67 for mid, rej := range sent { 68 if rej { 69 s.h.SetSent(mid, rej) 70 delete(sent, mid) 71 } 72 } 73 74 // If all messages was deferred/rejected, we should propose new messages 75 if len(sent) == 0 && len(s.outbound()) > 0 { 76 return s.handleOutbound(rw) 77 } 78 79 // Handle session turnover 80 switch { 81 case len(sent) > 0: 82 // Turnover is implied 83 case s.remoteNoMsgs && len(sent) == 0: 84 s.pLog.Print(">FQ") 85 fmt.Fprint(rw, "FQ\r") 86 quitSent = true 87 return // No need to check for remote error since we did not send any messages 88 default: 89 s.pLog.Print(">FF") 90 fmt.Fprint(rw, "FF\r") 91 } 92 93 // Error reporting from remote is not defined by the protocol, 94 // but usually indicated by sending a line prefixed with '***'. 95 // The only valid bytes (according to protocol) after a session 96 // turnover is 'F' or ';', so we use those to confirm the block 97 // was successfully received. 98 var p []byte 99 if p, err = s.rd.Peek(1); err != nil { 100 return 101 } else if p[0] != 'F' && p[0] != ';' { 102 var line string 103 line, err = s.nextLine() 104 if err != nil { 105 return 106 } 107 err = fmt.Errorf("Unexpected response: '%s'", line) 108 return 109 } 110 111 // Report successfully sent messages 112 for mid, rej := range sent { 113 s.h.SetSent(mid, rej) 114 if !rej { 115 s.trafficStats.Sent = append(s.trafficStats.Sent, mid) 116 } 117 } 118 return 119 } 120 121 func (s *Session) sendOutbound(rw io.ReadWriter) (sent map[string]bool, err error) { 122 sent = make(map[string]bool) // Use this to keep track of sent (rejected or not) mids. 123 var checksum int64 124 125 outbound := s.outbound() 126 if len(outbound) > MaxBlockSize { 127 outbound = outbound[0:MaxBlockSize] 128 } 129 130 for _, prop := range outbound { 131 sp := fmt.Sprintf("F%c %s %s %d %d %d", 132 prop.code, // Proposal code 133 prop.msgType, // Message type (1 or 2 alphanumeric) 134 prop.mid, // Max 12 characters 135 prop.size, // Uncompressed size of message 136 prop.compressedSize, // Compressed size of message 137 0) // ? 138 139 s.pLog.Printf(">%s", sp) 140 fmt.Fprintf(rw, "%s\r", sp) 141 for _, c := range sp { 142 checksum += int64(c) 143 } 144 checksum += int64('\r') 145 } 146 checksum = (-checksum) & 0xff 147 148 s.log.Printf(`Sending checksum %02X`, checksum) 149 fmt.Fprintf(rw, "F> %02X\r", checksum) 150 151 var reply string 152 for reply == "" { 153 line, err := s.nextLine() 154 switch { 155 case err != nil: 156 return sent, err 157 case strings.HasPrefix(line, "FS "): 158 reply = line // The expected proposal answer 159 case strings.HasPrefix(line, ";"): 160 continue // Ignore comment 161 default: 162 return sent, fmt.Errorf("Expected proposal answer from remote. Got: '%s'", reply) 163 } 164 } 165 166 if err = parseProposalAnswer(reply, outbound, s.log); err != nil { 167 return sent, fmt.Errorf("Unable to parse proposal answer: %w", err) 168 } 169 170 if len(outbound) == 0 { 171 return 172 } 173 174 if r, ok := rw.(transport.Robust); ok && s.robustMode == RobustAuto { 175 r.SetRobust(false) 176 defer r.SetRobust(true) 177 } 178 179 for _, prop := range outbound { 180 switch prop.answer { 181 case Defer: 182 s.h.SetDeferred(prop.mid) 183 case Reject: 184 sent[prop.mid] = true 185 case Accept: 186 if err = s.writeCompressed(rw, prop); err != nil { 187 return 188 } 189 sent[prop.mid] = false 190 } 191 } 192 return 193 } 194 195 func (s *Session) handleInbound(rw io.ReadWriter) (quitReceived bool, err error) { 196 var ourChecksum int64 197 proposals := make([]*Proposal, 0) 198 var nAccepted int 199 200 Loop: 201 for { 202 var line string 203 line, err = s.nextLine() 204 if err != nil { 205 return 206 } 207 208 // Ignore comments and empty lines 209 if line == "" || line[0] == ';' { 210 continue 211 } 212 213 // The line should be prefixed F? (? is the command character) 214 if len(line) < 2 || line[0] != 'F' { 215 return false, fmt.Errorf("Got unexpected protocol line: '%s'", line) 216 } 217 218 switch line[:2] { 219 case "FA", "FB", "FC", "FD": // Proposals 220 for _, c := range line { 221 ourChecksum += int64(c) 222 } 223 ourChecksum += int64('\r') 224 225 prop := new(Proposal) 226 if err = parseProposal(line, prop); err != nil { 227 err = fmt.Errorf("Unable to parse proposal: %w", err) 228 return 229 } 230 proposals = append(proposals, prop) 231 232 case "FF": // No more messages 233 break Loop 234 235 case "FQ": // Quit 236 quitReceived = true 237 break Loop 238 239 case "F>": // Prompt (end of proposal block) 240 // Verify checksum 241 ourChecksum = (-ourChecksum) & 0xff 242 their, _ := strconv.ParseInt(line[3:], 16, 64) 243 if their != ourChecksum { 244 err = errors.New(fmt.Sprintf(`Checksum error (%d-%d)`, ourChecksum, their)) 245 return 246 } 247 248 // If we didn't get any proposals, return 249 if len(proposals) == 0 { 250 return 251 } 252 253 // Answer proposal 254 s.log.Printf(`%d proposal(s) received`, len(proposals)) 255 nAccepted, err = s.writeProposalsAnswer(rw, proposals) 256 if err != nil { 257 return quitReceived, err 258 } 259 260 if nAccepted > 0 { 261 break Loop // Session turn over is implied after receiving the messages 262 } 263 264 // Continue receiving proposals if all where rejected/deferred 265 return s.handleInbound(rw) 266 default: //TODO: Ignore? 267 return false, fmt.Errorf("Unknown protocol command %c", line[1]) 268 } 269 } 270 271 if quitReceived && nAccepted > 0 { 272 return true, errors.New("Got quit command when inbound proposals were pending") 273 } 274 275 // Fetch and decompress accepted 276 s.remoteNoMsgs = true 277 for _, prop := range proposals { 278 if prop.answer != Accept { 279 continue 280 } 281 s.remoteNoMsgs = false 282 283 var msg *Message 284 if err = s.readCompressed(rw, prop); err != nil { 285 return 286 } else if msg, err = prop.Message(); err != nil { 287 return 288 } 289 290 if err = s.h.ProcessInbound(msg); err != nil { 291 return 292 } 293 s.trafficStats.Received = append(s.trafficStats.Received, prop.MID()) 294 } 295 296 return 297 } 298 299 // The B2F protocol does not support offsets larger than 6 digits, the author of the protocol 300 // seems to have thrown away the idea of supporting transfer of fragmented messages. 301 // 302 // If we ever want to support requests of message with offset, we must guard against asking for 303 // offsets > 999999. RMS Express does not do this (in Winmor P2P anyway), we must avoid that pitfall. 304 func (s *Session) writeProposalsAnswer(rw io.ReadWriter, proposals []*Proposal) (nAccepted int, err error) { 305 answers := make([]byte, len(proposals)) 306 307 seen := make(map[string]bool) 308 309 for i, prop := range proposals { 310 if seen[prop.MID()] { 311 // Radio Only gateways will sometimes send multiple proposals for the same MID in the same batch. 312 // Instead of rejecting them right away, let's defer the dups until we know we have sucessfully received at least one of the copies. 313 s.log.Printf("Defering duplicate message %s", prop.MID()) 314 prop.answer = Defer 315 } else if prop.code != Wl2kProposal && prop.code != GzipProposal { 316 s.log.Printf("Defering %s (unsupported format)", prop.MID()) 317 prop.answer = Defer 318 } else if s.h == nil { 319 s.log.Printf("Defering %s (missing handler)", prop.MID()) 320 prop.answer = Defer 321 } else if prop.answer = s.h.GetInboundAnswer(*prop); prop.answer == Accept { 322 s.log.Printf("Accepting %s", prop.MID()) //TODO: Remove? 323 nAccepted++ 324 } 325 326 seen[prop.MID()] = true 327 answers[i] = byte(prop.answer) 328 } 329 330 _, err = fmt.Fprintf(rw, "FS %s\r", answers) 331 return 332 } 333 334 // Parses the proposal answer (str) and updates the proposals given (in that order) 335 func parseProposalAnswer(str string, props []*Proposal, l *log.Logger) error { 336 str = strings.TrimPrefix(str, "FS ") 337 338 var c byte 339 for i := 0; len(str) > 0; i++ { 340 if i >= len(props) { 341 return errors.New("Got answer for more proposals than expected") 342 } 343 344 prop := props[i] 345 c, str = str[0], str[1:] 346 347 switch c { 348 case 'Y', 'y', '+': 349 if l != nil { 350 l.Printf("Remote accepted %s", prop.MID()) 351 } 352 prop.answer = Accept 353 case 'N', 'n', 'R', 'r', '-': 354 if l != nil { 355 l.Printf("Remote already received %s", prop.MID()) 356 } 357 prop.answer = Reject 358 case 'L', 'l', '=', 'H', 'h': 359 if l != nil { 360 l.Printf("Remote defered %s", prop.MID()) 361 } 362 prop.answer = Defer 363 case 'A', 'a', '!': 364 idx := strings.LastIndexAny(str, "0123456789") 365 if idx < 0 { 366 return errors.New("Got offset request without offset index") 367 } 368 prop.answer = Accept // Offset is not implemented as a ProposalAnswer 369 prop.offset, _ = strconv.Atoi(str[:idx+1]) 370 str = str[idx+1:] 371 372 if prop.offset > ProtocolOffsetSizeLimit { // RMS Express does this (in Winmor P2P for sure) 373 prop.offset = 0 374 if l != nil { 375 l.Printf( 376 "Remote requested %s at offset %d which exceeds the binary protocol offset limit. Ignoring offset.", 377 prop.MID(), prop.offset, 378 ) 379 } 380 } else if l != nil { 381 l.Printf("Remote accepted %s at offset %d", prop.MID(), prop.offset) 382 } 383 default: 384 return fmt.Errorf("Invalid character (%c) in proposal answer line", c) 385 } 386 } 387 return nil 388 } 389 390 func (s *Session) writeCompressed(rw io.ReadWriter, p *Proposal) (err error) { 391 s.log.Printf("Transmitting [%s] [offset %d]", p.title, p.offset) 392 393 if p.code == GzipProposal { 394 s.log.Println("GZIP_EXPERIMENT:", "Transmitting gzip compressed message.") 395 } 396 397 writer := bufio.NewWriter(rw) 398 399 var ( 400 title = mime.QEncoding.Encode("utf-8", p.title) // Word-encode the title since this field must be ASCII-only 401 offset = fmt.Sprintf("%d", p.offset) 402 length = len(title) + len(offset) + 2 403 checksum int64 404 ) 405 406 writer.Write([]byte{_CHRSOH, byte(length)}) 407 writer.WriteString(title) // Max 80 bytes, min 1 byte 408 writer.WriteByte(_CHRNUL) 409 writer.WriteString(offset) // Max 6 bytes, min 1 byte. Highest supported offset is 1MB-1B. 410 writer.WriteByte(_CHRNUL) 411 writer.Flush() 412 413 if p.compressedSize < 6 { // lzhuf's smallest valid length (empty) 414 return errors.New(`Invalid compressed data`) 415 } 416 417 buffer := bytes.NewBuffer(p.compressedData[p.offset:]) 418 419 // Update Status of message transfer every 250ms 420 statusTicker := time.NewTicker(250 * time.Millisecond) 421 statusDone := make(chan struct{}) 422 go func() { 423 for { 424 select { 425 case <-statusTicker.C: 426 if s.statusUpdater == nil || buffer == nil { 427 continue 428 } 429 430 // Take into account that the modem has an internal tx buffer (if possible). 431 var txBufLen int 432 if b, ok := rw.(transport.TxBuffer); ok { 433 txBufLen = b.TxBufferLen() 434 } 435 436 transferred := p.compressedSize - buffer.Len() - txBufLen 437 if transferred < 0 { 438 transferred = 0 439 } 440 441 if s.statusUpdater != nil { 442 s.statusUpdater.UpdateStatus(Status{ 443 Sending: p, 444 BytesTransferred: transferred, 445 BytesTotal: p.compressedSize, 446 }) 447 } 448 case <-statusDone: 449 if s.statusUpdater != nil { 450 s.statusUpdater.UpdateStatus(Status{ 451 Sending: p, 452 BytesTransferred: p.compressedSize - buffer.Len(), 453 BytesTotal: p.compressedSize, 454 Done: true, 455 }) 456 } 457 return 458 } 459 } 460 }() 461 defer func() { close(statusDone) }() 462 463 // Data (in chunks of max 250) 464 for buffer.Len() > 0 { 465 msgLen := MaxMsgLength 466 if buffer.Len() < MaxMsgLength { 467 msgLen = buffer.Len() 468 } 469 470 if _, err = writer.Write([]byte{_CHRSTX, byte(msgLen)}); err != nil { 471 return err 472 } 473 474 for i := 0; i < msgLen; i++ { 475 c, _ := buffer.ReadByte() 476 if err := writer.WriteByte(c); err != nil { 477 return err 478 } 479 checksum += int64(c) 480 } 481 482 if err = writer.Flush(); err != nil { 483 return err 484 } 485 } 486 487 // Checksum 488 checksum = -checksum & 0xff 489 _, err = writer.Write([]byte{_CHREOT, byte(checksum)}) 490 err = writer.Flush() 491 492 // Flush connection buffers. 493 // This enables us to block until the whole message has been transmitted over the air. 494 if f, ok := rw.(transport.Flusher); ok { 495 err = f.Flush() 496 } 497 498 statusTicker.Stop() 499 500 return err 501 } 502 503 func (s *Session) readCompressed(rw io.ReadWriter, p *Proposal) (err error) { 504 var ( 505 ourChecksum int 506 buf bytes.Buffer 507 ) 508 509 var c byte 510 if c, err = s.rd.ReadByte(); err != nil { 511 return 512 } 513 switch c { 514 case _CHRSOH: 515 // what we expected... 516 case '*': 517 line, _ := s.nextLine() 518 return errors.New(fmt.Sprintf(`Got error from CMS: %s`, line)) 519 default: 520 return errors.New(fmt.Sprintf(`First byte not as expected, got %d`, int(c))) 521 } 522 523 if c, err = s.rd.ReadByte(); err != nil { 524 return 525 } 526 headerLength := int(c) 527 528 // Read proposal title. 529 title, err := s.rd.ReadString(_CHRNUL) 530 if err != nil { 531 return fmt.Errorf("Unable to parse title: %w", err) 532 } 533 title = title[:len(title)-1] // Remove _CHRNUL 534 535 // The proposal title should be ASCII-only according to the protocol specification. Since RMS Express and CMS puts 536 // the raw subject header here, we need to handle this by decoding it the same way as the subject header. 537 p.title, _ = new(WordDecoder).DecodeHeader(title) 538 539 // Read offset part 540 var offsetStr string 541 if offsetStr, err = s.rd.ReadString(_CHRNUL); err != nil { 542 return fmt.Errorf("Unable to parse offset: %w", err) 543 } else { 544 offsetStr = offsetStr[:len(offsetStr)-1] 545 } 546 547 // Check overall length of header 548 actualHeaderLength := (len(title) + len(offsetStr)) + 2 549 if headerLength != actualHeaderLength { 550 return errors.New(fmt.Sprintf(`Header length mismatch: expected %d, got %d`, headerLength, actualHeaderLength)) 551 } 552 553 // Parse offset as integer (and do some sanity checks) 554 offset, err := strconv.Atoi(offsetStr) 555 switch { 556 case err != nil: 557 return fmt.Errorf("Offset header not parseable as integer: %w", err) 558 case offset != p.offset: 559 return fmt.Errorf(`Expected offset %d, got %d`, p.offset, offset) 560 } 561 562 s.log.Printf("Receiving [%s] [offset %d]", p.title, p.offset) 563 564 if p.code == GzipProposal { 565 s.log.Println("GZIP_EXPERIMENT:", "Receiving gzip compressed message.") 566 } 567 568 statusUpdate := make(chan struct{}) 569 go func() { 570 for { 571 _, ok := <-statusUpdate 572 if s.statusUpdater != nil { 573 s.statusUpdater.UpdateStatus(Status{ 574 Receiving: p, 575 BytesTransferred: buf.Len(), 576 BytesTotal: p.compressedSize, 577 Done: !ok, 578 }) 579 } 580 if !ok { 581 return 582 } 583 } 584 }() 585 defer func() { close(statusUpdate) }() 586 updateStatus := func() { 587 select { 588 case statusUpdate <- struct{}{}: 589 default: 590 } 591 } 592 593 for { 594 updateStatus() 595 c, err = s.rd.ReadByte() 596 if err != nil { 597 return err 598 } 599 600 switch c { 601 case _CHRSTX: 602 c, _ := s.rd.ReadByte() 603 length := int(c) 604 if length == 0 { 605 length = 256 606 } 607 for i := 0; i < length; i++ { 608 c, err = s.rd.ReadByte() 609 if err != nil { 610 return 611 } 612 buf.WriteByte(c) 613 ourChecksum = (ourChecksum + int(c)) % 256 614 if i%10 == 0 { 615 updateStatus() 616 } 617 } 618 case _CHREOT: 619 c, _ = s.rd.ReadByte() 620 ourChecksum = (ourChecksum + int(c)) % 256 621 if ourChecksum != 0 { 622 return errors.New(`Bad checksum`) 623 } else if p.compressedSize != buf.Len() { 624 return errors.New(`Length mismatch after EOT`) 625 } else { 626 p.compressedData = buf.Bytes() 627 } 628 return 629 default: 630 return errors.New(`Unexpected byte in compressed stream: ` + string(c)) 631 } 632 } 633 }