├── .gitignore ├── ANNOUNCE.rst ├── COPYING ├── MANIFEST.in ├── README.rst ├── fidonet ├── __init__.py ├── address.py ├── app.py ├── apps │ ├── __init__.py │ ├── binknodes.py │ ├── editmsg.py │ ├── editpkt.py │ ├── ftn2rfc.py │ ├── hatch.py │ ├── indexnl.py │ ├── makemsg.py │ ├── pack.py │ ├── pftransport.py │ ├── poll.py │ ├── querymsg.py │ ├── querynl.py │ ├── querypkt.py │ ├── route.py │ ├── scanmsg.py │ ├── scanpkt.py │ ├── srif.py │ ├── toss.py │ └── unpack.py ├── binkp.py ├── bitparser.py ├── defaults.py ├── formats │ ├── __init__.py │ ├── attributeword.py │ ├── diskmessage.py │ ├── fsc0045packet.py │ ├── fsc0048packet.py │ ├── fts0001packet.py │ └── packedmessage.py ├── ftnerror.py ├── message.py ├── messagefactory.py ├── nodelist.py ├── odict.py ├── packet.py ├── packetfactory.py ├── router.py ├── sequence.py ├── srif.py └── util.py ├── setup.py └── tests ├── route.cfg ├── run-cli-tests ├── sample.msg └── sample.pkt /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[co] 2 | .*.sw? 3 | 4 | -------------------------------------------------------------------------------- /ANNOUNCE.rst: -------------------------------------------------------------------------------- 1 | ============================================================ 2 | Pytnon-FTN: API and tools for Fidonet and other FTN networks 3 | ============================================================ 4 | 5 | Python-FTN is a set of Python modules that provide the following 6 | capabilities: 7 | 8 | - Generating FTN messages. 9 | - Editing message data. 10 | - Displaying and querying message data. 11 | - Packing/unpacking FTN mail packets. 12 | - Editing packet data. 13 | - Displaying and querying packet data. 14 | - Parsing and querying FTN nodelists. 15 | - Making routing decsisions from nodelist data. 16 | 17 | The python-ftn API makes it easy to develop new tools that interact 18 | with FTN format data. 19 | 20 | Python-ftn supports FTS-0001 "on disk" and "packed" messages (and can 21 | convert between them), and supports both FTS-0001 ("type 2") and 22 | FSC-0048 ("type 2+") packets. 23 | 24 | Availability 25 | ============ 26 | 27 | Python-ftn is available from: 28 | 29 | - http://projects.oddbit.com/python-ftn/ 30 | 31 | Online documentation is available from: 32 | 33 | - https://github.com/larsks/python-ftn/wiki 34 | 35 | Examples 36 | ======== 37 | 38 | Change the destination of an outbound packet:: 39 | 40 | $ ftn-editpkt --dest 1:322/761 00000001.pkt 41 | 42 | Change the sender name in a message:: 43 | 44 | $ ftn-editmsg --from 'A Different Person' 1.msg 45 | 46 | Create and pack a message for delivery:: 47 | 48 | $ echo "My body text" | 49 | ftn-makemsg \ 50 | --from 'Lars Kellogg-Stedman' \ 51 | --to 'Someone Else' \ 52 | --orig 1:322/761 \ 53 | --dest 99:99/99 \ 54 | --subject "This is a test" \ 55 | --flag killSent --flag private | 56 | ftn-pack -d 1:322/759 --out 014202f7.out 57 | 58 | Examine the generated packet:: 59 | 60 | $ ftn-scanpkt -m 014202f7.out 61 | ====================================================================== 62 | 014202f7.out: 1:322/761 -> 1:322/759 @ 2011-02-26 23:31:59 63 | ====================================================================== 64 | 65 | [000] 66 | From: Lars Kellogg-Stedman @ 322/761 67 | To: Someone Else @ 99/99 68 | Date: 26 Feb 11 23:31:59 69 | Subject: This is a test 70 | Flags: KILLSENT PRIVATE 71 | 72 | Author 73 | ====== 74 | 75 | Python-ftn was written by Lars Kellogg-Stedman. 76 | 77 | - Fidonet: Lars @ 1:322/761 78 | - Internet: lars@oddbit.com 79 | - Twitter: the_odd_bit 80 | 81 | * Origin: The Odd Bit 1:322/761 82 | 83 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . 675 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include COPYING 3 | 4 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ==================== 2 | FTN Tools for Python 3 | ==================== 4 | 5 | ``Python-ftn`` is an API and suite of tools for manipulating FTN message and 6 | packet data. The package currently includes tools for: 7 | 8 | - Generating FTN messages. 9 | - Editing message data. 10 | - Displaying and querying message data. 11 | - Packing/unpacking FTN mail packets. 12 | - Editing packet data. 13 | - Displaying and querying packet data. 14 | - Parsing and querying FTN nodelists. 15 | - Making routing decsisions from nodelist data. 16 | 17 | The ``python-ftn`` API makes it easy to develop new tools that interact with 18 | FTN format data. 19 | 20 | Python-ftn support FTS-0001 "on disk" and "packed" messages (and can 21 | convert between them), and supports both FTS-0001 ("type 2") and FSC-0048 22 | ("type 2+") packets. 23 | 24 | Documentation is available online at: 25 | 26 | - https://github.com/larsks/python-ftn/wiki 27 | 28 | Requirements 29 | ============ 30 | 31 | This software requires the following Python modules: 32 | 33 | - `bitstring`_ -- for reading/writing binary formats. 34 | - sqlite3 -- for interacting with SQLite databases. 35 | 36 | .. _bitstring: http://code.google.com/p/python-bitstring/ 37 | 38 | Installation 39 | ============ 40 | 41 | Install this package by running ``setup.py``:: 42 | 43 | python setup.py install 44 | 45 | Configuration 46 | ============= 47 | 48 | Many of the tools in this package read configuration information from a 49 | file named ``ftn.cfg``, which is by default found in ``/etc/ftn/ftn.cfg``. 50 | You can change this default in the following ways: 51 | 52 | - Use the ``-f`` command line option to provide an explicit path to a 53 | configuration file. 54 | - Set the ``FTN_CONFIG_DIR`` environment variable. The python-ftn tools 55 | will look in this directory for ``ftn.cfg`` and other config files. 56 | - Set the ``FTN_CONFIG_FILE`` environment variable. The python-ftn tools 57 | will use this file, but continue to look for other files in 58 | ``FTN_CONFIG_DIR`` or ``/etc/ftn``. 59 | 60 | Reporting bugs 61 | ============== 62 | 63 | If you would like to report bugs or make a feature request, please use the 64 | project issue tracker: 65 | 66 | - https://github.com/larsks/python-ftn/issues 67 | 68 | Author 69 | ====== 70 | 71 | Python-ftn was written by Lars Kellogg-Stedman. 72 | 73 | - Fidonet: Lars @ 1:322/761 74 | - Internet: `lars@oddbit.com`_ 75 | - Twitter: the_odd_bit_ 76 | 77 | .. _lars@oddbit.com: mailto:lars@oddbit.com 78 | .. _the_odd_bit: http://www.twitter.com/the_odd_bit 79 | 80 | License 81 | ======= 82 | 83 | Python-ftn is free software: you can redistribute it and/or modify it under 84 | the terms of the `GNU General Public License`_ as published by the Free 85 | Software Foundation, either version 3 of the License, or (at your option) 86 | any later version. 87 | 88 | This program is distributed in the hope that it will be useful, but WITHOUT 89 | ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 90 | FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 91 | more details. 92 | 93 | You should have received a copy of the GNU General Public License along 94 | with this software. If not, see . 95 | 96 | Fido, FidoNet and the dog-with-diskette are registered trademarks of Tom 97 | Jennings, San Francisco California, USA. 98 | 99 | .. _gnu general public license: 100 | http://www.gnu.org/licenses/gpl-3.0-standalone.html 101 | 102 | -------------------------------------------------------------------------------- /fidonet/__init__.py: -------------------------------------------------------------------------------- 1 | from ftnerror import * 2 | 3 | __author__ = 'Lars Kellogg-Stedman' 4 | __license__ = 'GPL' 5 | __email__ = 'lars@oddbit.com' 6 | __version__ = '1.2.0' 7 | 8 | from packetfactory import PacketFactory 9 | from messagefactory import MessageFactory 10 | from address import Address 11 | from router import Router 12 | 13 | -------------------------------------------------------------------------------- /fidonet/address.py: -------------------------------------------------------------------------------- 1 | from ftnerror import * 2 | import re 3 | 4 | re_ftn_addr = re.compile(''' 5 | ((?P\d+):)? 6 | (?P\d+)/ 7 | (?P\d+) 8 | (.(?P\d+))? 9 | (@(?P\w+))? 10 | ''', re.VERBOSE) 11 | 12 | re_rfc_addr = re.compile(''' 13 | (p(?P\d+)\.)? 14 | f(?P\d+)\. 15 | n(?P\d+)\. 16 | z(?P\d+)\. 17 | fidonet.org 18 | ''', re.VERBOSE) 19 | 20 | def int_property(name): 21 | '''Create a class property that converts all values to ints.''' 22 | 23 | def s(self, v): 24 | if v is not None: 25 | setattr(self, '_%s' % name, int(v)) 26 | 27 | def g(self): 28 | return getattr(self, '_%s' % name) 29 | 30 | return property(g,s) 31 | 32 | class Address (object): 33 | '''A class for parsing and creating FTN network addresses. 34 | 35 | Parsing 36 | ------- 37 | 38 | The class will parse both RFC and FTN style representations. For 39 | example:: 40 | 41 | >>> a = Address('1:322/761') 42 | >>> a.ftn 43 | '1:322/761' 44 | >>> a.rfc 45 | 'f761.n322.z1.fidonet.org' 46 | 47 | >>> a = Address('f761.n322.z1.fidonet.org') 48 | >>> a.ftn 49 | '1:322/761' 50 | 51 | Creating 52 | -------- 53 | 54 | You can also create addresses:: 55 | 56 | >>> a = Address(zone=1, node=761, net=322) 57 | >>> a.ftn 58 | '1:322/761' 59 | 60 | An empty address has node=0 and net=0 and all other fields unset:: 61 | 62 | >>> a = Address() 63 | >>> a.ftn 64 | '0/0' 65 | >>> a.zone = 1 66 | >>> a.ftn 67 | '1:0/0' 68 | 69 | ''' 70 | 71 | fields = [ 'zone', 'net', 'node', 'point' ] 72 | 73 | def __init__ (self, 74 | addr=None, 75 | rfc_domain='fidonet.org', 76 | ftn_domain='fidonet', 77 | ftn5d=False, 78 | **kw): 79 | 80 | self.rfc_domain = rfc_domain 81 | self.ftn_domain = ftn_domain 82 | self.ftn5d = ftn5d 83 | 84 | # defaults 85 | self._zone = 0 86 | self._net = 0 87 | self._node = 0 88 | self._point = 0 89 | 90 | if isinstance(addr, Address): 91 | self._zone = addr.zone 92 | self._net = addr.net 93 | self._node = addr.node 94 | self._point = addr.point 95 | elif addr is not None: 96 | self.parse_from_string(addr) 97 | 98 | for k,v in kw.items(): 99 | if k in self.fields: 100 | setattr(self, k, v) 101 | 102 | def parse_from_string(self, addr): 103 | for x in [ re_ftn_addr, re_rfc_addr ]: 104 | mo = x.match(addr) 105 | if mo: 106 | for k in self.fields: 107 | if mo.groupdict().get(k) is not None: 108 | setattr(self, k, mo.group(k)) 109 | return 110 | 111 | raise InvalidAddress(addr) 112 | 113 | zone = int_property('zone') 114 | net = int_property('net') 115 | node = int_property('node') 116 | point = int_property('point') 117 | 118 | def _ftn(self, showPoint=True): 119 | addr = [] 120 | if self.get('zone', 0) > 0: 121 | addr.append('%(zone)s:' % self) 122 | 123 | addr.append('%(net)s/%(node)s' % self) 124 | 125 | if showPoint and self.get('point', 0) > 0: 126 | addr.append('.%(point)s' % self) 127 | 128 | if self.ftn5d: 129 | addr.append('@%s' % self.ftn_domain) 130 | 131 | return ''.join(addr) 132 | 133 | def _pointless(self): 134 | return self._ftn(showPoint=False) 135 | 136 | def _msg(self): 137 | return '%(net)s/%(node)s' % self 138 | 139 | def _rfc(self, showPoint=True): 140 | addr = [] 141 | 142 | for field in [ 'zone', 'net', 'node']: 143 | if self.get(field) is None: 144 | raise InvalidAddress() 145 | 146 | if showPoint and self.get('point', 0) > 0: 147 | addr.append('p%(point)s' % self) 148 | 149 | addr.append('f%(node)s.n%(net)s.z%(zone)s' % self) 150 | addr.append(self.rfc_domain) 151 | 152 | return '.'.join(addr) 153 | 154 | def _hex(self): 155 | return '%(net)04x%(node)04x' % self 156 | 157 | ftn = property(_ftn) 158 | pointless = property(_pointless) 159 | rfc = property(_rfc) 160 | hex = property(_hex) 161 | msg = property(_msg) 162 | 163 | def __str__(self): 164 | return self.ftn 165 | 166 | def __repr__ (self): 167 | return self.__str__() 168 | 169 | def __getitem__(self, k): 170 | if k in self.fields: 171 | return getattr(self, k) 172 | else: 173 | raise KeyError(k) 174 | 175 | def get(self, k, default=None): 176 | try: 177 | return getattr(self, k, None) 178 | except AttributeError: 179 | return default 180 | 181 | if __name__ == '__main__': 182 | a = Address('1:322/761') 183 | b = Address('f761.n322.z1.fidonet.org') 184 | 185 | -------------------------------------------------------------------------------- /fidonet/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import optparse 4 | import logging 5 | from logging import FileHandler 6 | from logging.handlers import SysLogHandler 7 | import ConfigParser 8 | 9 | import bitstring 10 | 11 | from fidonet import defaults 12 | 13 | class App (object): 14 | logtag = 'fidonet' 15 | 16 | def run(class_): 17 | app = class_() 18 | return app.main() 19 | run = classmethod(run) 20 | 21 | def main(self): 22 | self.setup_basic_logging() 23 | 24 | parser = self.create_parser() 25 | opts, args = parser.parse_args() 26 | 27 | self.opts = opts 28 | self.parser = parser 29 | 30 | self.cfg = self.create_config() 31 | self.read_config() 32 | self.defaults = self.set_defaults() 33 | 34 | self.setup_logging() 35 | self.setup_umask() 36 | 37 | self.log.debug('reading config from %s' % self.opts.config) 38 | self.log.debug('finished generic setup') 39 | 40 | if self.opts.dump_config: 41 | self.log.debug('dumping configuration to stdout') 42 | self.cfg.write(sys.stdout) 43 | sys.exit() 44 | 45 | self.handle_args(args) 46 | 47 | def setup_basic_logging(self): 48 | logging.basicConfig( 49 | datefmt='%Y-%m-%d %H:%M:%S', 50 | format='%(asctime)s %(module)s:%(levelname)s [%(process)s] %(message)s') 51 | 52 | def create_parser(self): 53 | p = optparse.OptionParser() 54 | 55 | p.add_option('--config', '--cf', 56 | default=defaults.ftn_config_file, 57 | help='Path to main configuration file.') 58 | p.add_option('--config-dir', 59 | default=defaults.ftn_config_dir, 60 | help='Find configuration files in this directory.') 61 | p.add_option('--data-dir', 62 | default=None, 63 | help='Find data files in this directory.'), 64 | p.add_option('-v', '--verbose', 65 | action='store_true', 66 | help='Enable addtional logging.') 67 | p.add_option('--debug', 68 | action='store_true', 69 | help='Turn on debugging output.') 70 | p.add_option('--option', 71 | action='append', 72 | default=[], 73 | help='Set configuration options on the command line.') 74 | p.add_option('--dump-config', 75 | action='store_true', 76 | help='Dump configuration to stdout.') 77 | 78 | return p 79 | 80 | def set_defaults(self): 81 | '''This is called after processing command line options and reading 82 | the config file. It is responsible for setting up any default 83 | values and for adjusting existing config files (e.g., transforming 84 | relative paths into absolute paths).''' 85 | 86 | if not self.opts.data_dir: 87 | self.opts.data_dir = self.get('fidonet', 'datadir') 88 | if not self.opts.data_dir: 89 | self.opts.data_dir = defaults.ftn_data_dir 90 | 91 | if self.opts.data_dir: 92 | self.opts.data_dir = os.path.abspath(self.opts.data_dir) 93 | if self.opts.config_dir: 94 | self.opts.config_dir = os.path.abspath(self.opts.config_dir) 95 | 96 | def create_config(self): 97 | cfg = ConfigParser.ConfigParser() 98 | return cfg 99 | 100 | def read_config(self): 101 | self.cfg.read(self.opts.config) 102 | 103 | for opt in self.opts.option: 104 | name, val = opt.split('=',1) 105 | section, name = name.split(':', 1) 106 | 107 | if not self.cfg.has_section(section): 108 | self.cfg.add_section(section) 109 | 110 | self.cfg.set(section, name, val) 111 | 112 | def setup_logging(self): 113 | if self.opts.debug: 114 | logging.root.setLevel(logging.DEBUG) 115 | elif self.opts.verbose: 116 | logging.root.setLevel(logging.INFO) 117 | 118 | self.log = logging.getLogger(self.logtag) 119 | 120 | if self.get('fidonet', 'logfile'): 121 | self.log.debug('adding file handler') 122 | handler = FileHandler(self.cfg.get('fidonet', 'logfile')) 123 | handler.setFormatter(logging.root.handlers[0].formatter) 124 | self.log.addHandler(handler) 125 | 126 | def setup_umask(self): 127 | try: 128 | umask = int(self.cfg.get('fidonet', 'umask'), 8) 129 | except (ConfigParser.NoSectionError, 130 | ConfigParser.NoOptionError): 131 | umask = 0022 132 | 133 | self.log.debug('set umask to %04o.' % umask) 134 | os.umask(umask) 135 | 136 | def get(self, section, option, default=None): 137 | try: 138 | return self.cfg.get(section, option) 139 | except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): 140 | return default 141 | 142 | def relpath(self, path, dir): 143 | if path is None: 144 | return None 145 | elif path.startswith('/'): 146 | return path 147 | else: 148 | return os.path.join(dir, path) 149 | 150 | def get_cfg_path(self, section, option, default=None): 151 | try: 152 | path = self.cfg.get(section, option).split()[0] 153 | return self.relpath(path, self.opts.config_dir) 154 | except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): 155 | return default 156 | 157 | def get_cfg_paths(self, section, option, default=''): 158 | for path in self.get(section, option, default).split(): 159 | yield self.relpath(path, self.opts.config_dir) 160 | 161 | def get_data_path(self, section, option, default=None): 162 | try: 163 | path = self.cfg.get(section, option).split()[0] 164 | return self.relpath(path, self.opts.data_dir) 165 | except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): 166 | return default 167 | 168 | def get_data_paths(self, section, option, default=''): 169 | for path in self.get(section, option, default).split(): 170 | yield self.relpath(path, self.opts.data_dir) 171 | 172 | def handle_args(self, args): 173 | pass 174 | 175 | def for_each_arg(self, func, args, ctx=None): 176 | if not args: 177 | args = ['-'] 178 | 179 | for msgfile in args: 180 | if msgfile == '-': 181 | msgbits = bitstring.BitStream(bytes=sys.stdin.read()) 182 | msgfile = '' 183 | else: 184 | msgbits = open(msgfile, 'r+') 185 | 186 | func(msgbits, msgfile, ctx=ctx) 187 | 188 | class AppUsingFiles (App): 189 | 190 | def create_parser(self): 191 | p = super(AppUsingFiles, self).create_parser() 192 | p.add_option('-O', '--output', '--out', 193 | metavar='FILE', 194 | help='Send output to FILE.') 195 | p.add_option('-D', '--destdir', '--dir', 196 | metavar='DIR', 197 | help='Place output files in DIR.') 198 | return p 199 | 200 | class AppUsingAddresses (App): 201 | 202 | def create_parser(self): 203 | p = super(AppUsingAddresses, self).create_parser() 204 | p.add_option('-o', '--origin', '--orig', 205 | metavar='ADDRESS', 206 | help='Set origin address to ADDRESS.') 207 | p.add_option('-d', '--destination', '--dest', 208 | metavar='ADDRESS', 209 | help='Set destination address to ADDRESS.') 210 | return p 211 | 212 | class AppUsingNames (App): 213 | 214 | def create_parser(self): 215 | p = super(AppUsingNames, self).create_parser() 216 | p.add_option('-f', '--from-name', '--from', 217 | metavar='NAME', 218 | help='Set sender name to NAME.') 219 | p.add_option('-t', '--to-name', '--to', 220 | metavar='NAME', 221 | help='Set recipient name to NAME.') 222 | return p 223 | 224 | if __name__ == '__main__': 225 | a = App() 226 | a.main() 227 | 228 | 229 | -------------------------------------------------------------------------------- /fidonet/apps/__init__.py: -------------------------------------------------------------------------------- 1 | '''Command line tools distributed with this package are all children of 2 | this module. See ``fidonet.apps.`` for application-specific 3 | documentation.''' 4 | 5 | -------------------------------------------------------------------------------- /fidonet/apps/binknodes.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fidonet 4 | import fidonet.app 5 | from fidonet.nodelist import Nodelist, Node, Flag 6 | 7 | class App(fidonet.app.App): 8 | logtag = 'fidonet.binknodes' 9 | 10 | def create_parser(self): 11 | p = super(App, self).create_parser() 12 | p.add_option('-n', '--nodelist') 13 | p.add_option('-D', '--domain', 14 | default='fidonet') 15 | return p 16 | 17 | def handle_args(self, args): 18 | if self.opts.nodelist is None: 19 | nodelist = self.get_data_path('fidonet', 'nodelist') 20 | self.opts.nodelist = '%s.idx' % nodelist 21 | 22 | nl = Nodelist('sqlite:///%s' % self.opts.nodelist) 23 | nl.setup() 24 | session = nl.broker() 25 | 26 | nodes = session.query(Node).join('flags').filter( 27 | Flag.flag_name == 'IBN') 28 | 29 | for n in nodes: 30 | inet = n.inet('IBN') 31 | if inet is None: 32 | continue 33 | 34 | print 'Node %-20s %s' % ( 35 | ('%s@%s' % (n.address, self.opts.domain)), 36 | inet 37 | ) 38 | 39 | if __name__ == '__main__': 40 | App.run() 41 | 42 | -------------------------------------------------------------------------------- /fidonet/apps/editmsg.py: -------------------------------------------------------------------------------- 1 | 2 | #!/usr/bin/python 3 | 4 | import os 5 | import sys 6 | 7 | import fidonet 8 | import fidonet.app 9 | import fidonet.message 10 | 11 | class App (fidonet.app.AppUsingFiles, 12 | fidonet.app.AppUsingAddresses, 13 | fidonet.app.AppUsingNames): 14 | logtag = 'fidonet.editmsg' 15 | 16 | def create_parser(self): 17 | p = super(App, self).create_parser() 18 | p.add_option('-k', '--kludge', action='append', default=[]) 19 | p.add_option('-s', '--subject') 20 | p.add_option('-T', '--time') 21 | p.add_option('-A', '--area') 22 | p.add_option('-g', '--flag', action='append', 23 | default=[]) 24 | p.add_option('--originline', '--oline') 25 | p.add_option('--disk', action='store_false', 26 | dest='packed') 27 | p.add_option('--packed', action='store_true', 28 | dest='packed') 29 | return p 30 | 31 | def handle_args(self, args): 32 | self.for_each_arg(self.edit_msg, args) 33 | 34 | def edit_msg(self, src, name, ctx): 35 | msg = fidonet.MessageFactory(src) 36 | 37 | if self.opts.to_name: 38 | self.log.debug('setting to = %s' % self.opts.to_name) 39 | msg.toUsername = self.opts.to_name 40 | if self.opts.from_name: 41 | self.log.debug('setting from = %s' % self.opts.from_name) 42 | msg.fromUsername = self.opts.from_name 43 | if self.opts.origin: 44 | addr = fidonet.Address(self.opts.origin) 45 | self.log.debug('setting origin = %s' % addr) 46 | msg.origAddr = addr 47 | if self.opts.destination: 48 | addr = fidonet.Address(self.opts.destination) 49 | self.log.debug('setting destination = %s' % addr) 50 | msg.destAddr = addr 51 | if self.opts.subject: 52 | self.log.debug('setting subject = %s' % self.opts.subject) 53 | msg.subject = self.opts.subject 54 | if self.opts.time: 55 | msg.dateTime = self.opts.time 56 | self.log.debug('set dateTime = %s' % msg.dateTime) 57 | 58 | for f in self.opts.flag: 59 | if f.startswith('!'): 60 | msg.attributeWord[f[1:]] = False 61 | else: 62 | msg.attributeWord[f] = True 63 | 64 | if self.opts.area: 65 | msg.body.area = self.opts.area 66 | 67 | for k in self.opts.kludge: 68 | k_name, k_val = k.split(' ', 1) 69 | msg.body.klines[k_name] = msg.body.klines.get(k_name, []) + [k_val] 70 | 71 | if self.opts.debug: 72 | import pprint 73 | pprint.pprint(msg, stream=sys.stderr) 74 | 75 | if name == '': 76 | msg.write(sys.stdout) 77 | else: 78 | src.seek(0) 79 | msg.write(src) 80 | src.close() 81 | 82 | if __name__ == '__main__': 83 | App.run() 84 | 85 | -------------------------------------------------------------------------------- /fidonet/apps/editpkt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | import time 6 | 7 | import fidonet 8 | import fidonet.app 9 | 10 | class App (fidonet.app.AppUsingAddresses): 11 | logtag = 'fidonet.editpkt' 12 | 13 | def create_parser(self): 14 | p = super(App, self).create_parser() 15 | p.add_option('-t', '--time', 16 | help='Set the time in the packet, specified as "YYYY-mm-dd HH:MM:SS"') 17 | p.add_option('--capword') 18 | p.add_option('--productdata') 19 | return p 20 | 21 | def handle_args(self, args): 22 | self.for_each_arg(self.edit_pkt, args) 23 | 24 | def edit_pkt(self, src, name, ctx): 25 | pkt = fidonet.PacketFactory(src) 26 | 27 | if self.opts.origin: 28 | pkt.origAddr = fidonet.Address(self.opts.origin) 29 | self.log.debug('set origAddr = %s' % pkt.origAddr) 30 | if self.opts.destination: 31 | pkt.destAddr = fidonet.Address(self.opts.destination) 32 | self.log.debug('set destAddr = %s' % pkt.destAddr) 33 | if self.opts.time: 34 | t = time.strptime(self.opts.time, '%Y-%m-%d %H:%M:%S') 35 | pkt.time = t 36 | self.log.debug('set time = %s' % time.strftime( 37 | '%Y-%m-%d %H:%M:%S', t)) 38 | if self.opts.capword: 39 | pkt.capWord = int(self.opts.capword) 40 | self.log.debug('set capword = %d' % pkt.capWord) 41 | 42 | if name == '': 43 | pkt.write(sys.stdout) 44 | else: 45 | src.seek(0) 46 | pkt.write(src) 47 | src.close() 48 | 49 | self.log.info('wrote edits to %s' % name) 50 | 51 | if __name__ == '__main__': 52 | App.run() 53 | 54 | -------------------------------------------------------------------------------- /fidonet/apps/ftn2rfc.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import email.message 4 | 5 | import bitstring 6 | 7 | import fidonet 8 | import fidonet.app 9 | 10 | class App(fidonet.app.App): 11 | def create_parser(self): 12 | p = super(App, self).create_parser() 13 | p.add_option('-z', '--zone') 14 | return p 15 | 16 | def handle_args(self, args): 17 | if self.opts.zone is None: 18 | try: 19 | myaddr = fidonet.Address(self.cfg.get('fidonet', 'address')) 20 | self.opts.zone = myaddr.zone 21 | except: 22 | pass 23 | if args: 24 | src = open(args.pop(0)) 25 | else: 26 | src = bitstring.ConstBitStream(bytes=sys.stdin.read()) 27 | 28 | ftnmsg = fidonet.MessageFactory(src) 29 | rfcmsg = email.message.Message() 30 | 31 | origAddr = ftnmsg.origAddr 32 | destAddr = ftnmsg.destAddr 33 | 34 | origAddr.zone = self.opts.zone 35 | destAddr.zone = self.opts.zone 36 | 37 | rfcmsg['From'] = '@'.join([ 38 | ftnmsg.fromUsername.replace(' ', '_'), 39 | origAddr.rfc]) 40 | rfcmsg['To'] = '@'.join([ 41 | ftnmsg.toUsername.replace(' ', '_'), 42 | destAddr.rfc]) 43 | rfcmsg['Subject'] = ftnmsg.subject 44 | 45 | if ftnmsg.body.area is not None: 46 | rfcmsg['Newsgroups'] = ftnmsg.body.area 47 | 48 | for k in ftnmsg.body['klines'].keys(): 49 | for v in ftnmsg.body['klines'][k]: 50 | rfcmsg['X-FTN-Kludge'] = '%s %s' % (k,v) 51 | 52 | rfcmsg.set_payload(ftnmsg.body.text) 53 | 54 | print rfcmsg.as_string() 55 | 56 | if __name__ == '__main__': 57 | App.run() 58 | 59 | -------------------------------------------------------------------------------- /fidonet/apps/hatch.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import binascii 5 | 6 | import fidonet.app 7 | 8 | tic_template = '''Area %(area)s 9 | Origin %(origin)s 10 | From %(origin)s 11 | File %(filename)s 12 | Desc %(description)s 13 | CRC %(checksum)s 14 | Created by python-ftn 15 | Size %(size)s 16 | Date %(now)s 17 | Pw %(password)s''' 18 | 19 | class App (fidonet.app.AppUsingAddresses): 20 | 21 | def create_parser(self): 22 | p = super(App, self).create_parser() 23 | 24 | p.add_option('-A', '--area') 25 | p.add_option('-D', '--description') 26 | p.add_option('-P', '--password', default='') 27 | 28 | return p 29 | 30 | def handle_args (self, args): 31 | if not self.opts.origin: 32 | self.opts.origin = self.get('fidonet', 'address') 33 | if self.opts.origin is None: 34 | self.log.error('Missing origin address.') 35 | sys.exit(1) 36 | 37 | srcfile_path = args.pop(0) 38 | srcfile_name = os.path.basename(srcfile_path) 39 | 40 | fd = open(srcfile_path) 41 | crc = binascii.crc32(fd.read()) & 0xffffffff 42 | srcfile_size = fd.tell() 43 | now = int(time.time()) 44 | 45 | print tic_template % { 46 | 'area': self.opts.area, 47 | 'origin': self.opts.origin, 48 | 'filename': srcfile_name, 49 | 'description': self.opts.description, 50 | 'checksum': '%08X' % crc, 51 | 'size': srcfile_size, 52 | 'now': now, 53 | 'password': self.opts.password 54 | } 55 | 56 | if __name__ == '__main__': 57 | App.run() 58 | 59 | -------------------------------------------------------------------------------- /fidonet/apps/indexnl.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import glob 4 | import errno 5 | 6 | from sqlalchemy.sql import and_, or_, not_ 7 | 8 | import fidonet 9 | import fidonet.app 10 | from fidonet.nodelist import Nodelist, Node 11 | 12 | class App(fidonet.app.AppUsingFiles): 13 | logtag = 'fidonet.indexnl' 14 | 15 | def handle_args(self, args): 16 | nodelists = [] 17 | 18 | if self.opts.output is None: 19 | nl_base_path = list(self.get_data_paths('fidonet', 'nodelist'))[0] 20 | self.opts.output = '%s.idx' % nl_base_path 21 | 22 | for nl_base_path in self.get_data_paths('fidonet', 'nodelist'): 23 | if os.path.exists(nl_base_path): 24 | nodelists.append(nl_base_path) 25 | self.log.debug('added nodelist %s' % nl_base_path) 26 | continue 27 | 28 | entries = glob.glob('%s.[0-9][0-9][0-9]' % nl_base_path) 29 | if entries: 30 | nodelists.append(list(sorted(entries))[-1]) 31 | self.log.debug('added nodelist %s' % nodelists[-1]) 32 | 33 | self.build_index(nodelists) 34 | 35 | def build_index(self, nodelists): 36 | tmp = tempfile.NamedTemporaryFile( 37 | dir=os.path.dirname(self.opts.output)) 38 | 39 | self.log.debug('output file is %s' % self.opts.output) 40 | self.log.debug('tmp file is %s' % tmp.name) 41 | 42 | nl = Nodelist('sqlite:///%s' % tmp.name) 43 | nl.setup(create=True) 44 | session = nl.broker() 45 | 46 | for nodelist in nodelists: 47 | self.index_one_nodelist(session, nodelist) 48 | 49 | self.log.info('creating nodelist index %s' % self.opts.output) 50 | os.rename(tmp.name, self.opts.output) 51 | 52 | try: 53 | tmp.close() 54 | except OSError, detail: 55 | if detail.errno == errno.ENOENT: 56 | pass 57 | else: 58 | raise 59 | 60 | def index_one_nodelist(self, session, nodelist): 61 | addr = fidonet.Address() 62 | hubs = [] 63 | host = None 64 | 65 | self.log.info('indexing %s' % nodelist) 66 | 67 | for line in open(nodelist): 68 | if line.startswith(';'): 69 | continue 70 | 71 | node = fidonet.nodelist.Node() 72 | node.from_nodelist(line, addr) 73 | 74 | if node.node is None: 75 | continue 76 | 77 | if node.kw == 'hub': 78 | hubs.append(node) 79 | 80 | session.add(node) 81 | 82 | self.log.debug('committing changes') 83 | session.commit() 84 | 85 | self.log.debug('updating hub information') 86 | for hub in hubs: 87 | session.execute( 88 | Node.__table__.update(and_( 89 | Node.net == hub.net, 90 | Node.node != hub.node, 91 | Node.node !=0), 92 | dict(hub_id=hub.id))) 93 | 94 | self.log.debug('committing changes') 95 | session.commit() 96 | 97 | if __name__ == '__main__': 98 | App.run() 99 | 100 | -------------------------------------------------------------------------------- /fidonet/apps/makemsg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import sys 4 | import time 5 | import random 6 | 7 | from fidonet import Address 8 | from fidonet.formats import * 9 | import fidonet.app 10 | 11 | class App (fidonet.app.AppUsingAddresses, fidonet.app.AppUsingNames): 12 | logtag = 'fidonet.makemsg' 13 | 14 | def create_parser(self): 15 | p = super(App, self).create_parser() 16 | 17 | p.add_option('-k', '--kludge', action='append', default=[]) 18 | p.add_option('-s', '--subject') 19 | p.add_option('-T', '--time') 20 | p.add_option('-A', '--area') 21 | p.add_option('-g', '--flag', action='append', 22 | default=[]) 23 | p.add_option('--originline', '--oline') 24 | p.add_option('--output', '--out') 25 | p.add_option('--disk', action='store_false', 26 | dest='packed') 27 | p.add_option('--packed', action='store_true', 28 | dest='packed') 29 | 30 | p.set_default('packed', True) 31 | 32 | return p 33 | 34 | def handle_args (self, args): 35 | if self.opts.packed: 36 | msg = packedmessage.MessageParser.create() 37 | else: 38 | msg = diskmessage.MessageParser.create() 39 | 40 | if not self.opts.origin: 41 | try: 42 | self.opts.origin = self.cfg.get('fidonet', 'address') 43 | self.log.debug('got origin address = %s' % self.opts.origin) 44 | except: 45 | pass 46 | 47 | if not self.opts.time: 48 | self.opts.time = time.strftime('%d %b %y %H:%M:%S', time.localtime()) 49 | 50 | if self.opts.from_name: 51 | msg.fromUsername = self.opts.from_name 52 | self.log.debug('set fromUsername = %s' % msg.fromUsername) 53 | if self.opts.to_name: 54 | msg.toUsername = self.opts.to_name 55 | self.log.debug('set toUsername = %s' % msg.toUsername) 56 | if self.opts.subject: 57 | msg.subject = self.opts.subject 58 | self.log.debug('set subject = %s' % msg.subject) 59 | 60 | if self.opts.origin: 61 | msg.origAddr = Address(self.opts.origin) 62 | self.log.debug('set originAddr = %s' % msg.origAddr) 63 | if self.opts.destination: 64 | msg.destAddr = Address(self.opts.destination) 65 | self.log.debug('set destAddr = %s' % msg.destAddr) 66 | 67 | if self.opts.time: 68 | msg.dateTime = self.opts.time 69 | self.log.debug('set dateTime = %s' % msg.dateTime) 70 | 71 | # set message attributes 72 | attr = attributeword.AttributeWordParser.create() 73 | for f in self.opts.flag: 74 | attr[f] = 1 75 | msg.attributeWord = attr 76 | 77 | if self.opts.area: 78 | msg.parsed_body.area = self.opts.area 79 | 80 | # Generate an origin line if this is an echomail post. 81 | if not self.opts.originline and self.opts.area: 82 | try: 83 | self.opts.originline = '%s (%s)' % ( 84 | self.cfg.get('fidonet', 'sysname'), 85 | Address(self.cfg.get('fidonet', 'address'))) 86 | except: 87 | pass 88 | 89 | if self.opts.originline: 90 | msg.parsed_body.origin = self.opts.originline 91 | 92 | msg.parsed_body.klines['PID:'] = ['python-ftn'] 93 | msg.parsed_body.klines['MSGID:'] = [ '%(origAddr)s ' % msg + '%08x' % self.next_message_id() ] 94 | 95 | for k in self.opts.kludge: 96 | k_name, k_val = k.split(' ', 1) 97 | msg.parsed_body.klines[k_name] = msg.parsed_body.klines.get(k_name, []) + [k_val] 98 | 99 | if args: 100 | sys.stdin = open(args[0]) 101 | if self.opts.output: 102 | sys.stdout = open(self.opts.output, 'w') 103 | 104 | msg.parsed_body.text = sys.stdin.read() 105 | 106 | msg.write(sys.stdout) 107 | 108 | def next_message_id(self): 109 | '''so really this should generate message ids from a monotonically 110 | incrementing sequence...''' 111 | return random.randint(0,2**32) 112 | 113 | 114 | if __name__ == '__main__': 115 | App.run() 116 | 117 | -------------------------------------------------------------------------------- /fidonet/apps/pack.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | from fidonet import Address, MessageFactory 6 | from fidonet.formats import * 7 | import fidonet.app 8 | 9 | class App(fidonet.app.AppUsingFiles, fidonet.app.AppUsingAddresses): 10 | logtag = 'fidonet.pack' 11 | 12 | def create_parser(self): 13 | p = super(App, self).create_parser() 14 | p.add_option('--stdout', action='store_true') 15 | p.add_option('-B', '--binkd', action='store_true') 16 | return p 17 | 18 | def handle_args (self, args): 19 | if not self.opts.origin: 20 | self.opts.origin = self.get('fidonet', 'address') 21 | if self.opts.origin is None: 22 | self.log.error('Missing origin address.') 23 | sys.exit(1) 24 | 25 | if not self.opts.destination: 26 | self.log.error('Missing destination address.') 27 | sys.exit(1) 28 | 29 | pkt = fsc0048packet.PacketParser.create() 30 | 31 | pkt.origAddr = Address(self.opts.origin) 32 | pkt.destAddr = Address(self.opts.destination) 33 | pkt.time = time.localtime() 34 | 35 | self.msgcount = 0 36 | self.for_each_arg(self.pack_msg, args, ctx=pkt) 37 | 38 | if self.opts.binkd and not self.opts.destdir: 39 | try: 40 | self.opts.destdir = self.cfg.get('binkd', 'outbound') 41 | except: 42 | self.log.error('unable to determine binkd outbound directory') 43 | sys.exit(1) 44 | elif not self.opts.destdir: 45 | self.opts.destdir = '.' 46 | 47 | if self.opts.output: 48 | outname = self.opts.output 49 | out = open(outname, 'w') 50 | elif self.opts.stdout: 51 | outname = '' 52 | out = sys.stdout 53 | else: 54 | outname = os.path.join(self.opts.destdir, '%s.out' % 55 | pkt.destAddr.hex) 56 | out = open(outname, 'w') 57 | 58 | pkt.write(out) 59 | self.log.info('packed %d messages into %s.' % (self.msgcount, outname)) 60 | 61 | def pack_msg(self, src, name, ctx=None): 62 | pkt = ctx 63 | 64 | msg = MessageFactory(src) 65 | pkt.messages.append(msg) 66 | self.msgcount += 1 67 | self.log.info('packed message from %s @ %s to %s @ %s' % 68 | (msg.fromUsername, msg.origAddr, msg.toUsername, 69 | msg.destAddr)) 70 | 71 | if __name__ == '__main__': 72 | App.run() 73 | 74 | -------------------------------------------------------------------------------- /fidonet/apps/pftransport.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import tempfile 4 | import glob 5 | import errno 6 | 7 | import fidonet 8 | import fidonet.app 9 | from fidonet.nodelist import Nodelist, Node, Flag 10 | from fidonet.router import Router 11 | 12 | class App(fidonet.app.AppUsingFiles): 13 | logtag = 'fidonet.pftransport' 14 | 15 | def create_parser(self): 16 | p = super(App, self).create_parser() 17 | p.add_option('-n', '--nodelist') 18 | p.add_option('-r', '--routes') 19 | p.add_option('-T', '--transport', default='ifmail') 20 | p.add_option('-q', '--quiet', action='store_true') 21 | return p 22 | 23 | def handle_args(self, args): 24 | if self.opts.nodelist is None: 25 | nodelist = self.get_data_path('fidonet', 'nodelist') 26 | self.opts.nodelist = '%s.idx' % nodelist 27 | self.log.debug('using nodelist = %s' % self.opts.nodelist) 28 | 29 | if self.opts.routes is None: 30 | self.opts.routes = self.get_cfg_path( 31 | 'fidonet', 'routes') 32 | 33 | self.log.info('using nodelist %s' % self.opts.nodelist) 34 | if self.opts.routes: 35 | self.log.info('using routes from %s' % self.opts.routes) 36 | 37 | nl = Nodelist('sqlite:///%s' % self.opts.nodelist) 38 | nl.setup() 39 | session = nl.broker() 40 | 41 | router = Router(nl, self.opts.routes) 42 | 43 | nodes = session.query(Node).join('flags') 44 | 45 | if self.opts.output: 46 | sys.stdout = open(self.opts.output, 'w') 47 | 48 | for node in nodes: 49 | addr = fidonet.Address(node.address) 50 | rspec = router[addr] 51 | 52 | if not self.opts.quiet: 53 | print '# %s -> %s [%s]' % (addr, rspec[0], ' '.join([str(x) 54 | for x in rspec[1]])) 55 | print '%s\t%s:%s' % (addr.rfc, self.opts.transport, 56 | rspec[0].rfc) 57 | 58 | if __name__ == '__main__': 59 | App.run() 60 | 61 | -------------------------------------------------------------------------------- /fidonet/apps/poll.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fidonet 4 | import fidonet.app 5 | 6 | class App (fidonet.app.App): 7 | '''Creates a BinkD style poll file for all addresses specified on the 8 | command line. For example, if you run:: 9 | 10 | $ ftn-poll 1:322/761 1:123/500 11 | 12 | This script will create two poll files:: 13 | 14 | $ ls 15 | 007b01f4.ilo 16 | 014202f9.ilo 17 | 18 | If you have configured a path to your binkd outbound directory in 19 | ``fidonet.cfg``, ftn-poll will create poll files there; otherwise it 20 | will use your current directory.''' 21 | 22 | def create_parser(self): 23 | p = super(App, self).create_parser() 24 | p.add_option('-C', '--crash', action='store_const', 25 | dest='mode', const='c') 26 | p.add_option('-I', '--immediate', action='store_const', 27 | dest='mode', const='i') 28 | p.add_option('-D', '--direct', action='store_const', 29 | dest='mode', const='d') 30 | p.add_option('-F', '--normal', action='store_const', 31 | dest='mode', const='f') 32 | 33 | p.set_default('mode', 'f') 34 | return p 35 | 36 | def handle_args(self, args): 37 | outb = self.get('binkd', 'outbound') 38 | if outb is None: 39 | outb = '.' 40 | 41 | for addr in args: 42 | addr = fidonet.Address(addr) 43 | self.log.info('creating poll for %s.' % addr) 44 | poll = os.path.join(outb, '%s.%slo' % (addr.hex, self.opts.mode)) 45 | self.log.debug('poll file = %s' % poll) 46 | open(poll, 'w').close() 47 | 48 | if __name__ == '__main__': 49 | App.run() 50 | 51 | -------------------------------------------------------------------------------- /fidonet/apps/querymsg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | import tempfile 6 | 7 | import bitstring 8 | 9 | import fidonet 10 | import fidonet.app 11 | 12 | class App (fidonet.app.App): 13 | logtag = 'fidonet.querymsg' 14 | 15 | def create_parser(self): 16 | p = super(App, self).create_parser() 17 | p.add_option('-q', '--query', action='append', default=[]) 18 | p.add_option('--queryformat', '--qf') 19 | return p 20 | 21 | def query_msg(self, src, name, ctx): 22 | msg = fidonet.MessageFactory(src) 23 | 24 | try: 25 | if self.opts.queryformat: 26 | print self.opts.queryformat % msg 27 | else: 28 | for k in self.opts.query: 29 | print msg[k] 30 | except KeyError, detail: 31 | print >>sys.stderr, 'error: %s: no such field.' % detail 32 | 33 | def handle_args(self, args): 34 | self.for_each_arg(self.query_msg, args) 35 | 36 | if __name__ == '__main__': 37 | App.run() 38 | 39 | -------------------------------------------------------------------------------- /fidonet/apps/querynl.py: -------------------------------------------------------------------------------- 1 | import os 2 | import tempfile 3 | import glob 4 | import errno 5 | 6 | import fidonet 7 | import fidonet.app 8 | from fidonet.nodelist import Nodelist, Node, Flag 9 | 10 | class App(fidonet.app.AppUsingFiles): 11 | '''Query the nodelist index. 12 | 13 | Examples 14 | -------- 15 | 16 | Find all BinkD capable nodes in 1:322/*:: 17 | 18 | $ ftn-querynl -g IBN -a 1:322/* 19 | 1:322/0 : Mass_Net_Central 20 | 1:322/320 : Lost_Crypt 21 | 1:322/759 : The_Zone 22 | 1:322/761 : Somebbs 23 | 1:322/762 : The_American_Connection_BBS 24 | 1:322/767 : EOS_2 25 | 26 | Show verbose information for 1:322/761:: 27 | 28 | $ ftn-querynl -a 1:322/761 -v 29 | Address: 1:322/761 30 | Name: Somebbs 31 | SysOp: Lars_Kellogg-stedman 32 | Phone: 000-0-0-0-0 33 | BinkD: somebbs.oddbit.com 34 | Flags: INA:somebbs.oddbit.com IBN 35 | 36 | ''' 37 | 38 | logtag = 'fidonet.querynl' 39 | 40 | def create_parser(self): 41 | p = super(App, self).create_parser() 42 | p.add_option('-n', '--nodelist') 43 | p.add_option('-a', '--address') 44 | p.add_option('-g', '--flag', action='append', default=[]) 45 | p.add_option('-N', '--name') 46 | p.add_option('-p', '--sysop') 47 | p.add_option('-r', '--raw', action='store_true') 48 | return p 49 | 50 | def handle_args(self, args): 51 | if self.opts.nodelist is None: 52 | nodelist = self.get_data_path('fidonet', 'nodelist') 53 | self.opts.nodelist = '%s.idx' % nodelist 54 | 55 | nl = Nodelist('sqlite:///%s' % self.opts.nodelist) 56 | nl.setup() 57 | session = nl.broker() 58 | 59 | nodes = session.query(Node).join('flags') 60 | 61 | if self.opts.address: 62 | if '%' in self.opts.address or '*' in self.opts.address: 63 | nodes = nodes.filter( 64 | Node.address.like(self.opts.address.replace('*', '%'))) 65 | else: 66 | nodes = nodes.filter(Node.address == self.opts.address) 67 | 68 | if self.opts.sysop: 69 | if '%' in self.opts.sysop or '*' in self.opts.sysop: 70 | nodes = nodes.filter( 71 | Node.sysop.like(self.opts.sysop.replace('*', '%'))) 72 | else: 73 | nodes = nodes.filter(Node.sysop == self.opts.sysop) 74 | 75 | if self.opts.name: 76 | if '%' in self.opts.name or '*' in self.opts.name: 77 | nodes = nodes.filter( 78 | Node.name.like(self.opts.name.replace('*', '%'))) 79 | else: 80 | nodes = nodes.filter(Node.name == self.opts.name) 81 | 82 | for flag in self.opts.flag: 83 | if flag.startswith('!'): 84 | pass 85 | else: 86 | nodes = nodes.filter(Flag.flag_name == flag) 87 | 88 | if self.opts.debug: 89 | print nodes.as_scalar() 90 | 91 | for n in nodes: 92 | if self.opts.verbose: 93 | print '%10s: %s' % ('Address', n.address) 94 | print '%10s: %s' % ('Name', n.name) 95 | print '%10s: %s' % ('SysOp', n.sysop) 96 | print '%10s: %s' % ('Phone', n.phone) 97 | 98 | if [x for x in n.flags if x.flag_name == 'IBN']: 99 | print '%10s: %s' % ('BinkD', n.inet('IBN')) 100 | if [x for x in n.flags if x.flag_name == 'ITN']: 101 | print '%10s: %s' % ('Telnet', n.inet('ITN')) 102 | print '%10s:' % ('Flags',), 103 | for flag in n.flags: 104 | if flag.flag_val is not None: 105 | print '%s:%s' % (flag.flag_name, flag.flag_val), 106 | else: 107 | print flag.flag_name, 108 | print 109 | if self.opts.raw: 110 | print '%10s: %s' % ('Raw', n.raw[0].entry) 111 | print 112 | else: 113 | print '%12s : %s' % (n.address, n.name) 114 | 115 | if __name__ == '__main__': 116 | App.run() 117 | 118 | -------------------------------------------------------------------------------- /fidonet/apps/querypkt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | 6 | import fidonet 7 | import fidonet.app 8 | 9 | class App (fidonet.app.App): 10 | logtag = 'fidonet.querypkt' 11 | 12 | def create_parser(self): 13 | p = super(App, self).create_parser() 14 | p.add_option('-q', '--query', action='append', default=[]) 15 | p.add_option('--queryformat', '--qf') 16 | return p 17 | 18 | def query_pkt(self, src, name, ctx): 19 | pkt = fidonet.PacketFactory(src) 20 | 21 | try: 22 | if self.opts.queryformat: 23 | print self.opts.queryformat % pkt 24 | else: 25 | for k in self.opts.query: 26 | print pkt[k] 27 | except KeyError, detail: 28 | print >>sys.stderr, 'error: %s: no such field.' % detail 29 | 30 | def handle_args(self, args): 31 | self.for_each_arg(self.query_pkt, args) 32 | 33 | if __name__ == '__main__': 34 | App.run() 35 | 36 | -------------------------------------------------------------------------------- /fidonet/apps/route.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | 5 | import fidonet 6 | import fidonet.app 7 | from fidonet.router import Router 8 | from fidonet.nodelist import Node, Nodelist 9 | 10 | class App (fidonet.app.App): 11 | 12 | def create_parser(self): 13 | p = super(App, self).create_parser() 14 | p.add_option('-n', '--nodelist') 15 | p.add_option('-r', '--routes') 16 | return p 17 | 18 | def handle_args(self, args): 19 | if self.opts.nodelist is None: 20 | nodelist = list(self.get_data_paths( 21 | 'fidonet', 'nodelist'))[0] 22 | if not nodelist: 23 | self.log.error('unable to locate a nodelist index') 24 | sys.exit(1) 25 | 26 | self.opts.nodelist = '%s.idx' % nodelist 27 | if self.opts.routes is None: 28 | self.opts.routes = self.get_cfg_path( 29 | 'fidonet', 'routes') 30 | 31 | self.log.info('using nodelist %s' % self.opts.nodelist) 32 | if self.opts.routes: 33 | self.log.info('using routes from %s' % self.opts.routes) 34 | 35 | nl = Nodelist('sqlite:///%s' % self.opts.nodelist) 36 | nl.setup() 37 | router = Router(nl, self.opts.routes) 38 | 39 | for addr in args: 40 | addr = fidonet.Address(addr) 41 | rspec = router[addr] 42 | print addr, 'via', rspec[0], 'using', rspec[1] 43 | 44 | if __name__ == '__main__': 45 | App.run() 46 | 47 | -------------------------------------------------------------------------------- /fidonet/apps/scanmsg.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | 6 | import fidonet 7 | import fidonet.app 8 | import fidonet.message 9 | 10 | class App (fidonet.app.App): 11 | logtag = 'fidonet.scanmsg' 12 | 13 | def create_parser(self): 14 | p = super(App, self).create_parser() 15 | p.add_option('-t', '--show-text', action='store_true') 16 | return p 17 | 18 | def handle_args(self, args): 19 | self.for_each_arg(self.scan_msg, args) 20 | 21 | def scan_msg(self, src, name, ctx): 22 | msg = fidonet.MessageFactory(src) 23 | print msg 24 | print 25 | 26 | if self.opts.show_text: 27 | if self.opts.debug: 28 | import pprint 29 | pprint.pprint(msg.parsed_body) 30 | else: 31 | print msg.parsed_body 32 | print 33 | 34 | if __name__ == '__main__': 35 | App.run() 36 | 37 | -------------------------------------------------------------------------------- /fidonet/apps/scanpkt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | import sys 5 | 6 | import fidonet 7 | import fidonet.app 8 | 9 | class App (fidonet.app.App): 10 | logtag = 'fidonet.scanpkt' 11 | 12 | def create_parser(self): 13 | p = super(App, self).create_parser() 14 | p.add_option('-m', '--show-messages', action='store_true') 15 | p.add_option('-t', '--show-message-text', action='store_true') 16 | 17 | return p 18 | 19 | def handle_args(self, args): 20 | self.for_each_arg(self.scan_pkt, args) 21 | 22 | def scan_pkt(self, src, name, ctx): 23 | pkt = fidonet.PacketFactory(src) 24 | 25 | print '=' * 70 26 | print '%s: ' % name, 27 | print pkt 28 | print '=' * 70 29 | print 30 | 31 | if self.opts.show_messages: 32 | for i, msg in enumerate(pkt.messages): 33 | print '[%03d]' % i 34 | print msg 35 | print 36 | 37 | if self.opts.show_message_text: 38 | print msg.parsed_body 39 | print 40 | 41 | if __name__ == '__main__': 42 | App.run() 43 | 44 | -------------------------------------------------------------------------------- /fidonet/apps/srif.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | import tempfile 5 | from stat import * 6 | 7 | import fidonet.srif 8 | import fidonet.app 9 | 10 | class App (fidonet.app.App): 11 | def create_parser (self): 12 | p = super(App, self).create_parser() 13 | p.add_option('-D', '--basedir') 14 | p.add_option('-M', '--magicdir') 15 | return p 16 | 17 | def set_defaults (self): 18 | super(App, self).set_defaults() 19 | 20 | if not self.cfg.has_section('binkd'): 21 | self.cfg.add_section('binkd') 22 | 23 | self.cfg.set('DEFAULT', 'files', 24 | os.path.join(self.opts.data_dir, 'files')) 25 | 26 | def handle_args (self, args): 27 | if self.opts.basedir is None: 28 | self.opts.basedir = self.get_data_path('binkd', 'files') 29 | 30 | if self.opts.magicdir is None: 31 | self.opts.magicdir = self.get_data_path('binkd', 'magic') 32 | 33 | if self.opts.basedir is None: 34 | self.log.error('base directory is not set') 35 | sys.exit(1) 36 | 37 | if not os.path.isdir(self.opts.basedir): 38 | self.log.error('base directory "%s" does not exist' % 39 | self.opts.basedir) 40 | sys.exit(1) 41 | 42 | self.log.debug('basedir = %s' % self.opts.basedir) 43 | 44 | self.process_srif(args) 45 | 46 | def process_srif(self, args): 47 | if args: 48 | sys.stdin = open(args.pop(0)) 49 | 50 | data = fidonet.srif.SRIF(sys.stdin) 51 | if not 'CallerID' in data: 52 | data['CallerID'] = 'unknown' 53 | 54 | self.log.info("processing request from %(AKA)s @ %(CallerID)s" % data) 55 | 56 | req = open(data['RequestList']) 57 | rsp = open(data['ResponseList'], 'w') 58 | rsp.truncate() 59 | 60 | for line in (x.strip() for x in req): 61 | if not line: 62 | continue 63 | 64 | if line.startswith('/'): 65 | self.log.warn('ignoring absolute path: %s' % line) 66 | continue 67 | 68 | if '..' in line: 69 | self.log.warn('ignoring path containing "..": %s' % line) 70 | continue 71 | 72 | # First check for magic file matching request. 73 | if self.opts.magicdir: 74 | reqpath = os.path.join(self.opts.magicdir, line) 75 | if self.is_exe_file(reqpath): 76 | self.log.info('request for magic file: %s' % line) 77 | tmppath = self.run_magic_file(data, line, reqpath) 78 | print >>rsp, '-%s' % tmppath 79 | continue 80 | 81 | reqpath = os.path.join(self.opts.basedir, line) 82 | 83 | if not os.path.isfile(reqpath): 84 | self.log.warn('request for invalid file: %s' % line) 85 | continue 86 | else: 87 | self.log.info('request for normal file: %s' % line) 88 | print >>rsp, '+%s' % reqpath 89 | 90 | def run_magic_file(self, data, reqname, reqpath): 91 | rsppath = os.path.join( 92 | os.path.dirname(data['ResponseList']), 93 | reqname) 94 | 95 | self.log.debug('putting output of %s in: %s' % (reqname, rsppath)) 96 | 97 | fd = open(rsppath, 'w') 98 | env = dict([('SRIF_%s' % k, v) for k,v in data.items()]) 99 | env.update(os.environ) 100 | rc = subprocess.call([reqpath, self.opts.basedir], 101 | stdout=fd, 102 | env=env) 103 | 104 | if rc != 0: 105 | self.log.error('request for magic file failed, rc = %d' % rc) 106 | fd.seek(0) 107 | print >>fd, 'File request failed.' 108 | fd.truncate() 109 | 110 | return rsppath 111 | 112 | def is_exe_file(self, path): 113 | return os.path.isfile(path) and \ 114 | os.stat(path)[ST_MODE] & (S_IXUSR|S_IXGRP|S_IXOTH) 115 | 116 | if __name__ == '__main__': 117 | App.run() 118 | 119 | -------------------------------------------------------------------------------- /fidonet/apps/toss.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | 5 | from fidonet import Address, MessageFactory, Router 6 | from fidonet.nodelist import Node, Nodelist 7 | from fidonet.formats import * 8 | import fidonet.app 9 | 10 | class App(fidonet.app.App): 11 | logtag = 'fidonet.toss' 12 | 13 | def create_parser(self): 14 | p = super(App, self).create_parser() 15 | p.add_option('-o', '--origin', '--orig') 16 | p.add_option('-D', '--dir') 17 | p.add_option('-r', '--routes') 18 | p.add_option('-n', '--nodelist') 19 | return p 20 | 21 | def handle_args (self, args): 22 | if not self.opts.origin: 23 | self.opts.origin = self.get('fidonet', 'address') 24 | 25 | if not self.opts.dir: 26 | self.opts.dir = self.get_data_path('binkd', 'outbound') 27 | 28 | if not self.opts.routes: 29 | self.opts.routes = self.get_cfg_path('fidonet', 'routes') 30 | 31 | if not self.opts.nodelist: 32 | nodelist = list(self.get_data_paths( 33 | 'fidonet', 'nodelist'))[0] 34 | if not nodelist: 35 | sys.log.error('unable to locate a nodelist index') 36 | sys.exit(1) 37 | self.opts.nodelist = '%s.idx' % nodelist 38 | 39 | if not os.path.isfile(self.opts.nodelist): 40 | self.log.error('nodelist index "%s" is unavailable.' % 41 | self.opts.nodelist) 42 | sys.exit(1) 43 | if not os.path.isdir(self.opts.dir): 44 | self.log.error('binkd outbound directory "%s" is unavailable.' 45 | % self.opts.dir) 46 | sys.exit(1) 47 | if self.opts.routes is not None and not os.path.isfile(self.opts.routes): 48 | self.log.error('routing policy file "%s" is unavailable.' % 49 | self.opts.routes) 50 | sys.exit(1) 51 | 52 | self.opts.origin = Address(self.opts.origin) 53 | 54 | self.log.debug('my origin = %s' % self.opts.origin) 55 | self.log.debug('target directory = %s' % self.opts.dir) 56 | self.log.debug('routing policy = %s' % self.opts.routes) 57 | self.log.debug('nodelist = %s' % self.opts.nodelist) 58 | 59 | nodelist = Nodelist('sqlite:///%s' % self.opts.nodelist) 60 | nodelist.setup() 61 | self.router = Router(nodelist, self.opts.routes) 62 | self.packets = {} 63 | self.origin = fidonet.Address(self.opts.origin) 64 | 65 | self.for_each_arg(self.toss_pkt, args) 66 | 67 | for pkt in self.packets.values(): 68 | out = os.path.join(self.opts.dir, '%s.out' % pkt.destAddr.hex) 69 | self.log.info('writing packet for %s to %s.' % (pkt.destAddr, 70 | out)) 71 | pkt.write(open(out, 'w')) 72 | 73 | def toss_pkt(self, src, name, ctx): 74 | msg = fidonet.MessageFactory(src) 75 | 76 | if not msg.destAddr.zone: 77 | msg.destZone = self.origin.zone 78 | 79 | self.log.debug('processing message to %s' % msg.destAddr) 80 | 81 | route = self.router[msg.destAddr] 82 | self.log.debug('got route = %s' % str(route)) 83 | dest = route[0] 84 | 85 | if not dest.ftn in self.packets: 86 | pktfile = os.path.join(self.opts.dir, '%s.out' % dest.hex) 87 | 88 | if os.path.exists(pktfile): 89 | self.log.info('found existing packet for %s' % dest) 90 | newpkt = fidonet.PacketFactory(open(pktfile)) 91 | else: 92 | self.log.info('creating new packet for %s' % dest) 93 | newpkt = fsc0048packet.PacketParser.create() 94 | newpkt.destAddr = dest 95 | newpkt.origAddr = fidonet.Address(self.opts.origin) 96 | 97 | self.packets[dest.ftn] = newpkt 98 | 99 | self.log.info('packing message from %s to %s via %s.' % (msg.origAddr, 100 | msg.destAddr, dest)) 101 | self.packets[dest.ftn].messages.append(msg) 102 | 103 | if __name__ == '__main__': 104 | App.run() 105 | 106 | -------------------------------------------------------------------------------- /fidonet/apps/unpack.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import fidonet 4 | import fidonet.app 5 | from fidonet.formats import * 6 | 7 | def next_message(dir, start=1): 8 | count=start 9 | while True: 10 | msgpath = os.path.join(dir, '%d.msg' % count) 11 | if not os.path.exists(msgpath): 12 | return msgpath 13 | 14 | count += 1 15 | 16 | class App(fidonet.app.App): 17 | logtag = 'fidonet.unpack' 18 | 19 | def create_parser(self): 20 | p = super(App, self).create_parser() 21 | p.add_option('-D', '--output-directory', 22 | default='.') 23 | p.add_option('-m', '--message') 24 | p.add_option('--disk', action='store_false', 25 | dest='packed') 26 | p.add_option('--packed', action='store_true', 27 | dest='packed') 28 | 29 | p.set_default('packed', True) 30 | return p 31 | 32 | def handle_args(self, args): 33 | if self.opts.message: 34 | self.opts.message = [int(x) for x in self.opts.message.split(',')] 35 | 36 | self.gtotal = 0 37 | self.for_each_arg(self.unpack, args) 38 | self.log.info('Unpacked %d messages total.' % self.gtotal) 39 | 40 | def unpack(self, src, name, ctx): 41 | pkt = fidonet.PacketFactory(src) 42 | 43 | for count, msg in enumerate(pkt.messages): 44 | if self.opts.message and not count in self.opts.message: 45 | continue 46 | 47 | msgfile = next_message(self.opts.output_directory) 48 | fd = open(msgfile, 'w') 49 | 50 | if self.opts.packed: 51 | packedmessage.MessageParser.write(msg,fd) 52 | else: 53 | diskmessage.MessageParser.write(msg, fd) 54 | fd.close() 55 | self.log.info('wrote message %d from %s to %s' % ( 56 | count, name, msgfile)) 57 | 58 | self.log.info('Unpacked %d messages from %s into %s.' % ( 59 | count+1, name, self.opts.output_directory)) 60 | self.gtotal += count+1 61 | 62 | if __name__ == '__main__': 63 | App.run() 64 | 65 | -------------------------------------------------------------------------------- /fidonet/binkp.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import socket 4 | import struct 5 | 6 | DEFAULT_BINKP_PORT = 24554 7 | 8 | cmd_names = { 9 | 'M_NUL' : 0, 10 | 'M_ADR' : 1, 11 | 'M_PWD' : 2, 12 | 'M_OK' : 4, 13 | 'M_FILE' : 3, 14 | 'M_EOB' : 5, 15 | 'M_GOT' : 6, 16 | 'M_ERR' : 7, 17 | 'M_BSY' : 8, 18 | 'M_GET' : 9, 19 | 'M_SKIP' : 10, 20 | } 21 | 22 | cmd_ids = dict((v,k) for k,v in cmd_names.iteritems()) 23 | 24 | class ConnectionClosed(Exception): 25 | pass 26 | 27 | class BinkpConnection (object): 28 | 29 | def __init__ (self, addr, timeout=None): 30 | try: 31 | self.addr, self.port = addr 32 | except ValueError: 33 | (self.addr,) = addr 34 | self.port = DEFAULT_BINKP_PORT 35 | 36 | self.timeout = timeout 37 | 38 | def connect(self): 39 | self.ip = socket.gethostbyname(self.addr) 40 | s = socket.socket() 41 | self.sock = s 42 | 43 | if self.timeout: 44 | s.settimeout(float(self.timeout)) 45 | 46 | s.connect((self.addr, int(self.port))) 47 | 48 | def read_bytes(self, want): 49 | bytes = self.sock.recv(want) 50 | 51 | while len(bytes) < want: 52 | more = self.sock.recv(want - len(bytes)) 53 | if not more: 54 | raise ConnectionClosed() 55 | bytes += more 56 | 57 | return bytes 58 | 59 | def read_frame (self): 60 | bytes = self.read_bytes(2) 61 | frame_header = struct.unpack('>H', bytes)[0] 62 | cmd_frame = frame_header & 0x8000 63 | data_len = frame_header & ~0x8000 64 | 65 | if cmd_frame: 66 | cmd_id = struct.unpack('b', self.read_bytes(1))[0] 67 | cmd_id = cmd_ids[cmd_id] 68 | data = self.read_bytes(data_len - 1) 69 | else: 70 | cmd_id = None 71 | data = self.read_bytes(data_len) 72 | 73 | return {'command': bool(cmd_frame), 74 | 'cmd_id': cmd_id, 75 | 'data': data } 76 | 77 | def send_cmd_frame(self, cmd_id, data=''): 78 | cmd_id = cmd_names[cmd_id] 79 | data = struct.pack('b', cmd_id) + data 80 | data_len = len(data) 81 | frame_header = data_len | 0x8000 82 | self.sock.sendall(struct.pack('>H', frame_header)) 83 | self.sock.sendall(data) 84 | 85 | def send_data_frame(self, data): 86 | data_len = len(data) 87 | self.sock.sendall(struct.pack('>H', data_len)) 88 | self.sock.sendall(data) 89 | 90 | def disconnect(self): 91 | self.sock.close() 92 | 93 | if __name__ == '__main__': 94 | 95 | b = BinkpConnection(sys.argv[1].split(':'), timeout=10) 96 | b.connect() 97 | 98 | b.send_cmd_frame('M_PWD', '-') 99 | 100 | while True: 101 | frame = b.read_frame() 102 | print frame 103 | if frame['cmd_id'] == 'M_OK': 104 | break 105 | 106 | b.send_cmd_frame('M_EOB') 107 | b.read_frame() 108 | b.disconnect() 109 | 110 | -------------------------------------------------------------------------------- /fidonet/bitparser.py: -------------------------------------------------------------------------------- 1 | '''The ``bitparser`` module is a wrapper around bitstring_ that allows 2 | you to define data structures using a simple (I hope) declarative syntax. 3 | The syntax was inspired by the abandoned construct_ module. 4 | 5 | .. _bitstring: http://code.google.com/p/python-bitstring/ 6 | .. _construct: http://construct.wikispaces.com/ 7 | ''' 8 | 9 | import sys 10 | import logging 11 | 12 | import bitstring 13 | 14 | from ftnerror import * 15 | 16 | class Container(dict): 17 | '''The ``Struct`` class returns ``Container`` instances when you call any 18 | of the ``parse`` methods.''' 19 | 20 | def __init__(self, struct, *args, **kw): 21 | super(Container, self).__init__(*args, **kw) 22 | self.__struct__ = struct 23 | 24 | def __getattr__ (self, k): 25 | '''Allow keys to be accessed using dot notation.''' 26 | try: 27 | return self[k] 28 | except KeyError: 29 | raise AttributeError(k) 30 | 31 | def __setattr__ (self, k,v): 32 | '''Allow keys to be set using dot notation.''' 33 | if hasattr(self.__class__, k) and \ 34 | hasattr(getattr(self.__class__, k), '__set__'): 35 | super(Container, self).__setattr__(k, v) 36 | elif k in self: 37 | self[k] = v 38 | else: 39 | super(Container, self).__setattr__(k, v) 40 | 41 | def __getitem__ (self, k): 42 | '''Make properties accessible as keys.''' 43 | try: 44 | return super(Container, self).__getitem__(k) 45 | except KeyError: 46 | if hasattr(self.__class__, k) and \ 47 | isinstance(getattr(self.__class__, k), property): 48 | return getattr(self, k) 49 | else: 50 | raise 51 | 52 | def pack(self): 53 | '''Return the binary representation of this object as a 54 | BitStream.''' 55 | return self.__struct__.pack(self) 56 | 57 | def write(self, fd): 58 | '''Write the binary representation of this object to a file.''' 59 | return self.__struct__.write(self, fd) 60 | 61 | def __unpack__ (self): 62 | '''This method is called by Struct.unpack() after processing all of 63 | the field defintions. This allows a wrapper object to extract data 64 | that otherwise cannot be parsed by the low-level parser.''' 65 | 66 | pass 67 | 68 | def __pack__ (self): 69 | '''This method is called by Struct.pack() immediately before 70 | processing all the field definitions. This allows a wrapper object 71 | to encode data that otherwse cannot be encoded by the low-level 72 | parser.''' 73 | 74 | pass 75 | 76 | class Struct (object): 77 | ''' 78 | ``Struct`` represents a binary file format, and provides methods for 79 | converting between the binary format and structured data. 80 | 81 | Examples 82 | ======== 83 | 84 | Creating a new Struct:: 85 | 86 | >>> s = Struct('sample', 87 | ... Field('id', 'uint:16'), 88 | ... Boolean('available'), 89 | ... Field('widgetcount', 'uint:8')) 90 | 91 | Parsing binary data:: 92 | 93 | >>> s.parse_bytes('\x01\x01\x01\x10') 94 | {'available': False, 'widgetcount': 2, 'id': 257} 95 | 96 | Initializing an empty structure:: 97 | 98 | >>> new = s.create() 99 | >>> new 100 | {'available': False, 'widgetcount': 0, 'id': 0} 101 | 102 | Transforming a structure to a BitStream:: 103 | 104 | >>> new.id = 123 105 | >>> new.available = True 106 | >>> new.widgetcount = 15 107 | >>> new.build() 108 | BitStream('0b0000000001111011100001111') 109 | 110 | Writing a structure out to a file:: 111 | 112 | >> import tempfile 113 | >> tmp = tempfile.NamedTemporaryFile() 114 | >> new.write(tmp) 115 | 116 | ''' 117 | 118 | def __init__ (self, name, *fields, **kw): 119 | '''Create a new Struct instance. 120 | 121 | - ``fields`` -- a list of ``Field`` instances that define the data 122 | structure. 123 | 124 | You may also pass the following keyword arguments: 125 | 126 | - ``factory`` -- controls the class return by the ``parse`` 127 | methods. This should generally be a ``Container`` instance. 128 | 129 | ''' 130 | 131 | self.name = name 132 | self.spec = 'struct:%s' % name 133 | self._fields = {} 134 | self._fieldlist = [] 135 | 136 | if 'factory' in kw: 137 | self._factory = kw['factory'] 138 | else: 139 | self._factory = Container 140 | 141 | for f in fields: 142 | self._fieldlist.append(f) 143 | self._fields[f.name] = f 144 | 145 | def unpack(self, bits): 146 | '''Parse a binary stream into a structured format.''' 147 | 148 | data = self._factory(self) 149 | self.bits = bits 150 | 151 | try: 152 | for f in self._fieldlist: 153 | data[f.name] = f.unpack(bits) 154 | except bitstring.errors.ReadError: 155 | if not f.missingok: 156 | raise EndOfData 157 | 158 | if hasattr(data, '__unpack__'): 159 | data.__unpack__() 160 | 161 | return data 162 | 163 | def unpack_fd(self, fd): 164 | '''Parse binary data from an open file into a structured format.''' 165 | 166 | bits = bitstring.BitStream(fd) 167 | return self.unpack(bits) 168 | 169 | def unpack_bytes(self, bytes): 170 | '''Parse a sequence of bytes into a structued format.''' 171 | 172 | bits = bitstring.BitStream(bytes=bytes) 173 | return self.unpack(bits) 174 | 175 | def pack(self, data): 176 | '''Transform a structured format into a binary representation.''' 177 | 178 | bits = bitstring.BitStream() 179 | 180 | if hasattr(data, '__pack__'): 181 | data.__pack__() 182 | 183 | for f in self._fieldlist: 184 | logging.debug('packing field %s as "%s"' % (f.name, f.spec)) 185 | try: 186 | bits.append(f.pack(data[f.name])) 187 | except KeyError: 188 | try: 189 | bits.append(f.pack(f.default)) 190 | except AttributeError: 191 | raise KeyError(f.name) 192 | 193 | return bits 194 | 195 | def write(self, data, fd): 196 | '''Write the binary representation of a structured format to an 197 | open file.''' 198 | 199 | fd.write(self.pack(data).bytes) 200 | 201 | def create(self): 202 | '''Return an empty Container instance corresponding to this 203 | Struct.''' 204 | 205 | data = self._factory(self) 206 | 207 | for f in self._fieldlist: 208 | if hasattr(f, 'default'): 209 | if callable(f.default): 210 | data[f.name] = f.default() 211 | else: 212 | data[f.name] = f.default 213 | 214 | if hasattr(data, '__parse__'): 215 | data.__parse__() 216 | 217 | return data 218 | 219 | def default(self): 220 | return self.create() 221 | 222 | class Field (object): 223 | '''Represents a field in a binary structure.''' 224 | 225 | def __init__ (self, name, spec=None, default=0, missingok=False): 226 | 227 | self.name = name 228 | self.spec = spec 229 | self.default = default 230 | self.missingok = missingok 231 | 232 | def unpack(self, bits): 233 | return self.__unpack(bits) 234 | 235 | def pack(self, val): 236 | return bitstring.pack(self.spec, self.__pack(val)) 237 | 238 | def __unpack(self, bits): 239 | return bits.read(self.spec) 240 | 241 | def __pack(self, val): 242 | return val 243 | 244 | class CString(Field): 245 | '''A NUL-terminated string.''' 246 | 247 | def __init__ (self, *args, **kw): 248 | if not 'default' in kw: 249 | kw['default'] = '' 250 | super(CString, self).__init__(*args, **kw) 251 | self.spec = 'bytes, 0x00' 252 | 253 | def unpack(self, bits): 254 | v = bits[bits.pos:bits.find('0x00', bits.pos, bytealigned=True)[0]] 255 | bits.pos += 8 256 | return v.tobytes() 257 | 258 | def _streammaker(length): 259 | def _(): 260 | return bitstring.BitStream(length) 261 | return _ 262 | 263 | class BitStream(Field): 264 | '''A BitStream. If length is unspecified, consumes all the remaining 265 | bytes in the stream, otherwise this is a bit field of the given 266 | length.''' 267 | 268 | def __init__(self, name, length=None, **kw): 269 | if length: 270 | spec = 'bits:%d' % length 271 | else: 272 | spec = 'bits' 273 | super(BitStream, self).__init__(name, spec, 274 | default=_streammaker(length), **kw) 275 | 276 | class PaddedString(Field): 277 | '''A fixed-width string filled with a padding character.''' 278 | 279 | def __init__(self, name, length=0, padchar=' ', **kw): 280 | super(PaddedString, self).__init__(name, 'bytes:%d' % length, 281 | default=padchar * length, **kw) 282 | self.length = length 283 | self.padchar = padchar 284 | 285 | def unpack(self, bits): 286 | v = super(PaddedString, self).unpack(bits) 287 | v.rstrip(self.padchar) 288 | 289 | return v 290 | 291 | def pack(self, val): 292 | val = (val + self.padchar * self.length) [:self.length] 293 | return super(PaddedString, self).pack(val) 294 | 295 | class Constant(Field): 296 | '''A constant field.''' 297 | 298 | def __init__(self, name, spec, val, **kw): 299 | super(Constant, self).__init__(name, spec, val, **kw) 300 | self.val = val 301 | 302 | def unpack(self, bits): 303 | '''Advance the bit position but ignore the read bits and return a 304 | constant value.''' 305 | val = super(Constant, self).unpack(bits) 306 | if val != self.val: 307 | raise ValueError('Constant value %s != %s' % (val, self.val)) 308 | return val 309 | 310 | def pack(self, val): 311 | return super(Constant, self).pack(self.val) 312 | 313 | class Boolean(Field): 314 | '''A boolean value.''' 315 | 316 | def __init__(self, name, default=False, **kw): 317 | super(Boolean, self).__init__(name, 'bool', default=default, **kw) 318 | 319 | def pack(self, val): 320 | return super(Boolean, self).pack(bool(val)) 321 | 322 | class Repeat(Field): 323 | '''Continuously read a field until we fail.''' 324 | 325 | def __init__(self, name, field, **kw): 326 | super(Repeat, self).__init__(name, 'field', default=list, **kw) 327 | self.field = field 328 | 329 | def pack(self, val): 330 | bits = bitstring.BitStream() 331 | for data in val: 332 | bits.append(self.field.pack(data)) 333 | return bits 334 | 335 | def unpack(self, bits): 336 | datavec = [] 337 | 338 | while True: 339 | try: 340 | pos = bits.pos 341 | data = self.field.unpack(bits) 342 | datavec.append(data) 343 | except (ValueError, EndOfData): 344 | # if we run out of data while trying to parse the next 345 | # repeat, we rewind the bitstream and return to the 346 | # containing structure. 347 | bits.pos = pos 348 | break 349 | 350 | return datavec 351 | 352 | -------------------------------------------------------------------------------- /fidonet/defaults.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | ftn_config_dir = os.environ.get('FTN_CONFIG_DIR', '/etc/ftn') 4 | ftn_config_file = os.path.join(ftn_config_dir, 'ftn.cfg') 5 | ftn_data_dir = os.environ.get('FTN_DATA_DIR', '/var/lib/ftn') 6 | 7 | def show_defaults(): 8 | for varname, varval in globals().items(): 9 | if varname.startswith('ftn_'): 10 | print varname, '=', varval 11 | 12 | if __name__ == '__main__': 13 | show_defaults() 14 | 15 | -------------------------------------------------------------------------------- /fidonet/formats/__init__.py: -------------------------------------------------------------------------------- 1 | '''This module contains the ``bitstring``-based parsers used to parse the 2 | various Fidonet binary file formats.''' 3 | 4 | __all__ = [ 5 | 'packedmessage', 6 | 'diskmessage', 7 | 'fts0001packet', 8 | 'fsc0045packet', 9 | 'fsc0048packet', 10 | 'attributeword', 11 | ] 12 | 13 | -------------------------------------------------------------------------------- /fidonet/formats/attributeword.py: -------------------------------------------------------------------------------- 1 | '''This parses the attributeWord data field from FTS-0001_, present in both 2 | packed and on-disk messages. 3 | 4 | .. _FTS-0001: http://www.ftsc.org/docs/fts-0001.016 5 | ''' 6 | 7 | from fidonet.bitparser import * 8 | 9 | AttributeWordParser = Struct('attributeWord', 10 | Boolean('private'), 11 | Boolean('crash'), 12 | Boolean('received'), 13 | Boolean('sent'), 14 | Boolean('fileAttached'), 15 | Boolean('inTransit'), 16 | Boolean('orphan'), 17 | Boolean('killSent'), 18 | Boolean('local'), 19 | Boolean('holdForPickup'), 20 | Boolean('unused1'), 21 | Boolean('fileRequest'), 22 | Boolean('returnReceiptRequested'), 23 | Boolean('isReturnReceipt'), 24 | Boolean('auditRequest'), 25 | Boolean('fileUpdateRequest'), 26 | ) 27 | 28 | -------------------------------------------------------------------------------- /fidonet/formats/diskmessage.py: -------------------------------------------------------------------------------- 1 | '''This is the on-disk message format described in FTS-0001_. 2 | 3 | .. _FTS-0001: http://www.ftsc.org/docs/fts-0001.016 4 | ''' 5 | 6 | from fidonet.bitparser import * 7 | from fidonet.message import Message 8 | from fidonet.formats import attributeword 9 | 10 | MessageParser = Struct('message', 11 | PaddedString('fromUsername', 36, '\x00'), 12 | PaddedString('toUsername', 36, '\x00'), 13 | PaddedString('subject', 72, '\x00'), 14 | PaddedString('dateTime', 20, '\x00'), 15 | Field('timesRead', 'uintle:16'), 16 | Field('destNode', 'uintle:16'), 17 | Field('origNode', 'uintle:16'), 18 | Field('cost', 'uintle:16'), 19 | Field('destNet', 'uintle:16'), 20 | Field('origNet', 'uintle:16'), 21 | Field('destZone', 'uintle:16'), 22 | Field('origZone', 'uintle:16'), 23 | Field('destPoint', 'uintle:16'), 24 | Field('origPoint', 'uintle:16'), 25 | Field('replyTo', 'uintle:16'), 26 | attributeword.AttributeWordParser, 27 | Field('nextReply', 'uintle:16'), 28 | CString('body', default=''), 29 | 30 | factory=Message 31 | ) 32 | 33 | -------------------------------------------------------------------------------- /fidonet/formats/fsc0045packet.py: -------------------------------------------------------------------------------- 1 | '''This is the packet format described in FSC-0048_. This format is a 2 | supserset of FTS-0001, and FSC-0048 describes some heuristics for 3 | determining which format is appropriate for parsing a given packet. 4 | 5 | .. _FSC-0048: http://www.ftsc.org/docs/fsc-0048.002 6 | ''' 7 | 8 | from fidonet.bitparser import * 9 | from fidonet.packet import Packet 10 | from fidonet.formats import packedmessage 11 | 12 | PacketParser = Struct('packet', 13 | Field('origNode', 'uintle:16'), 14 | Field('destNode', 'uintle:16'), 15 | Field('origPoint', 'uintle:16'), 16 | Field('destPoint', 'uintle:16'), 17 | Constant('reserved', 'uintle:16', 0), 18 | Constant('pktSubVersion', 'uintle:16', 2), 19 | Constant('pktVersion', 'uintle:16', 2), 20 | Field('origNet', 'uintle:16'), 21 | Field('destNet', 'uintle:16'), 22 | Field('productCodeLow', 'uintle:8', default=0xFE), 23 | Field('serialNo', 'uintle:8'), 24 | Field('password', 'bytes:8', default='\x00' * 8), 25 | Field('origZone', 'uintle:16'), 26 | Field('destZone', 'uintle:16'), 27 | Field('origDomain', 'bytes:8'), 28 | Field('destDomain', 'bytes:8'), 29 | Field('productData', 'bytes:4'), 30 | Repeat('messages', packedmessage.MessageParser), 31 | Constant('eop', 'bytes:2', '\x00\x00', missingok=True), 32 | 33 | factory=Packet 34 | ) 35 | 36 | 37 | if __name__ == '__main__': 38 | import doctest 39 | doctest.testmod() 40 | 41 | -------------------------------------------------------------------------------- /fidonet/formats/fsc0048packet.py: -------------------------------------------------------------------------------- 1 | '''This is the packet format described in FSC-0048_. This format is a 2 | supserset of FTS-0001, and FSC-0048 describes some heuristics for 3 | determining which format is appropriate for parsing a given packet. 4 | 5 | .. _FSC-0048: http://www.ftsc.org/docs/fsc-0048.002 6 | ''' 7 | 8 | from fidonet.bitparser import * 9 | from fidonet.packet import Packet 10 | from fidonet.formats import packedmessage 11 | 12 | PacketParser = Struct('packet', 13 | Field('origNode', 'uintle:16'), 14 | Field('destNode', 'uintle:16'), 15 | Field('year', 'uintle:16'), 16 | Field('month', 'uintle:16'), 17 | Field('day', 'uintle:16'), 18 | Field('hour', 'uintle:16'), 19 | Field('minute', 'uintle:16'), 20 | Field('second', 'uintle:16'), 21 | Field('baud', 'uintle:16'), 22 | Constant('pktVersion', 'uintle:16', 2), 23 | Field('origNet', 'uintle:16'), 24 | Field('destNet', 'uintle:16'), 25 | Field('productCodeLow', 'uintle:8', default=0xFE), 26 | Field('productRevMajor', 'uintle:8'), 27 | Field('password', 'bytes:8', default='\x00' * 8), 28 | Field('qOrigZone', 'uintle:16'), 29 | Field('qDestZone', 'uintle:16'), 30 | Field('auxNet', 'uintle:16'), 31 | Field('capWordValidationCopy', 'uintbe:16', default=1), 32 | Field('productCodeHigh', 'uintle:8'), 33 | Field('productRevMinor', 'uintle:8'), 34 | Field('capWord', 'uintle:16', default=1), 35 | Field('origZone', 'uintle:16'), 36 | Field('destZone', 'uintle:16'), 37 | Field('origPoint', 'uintle:16'), 38 | Field('destPoint', 'uintle:16'), 39 | Field('productData', 'uintle:32'), 40 | Repeat('messages', packedmessage.MessageParser), 41 | Constant('eop', 'bytes:2', '\x00\x00', missingok=True), 42 | 43 | factory=Packet, 44 | ) 45 | 46 | if __name__ == '__main__': 47 | import doctest 48 | doctest.testmod() 49 | 50 | -------------------------------------------------------------------------------- /fidonet/formats/fts0001packet.py: -------------------------------------------------------------------------------- 1 | '''This is the basic Fidonet packet structure defined in FTS-0001_. This 2 | format appears to have been largely superceded by the type 2+ packet 3 | format. 4 | 5 | .. _FTS-0001: http://www.ftsc.org/docs/fts-0001.016 6 | ''' 7 | 8 | from fidonet.bitparser import * 9 | from fidonet.packet import Packet 10 | from fidonet.formats import packedmessage 11 | 12 | PacketParser = Struct('packet', 13 | Field('origNode', 'uintle:16'), 14 | Field('destNode', 'uintle:16'), 15 | Field('year', 'uintle:16'), 16 | Field('month', 'uintle:16'), 17 | Field('day', 'uintle:16'), 18 | Field('hour', 'uintle:16'), 19 | Field('minute', 'uintle:16'), 20 | Field('second', 'uintle:16'), 21 | Field('baud', 'uintle:16'), 22 | Constant('pktVersion', 'uintle:16', 2), 23 | Field('origNet', 'uintle:16'), 24 | Field('destNet', 'uintle:16'), 25 | Field('productCodeLow', 'uintle:8', default=0xFE), 26 | Field('serialNo', 'uintle:8'), 27 | Field('password', 'bytes:8', default='\x00' * 8), 28 | Field('origZone', 'uintle:16'), 29 | Field('destZone', 'uintle:16'), 30 | Field('fill', 'bytes:20'), 31 | Repeat('messages', packedmessage.MessageParser), 32 | Constant('eop', 'bytes:2', '\x00\x00', missingok=True), 33 | 34 | factory=Packet 35 | ) 36 | 37 | -------------------------------------------------------------------------------- /fidonet/formats/packedmessage.py: -------------------------------------------------------------------------------- 1 | '''This is the packed message format described in FTS-0001_. 2 | 3 | .. _FTS-0001: http://www.ftsc.org/docs/fts-0001.016 4 | ''' 5 | 6 | from fidonet.bitparser import * 7 | from fidonet.message import Message 8 | from fidonet.formats import attributeword 9 | 10 | MessageParser = Struct('message', 11 | Constant('msgVersion', 'uintle:16', 2), 12 | Field('origNode', 'uintle:16'), 13 | Field('destNode', 'uintle:16'), 14 | Field('origNet', 'uintle:16'), 15 | Field('destNet', 'uintle:16'), 16 | attributeword.AttributeWordParser, 17 | Field('cost', 'uintle:16'), 18 | PaddedString('dateTime', 20, '\x00'), 19 | CString('toUsername'), 20 | CString('fromUsername'), 21 | CString('subject'), 22 | CString('body', default=''), 23 | 24 | factory=Message, 25 | ) 26 | 27 | -------------------------------------------------------------------------------- /fidonet/ftnerror.py: -------------------------------------------------------------------------------- 1 | class FTNError (Exception): 2 | pass 3 | 4 | class InvalidPacket (FTNError): 5 | pass 6 | 7 | class InvalidMessage (FTNError): 8 | pass 9 | 10 | class InvalidAddress (FTNError): 11 | pass 12 | 13 | class InvalidRoute (FTNError): 14 | '''An invalid route specification was encountered in a 15 | route configuration file.''' 16 | pass 17 | 18 | class EndOfData (FTNError): 19 | pass 20 | 21 | -------------------------------------------------------------------------------- /fidonet/message.py: -------------------------------------------------------------------------------- 1 | '''A wrapper class for FTN format messages. 2 | 3 | Reading a message 4 | ----------------- 5 | 6 | Read a packet from a file using the ``fidonet.MessageFactory`` 7 | method: 8 | 9 | >>> import fidonet 10 | >>> msg = fidonet.MessageFactory(open('tests/sample.msg')) 11 | 12 | Accessing message data 13 | ----------------------- 14 | 15 | You can access message data as a dictionary: 16 | 17 | >>> msg['fromUsername'] 18 | 'Lars' 19 | 20 | Or using dot notation: 21 | 22 | >>> msg.fromUsername 23 | 'Lars' 24 | 25 | Special properties 26 | ------------------ 27 | 28 | The ``origAddr`` and ``destAddr`` properties return the corresponding 29 | address as a ``fidonet.Address`` instance: 30 | 31 | >>> msg.origAddr.ftn 32 | '322/761' 33 | 34 | You can assign an Address object to this property to update the packet: 35 | 36 | >>> from fidonet.address import Address 37 | >>> msg.origAddr = Address('1:100/100') 38 | >>> msg.origAddr.ftn 39 | '1:100/100' 40 | 41 | Writing a message 42 | ----------------- 43 | 44 | Write a message to an open file using the ``write`` method: 45 | 46 | >>> msg.write(open('updated.msg', 'w')) 47 | ''' 48 | 49 | import os 50 | import sys 51 | import logging 52 | 53 | import fidonet 54 | from ftnerror import * 55 | from util import * 56 | from bitparser import Container 57 | import odict 58 | from formats import attributeword 59 | 60 | class Message (Container): 61 | origAddr = ftn_address_property('orig') 62 | destAddr = ftn_address_property('dest') 63 | 64 | def __init__ (self, *args, **kwargs): 65 | super(Message, self).__init__(*args, **kwargs) 66 | self['parsed_body'] = MessageBodyParser.create() 67 | 68 | def __str__ (self): 69 | text = [ 70 | 'From: %(fromUsername)s @ %(origAddr)s' % self, 71 | 'To: %(toUsername)s @ %(destAddr)s' % self, 72 | 'Date: %(dateTime)s' % self, 73 | 'Subject: %(subject)s' % self, 74 | ] 75 | flags = [ 'Flags:' ] 76 | for k,v in self.attributeWord.items(): 77 | if v: 78 | flags.append(k.upper()) 79 | 80 | text.append(' '.join(flags)) 81 | 82 | if self.parsed_body.area: 83 | text.append('Area: %(area)s' % self.parsed_body) 84 | 85 | return '\n'.join(text) 86 | 87 | def __pack__ (self): 88 | # unilaterally prefer point addressing in message metadata. 89 | # and always embed point addressing in message body 90 | # control lines. 91 | if self.get('origPoint', 0) > 0: 92 | self.parsed_body.klines['FMPT'] = [self.origPoint] 93 | if self.get('destPoint', 0) > 0: 94 | self.parsed_body.klines['TOPT'] = [self.destPoint] 95 | 96 | # Add INTL control line using origin and destination. 97 | # (NOT for echomail) 98 | if self.parsed_body.area is None: 99 | self.parsed_body.klines['INTL'] = ['%s %s' % ( 100 | self.destAddr.pointless, 101 | self.origAddr.pointless)] 102 | 103 | self['body'] = self.parsed_body.pack() 104 | 105 | def __unpack__ (self): 106 | logging.debug('parsing a message') 107 | 108 | self['parsed_body'] = MessageBodyParser.unpack(self['body']) 109 | 110 | if 'INTL' in self.parsed_body.klines: 111 | intlDest, intlOrig = self.parsed_body.klines['INTL'][0].split() 112 | self.destAddr = fidonet.Address(intlDest) 113 | self.origAddr = fidonet.Address(intlOrig) 114 | 115 | # Make sure we have origZone/destZone keys, because we need these 116 | # for a diskmessage (and for various sorts of address manipulation) 117 | if not 'origZone' in self: 118 | self['origZone'] = 0 119 | if not 'destZone' in self: 120 | self['destZone'] = 0 121 | 122 | # Extract point information from control lines. 123 | if self.get('origPoint', 0) == 0: 124 | logging.debug('looking for FMPT') 125 | if 'FMPT' in self.parsed_body.klines: 126 | self['origPoint'] = self.parsed_body.klines['FMPT'][0] 127 | logging.debug('set origPoint = %(origPoint)s' % self) 128 | else: 129 | self['origPoint'] = 0 130 | if self.get('destPoint', 0) == 0: 131 | logging.debug('looking for TOPT') 132 | if 'TOPT' in self.parsed_body.klines: 133 | self['destPoint'] = self.parsed_body.klines['TOPT'][0] 134 | logging.debug('set destPoint = %(destPoint)s' % self) 135 | else: 136 | self['destPoint'] = 0 137 | 138 | class MessageBody (Container): 139 | def __str__(self): 140 | return self.pack()\ 141 | .replace('\r', '\n')\ 142 | .replace('\x01', '[K]') 143 | 144 | def add_kludge(self, k, v): 145 | if k in self.klines: 146 | self.klines[k].append(v) 147 | else: 148 | self.klines[k] = [v] 149 | 150 | # Everything below this is an ungodly mess. Why, Fidonet, why!? 151 | 152 | class _MessageBodyParser (object): 153 | kludgePrefix = '\x01' 154 | 155 | def create(self): 156 | body = MessageBody(self, { 157 | 'area': None, 158 | 'klines': odict.odict(), 159 | 'seenby': [], 160 | 'text': '', 161 | 'body': '', 162 | }) 163 | 164 | body.__struct__ = self 165 | 166 | return body 167 | 168 | def unpack(self, raw): 169 | msg = self.create() 170 | 171 | state = 0 172 | text = [] 173 | 174 | for line in raw.split('\r'): 175 | if state == 0: 176 | state = 1 177 | 178 | if line.startswith('AREA:'): 179 | msg['area'] = line.split(':', 1)[1] 180 | continue 181 | 182 | if state == 1: 183 | if line.startswith('\x01'): 184 | self.addKludge(msg, line) 185 | elif line.startswith('SEEN-BY:'): 186 | state = 2 187 | else: 188 | text.append(line) 189 | 190 | if state == 2: 191 | if line.startswith('\x01'): 192 | self.addKludge(msg, line) 193 | elif line.startswith('SEEN-BY:'): 194 | msg['seenby'].append(line.split(': ', 1)[1]) 195 | elif len(line) == 0: 196 | pass 197 | else: 198 | raise InvalidMessage('Unexpected: %s' % line) 199 | 200 | msg['text'] = '\n'.join(text) 201 | 202 | return msg 203 | 204 | def pack(self, msg): 205 | lines = [] 206 | 207 | if msg['area']: 208 | lines.append('AREA:%(area)s' % msg) 209 | 210 | for k,vv in msg['klines'].items(): 211 | for v in vv: 212 | lines.append('%s%s %s' % (self.kludgePrefix, k,v)) 213 | 214 | lines.extend(msg['text'].split('\n')) 215 | 216 | for seenby in msg['seenby']: 217 | lines.append('SEEN-BY: %s' % seenby) 218 | 219 | return '\r'.join(lines) 220 | 221 | def addKludge(self, msg, line): 222 | k,v = line[1:].split(None, 1) 223 | 224 | if k in msg['klines']: 225 | msg['klines'][k].append(v) 226 | else: 227 | msg['klines'][k] = [v] 228 | 229 | MessageBodyParser = _MessageBodyParser() 230 | 231 | -------------------------------------------------------------------------------- /fidonet/messagefactory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import bitstring 4 | 5 | from fidonet.formats import * 6 | from fidonet.ftnerror import * 7 | from message import Message, MessageBodyParser 8 | 9 | def MessageFactory(src): 10 | if isinstance(src, bitstring.ConstBitArray): 11 | bits = src 12 | elif hasattr(src, 'read'): 13 | bits = bitstring.BitStream(src) 14 | else: 15 | raise InvalidMessage() 16 | 17 | mark = bits.pos 18 | 19 | try: 20 | msg = packedmessage.MessageParser.unpack(bits) 21 | logging.debug('this is an FTS-0001 (C) message.') 22 | except ValueError: 23 | logging.debug('not a packed message; assuming this ' 24 | 'is an FTS-0001 (B) message.') 25 | bits.pos = mark 26 | msg = diskmessage.MessageParser.unpack(bits) 27 | 28 | return msg 29 | 30 | if __name__ == '__main__': 31 | import sys 32 | logging.root.setLevel(logging.DEBUG) 33 | m = MessageFactory(open(sys.argv[1])) 34 | print m 35 | 36 | -------------------------------------------------------------------------------- /fidonet/nodelist.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | 4 | from sqlalchemy import * 5 | from sqlalchemy.ext.declarative import declarative_base 6 | from sqlalchemy.orm import sessionmaker, relationship, backref 7 | 8 | re_ip_in_phone = re.compile('000*-(\d+-\d+-\d+-\d+)') 9 | re_phone_all_zero = re.compile('000*-0+-0+-0+-0+') 10 | re_hostname = re.compile('[\w-]+\.[\w-]+') 11 | 12 | fields = ( 13 | 'kw', 14 | 'node', 15 | 'name', 16 | 'location', 17 | 'sysop', 18 | 'phone', 19 | 'speed' 20 | ) 21 | 22 | metadata = None 23 | engine = None 24 | broker = None 25 | 26 | Base = declarative_base() 27 | 28 | class Flag(Base): 29 | __tablename__ = 'flags' 30 | 31 | id = Column(Integer, primary_key=True) 32 | parent_id = Column(Integer, ForeignKey('nodes.id')) 33 | 34 | flag_name = Column(String) 35 | flag_val = Column(String) 36 | 37 | class Raw(Base): 38 | __tablename__ = 'raw' 39 | 40 | id = Column(Integer, primary_key=True) 41 | parent_id = Column(Integer, ForeignKey('nodes.id')) 42 | entry = Column(String) 43 | 44 | class Node(Base): 45 | __tablename__ = 'nodes' 46 | 47 | transform = { 48 | 'kw': lambda x: x.lower() 49 | } 50 | 51 | id = Column(Integer, primary_key=True) 52 | kw = Column(String, index=True) 53 | name = Column(String) 54 | location = Column(String) 55 | sysop = Column(String) 56 | phone = Column(String) 57 | speed = Column(String) 58 | 59 | zone = Column(Integer, index=True) 60 | region = Column(Integer, index=True) 61 | net = Column(Integer, index=True) 62 | node = Column(Integer) 63 | 64 | address = Column(String, index=True, unique=True) 65 | 66 | hub_id = Column(Integer, ForeignKey('nodes.id')) 67 | 68 | flags = relationship(Flag, backref='node') 69 | raw = relationship(Raw, backref='node') 70 | 71 | def __repr__ (self): 72 | return '' % (self.address, self.name) 73 | 74 | def __str__ (self): 75 | return self.__repr__() 76 | 77 | def inet(self, for_flag=None): 78 | '''Attempt to return the IP address or hostname for this 79 | node. If you specify for_flag, look for a service specific address 80 | first. Returns address or address:port if successful; returns None 81 | if unable to determine an address from the nodelist.''' 82 | 83 | ip = None 84 | port = None 85 | 86 | for flag in self.flags: 87 | # If there is an address attache to the requested flag, 88 | # prefer it over anything else. Note that unlike 89 | # binkd_nodelister, we stop at the first instance 90 | # of the flag right now. 91 | if flag.flag_name == for_flag and flag.flag_val is not None: 92 | if '.' in flag.flag_val: 93 | if ':' in flag.flag_val: 94 | ip, port = flag.flag_val.split(':') 95 | else: 96 | ip = flag.flag_val 97 | break 98 | else: 99 | port = flag.flag_val 100 | 101 | if ip is None: 102 | # If the system name looks like an address, use it. 103 | mo = re_hostname.match(self.name) 104 | if mo: 105 | ip = self.name 106 | 107 | if ip is None: 108 | # Use address from IP or INA flags. 109 | for flag in self.flags: 110 | if flag.flag_name == 'IP' and flag.flag_val: 111 | ip = flag.flag_val 112 | elif flag.flag_name == 'INA' and flag.flag_val: 113 | ip = flag.flag_val 114 | 115 | if ip is None: 116 | # Extract IP address from phone number field. This 117 | # is apparently a Thing That is Done, but I'm not 118 | # sure it's FTSC kosher. 119 | mo = re_ip_in_phone.match(self.phone) 120 | if mo and not re_phone_all_zero.match(self.phone): 121 | ip = mo.group(1).replace('-', '.') 122 | 123 | if ip is not None and ':' in ip: 124 | # Split an ip:port specification. 125 | ip = ip.split(':')[0] 126 | 127 | if ip: 128 | return port and '%s:%s' % (ip, port) or ip 129 | 130 | def to_nodelist(self): 131 | return ','.join([str(getattr(self, x)) for x in fields]) 132 | 133 | def from_nodelist(self, line, addr): 134 | self.raw.append(Raw(entry=line)) 135 | 136 | cols = line.rstrip().split(',') 137 | if len(cols) < len(fields): 138 | logging.debug('skipping invalid line: %s', line) 139 | return 140 | 141 | for k,v in (zip(fields, cols[:len(fields)])): 142 | if k in self.transform: 143 | v = self.transform[k](v) 144 | setattr(self, k, v) 145 | 146 | if self.kw == 'zone': 147 | logging.debug('start zone %s' % self.node) 148 | addr.zone = self.node 149 | addr.region = self.node 150 | addr.net = self.node 151 | addr.node = 0 152 | elif self.kw == 'region': 153 | logging.debug('start region %s' % self.node) 154 | addr.region = self.node 155 | addr.net = self.node 156 | addr.node = 0 157 | elif self.kw == 'host': 158 | logging.debug('start net %s' % self.node) 159 | addr.net = self.node 160 | addr.node = 0 161 | else: 162 | addr.node = self.node 163 | 164 | self.zone = addr.zone 165 | self.region = addr.region 166 | self.net = addr.net 167 | self.node = addr.node 168 | self.address = addr.ftn 169 | 170 | logging.debug('parsed node: %s' % self) 171 | 172 | flags = cols[len(fields):] 173 | 174 | for flag in flags: 175 | if ':' in flag: 176 | flag_name, flag_val = flag.split(':', 1) 177 | else: 178 | flag_name = flag 179 | flag_val = None 180 | 181 | self.flags.append(Flag(flag_name=flag_name, flag_val=flag_val)) 182 | 183 | class Nodelist (object): 184 | def __init__ (self, dburi): 185 | self.dburi = dburi 186 | 187 | def setup(self, create=False): 188 | self.metadata = Base.metadata 189 | self.engine = create_engine(self.dburi) 190 | 191 | if create: 192 | self.metadata.create_all(self.engine) 193 | 194 | self.broker = sessionmaker(bind=self.engine) 195 | 196 | -------------------------------------------------------------------------------- /fidonet/odict.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | odict 4 | ~~~~~ 5 | 6 | This module is an example implementation of an ordered dict for the 7 | collections module. It's not written for performance (it actually 8 | performs pretty bad) but to show how the API works. 9 | 10 | 11 | Questions and Answers 12 | ===================== 13 | 14 | Why would anyone need ordered dicts? 15 | 16 | Dicts in python are unordered which means that the order of items when 17 | iterating over dicts is undefined. As a matter of fact it is most of 18 | the time useless and differs from implementation to implementation. 19 | 20 | Many developers stumble upon that problem sooner or later when 21 | comparing the output of doctests which often does not match the order 22 | the developer thought it would. 23 | 24 | Also XML systems such as Genshi have their problems with unordered 25 | dicts as the input and output ordering of tag attributes is often 26 | mixed up because the ordering is lost when converting the data into 27 | a dict. Switching to lists is often not possible because the 28 | complexity of a lookup is too high. 29 | 30 | Another very common case is metaprogramming. The default namespace 31 | of a class in python is a dict. With Python 3 it becomes possible 32 | to replace it with a different object which could be an ordered dict. 33 | Django is already doing something similar with a hack that assigns 34 | numbers to some descriptors initialized in the class body of a 35 | specific subclass to restore the ordering after class creation. 36 | 37 | When porting code from programming languages such as PHP and Ruby 38 | where the item-order in a dict is guaranteed it's also a great help 39 | to have an equivalent data structure in Python to ease the transition. 40 | 41 | Where are new keys added? 42 | 43 | At the end. This behavior is consistent with Ruby 1.9 Hashmaps 44 | and PHP Arrays. It also matches what common ordered dict 45 | implementations do currently. 46 | 47 | What happens if an existing key is reassigned? 48 | 49 | The key is *not* moved. This is consitent with existing 50 | implementations and can be changed by a subclass very easily:: 51 | 52 | class movingodict(odict): 53 | def __setitem__(self, key, value): 54 | self.pop(key, None) 55 | odict.__setitem__(self, key, value) 56 | 57 | Moving keys to the end of a ordered dict on reassignment is not 58 | very useful for most applications. 59 | 60 | Does it mean the dict keys are sorted by a sort expression? 61 | 62 | That's not the case. The odict only guarantees that there is an order 63 | and that newly inserted keys are inserted at the end of the dict. If 64 | you want to sort it you can do so, but newly added keys are again added 65 | at the end of the dict. 66 | 67 | I initializes the odict with a dict literal but the keys are not 68 | ordered like they should! 69 | 70 | Dict literals in Python generate dict objects and as such the order of 71 | their items is not guaranteed. Before they are passed to the odict 72 | constructor they are already unordered. 73 | 74 | What happens if keys appear multiple times in the list passed to the 75 | constructor? 76 | 77 | The same as for the dict. The latter item overrides the former. This 78 | has the side-effect that the position of the first key is used because 79 | the key is actually overwritten: 80 | 81 | >>> odict([('a', 1), ('b', 2), ('a', 3)]) 82 | odict.odict([('a', 3), ('b', 2)]) 83 | 84 | This behavor is consistent with existing implementation in Python 85 | and the PHP array and the hashmap in Ruby 1.9. 86 | 87 | This odict doesn't scale! 88 | 89 | Yes it doesn't. The delitem operation is O(n). This is file is a 90 | mockup of a real odict that could be implemented for collections 91 | based on an linked list. 92 | 93 | Why is there no .insert()? 94 | 95 | There are few situations where you really want to insert a key at 96 | an specified index. To now make the API too complex the proposed 97 | solution for this situation is creating a list of items, manipulating 98 | that and converting it back into an odict: 99 | 100 | >>> d = odict([('a', 42), ('b', 23), ('c', 19)]) 101 | >>> l = d.items() 102 | >>> l.insert(1, ('x', 0)) 103 | >>> odict(l) 104 | odict.odict([('a', 42), ('x', 0), ('b', 23), ('c', 19)]) 105 | 106 | :copyright: (c) 2008 by Armin Ronacher and PEP 273 authors. 107 | :license: modified BSD license. 108 | """ 109 | from itertools import izip, imap 110 | from copy import deepcopy 111 | 112 | missing = object() 113 | 114 | 115 | class odict(dict): 116 | """ 117 | Ordered dict example implementation. 118 | 119 | This is the proposed interface for a an ordered dict as proposed on the 120 | Python mailinglist (proposal_). 121 | 122 | It's a dict subclass and provides some list functions. The implementation 123 | of this class is inspired by the implementation of Babel but incorporates 124 | some ideas from the `ordereddict`_ and Django's ordered dict. 125 | 126 | The constructor and `update()` both accept iterables of tuples as well as 127 | mappings: 128 | 129 | >>> d = odict([('a', 'b'), ('c', 'd')]) 130 | >>> d.update({'foo': 'bar'}) 131 | >>> d 132 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar')]) 133 | 134 | Keep in mind that when updating from dict-literals the order is not 135 | preserved as these dicts are unsorted! 136 | 137 | You can copy an odict like a dict by using the constructor, `copy.copy` 138 | or the `copy` method and make deep copies with `copy.deepcopy`: 139 | 140 | >>> from copy import copy, deepcopy 141 | >>> copy(d) 142 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar')]) 143 | >>> d.copy() 144 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar')]) 145 | >>> odict(d) 146 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar')]) 147 | >>> d['spam'] = [] 148 | >>> d2 = deepcopy(d) 149 | >>> d2['spam'].append('eggs') 150 | >>> d 151 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar'), ('spam', [])]) 152 | >>> d2 153 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar'), ('spam', ['eggs'])]) 154 | 155 | All iteration methods as well as `keys`, `values` and `items` return 156 | the values ordered by the the time the key-value pair is inserted: 157 | 158 | >>> d.keys() 159 | ['a', 'c', 'foo', 'spam'] 160 | >>> d.values() 161 | ['b', 'd', 'bar', []] 162 | >>> d.items() 163 | [('a', 'b'), ('c', 'd'), ('foo', 'bar'), ('spam', [])] 164 | >>> list(d.iterkeys()) 165 | ['a', 'c', 'foo', 'spam'] 166 | >>> list(d.itervalues()) 167 | ['b', 'd', 'bar', []] 168 | >>> list(d.iteritems()) 169 | [('a', 'b'), ('c', 'd'), ('foo', 'bar'), ('spam', [])] 170 | 171 | Index based lookup is supported too by `byindex` which returns the 172 | key/value pair for an index: 173 | 174 | >>> d.byindex(2) 175 | ('foo', 'bar') 176 | 177 | You can reverse the odict as well: 178 | 179 | >>> d.reverse() 180 | >>> d 181 | odict.odict([('spam', []), ('foo', 'bar'), ('c', 'd'), ('a', 'b')]) 182 | 183 | And sort it like a list: 184 | 185 | >>> d.sort(key=lambda x: x[0].lower()) 186 | >>> d 187 | odict.odict([('a', 'b'), ('c', 'd'), ('foo', 'bar'), ('spam', [])]) 188 | 189 | .. _proposal: http://thread.gmane.org/gmane.comp.python.devel/95316 190 | .. _ordereddict: http://www.xs4all.nl/~anthon/Python/ordereddict/ 191 | """ 192 | 193 | def __init__(self, *args, **kwargs): 194 | dict.__init__(self) 195 | self._keys = [] 196 | self.update(*args, **kwargs) 197 | 198 | def __delitem__(self, key): 199 | dict.__delitem__(self, key) 200 | self._keys.remove(key) 201 | 202 | def __setitem__(self, key, item): 203 | if key not in self: 204 | self._keys.append(key) 205 | dict.__setitem__(self, key, item) 206 | 207 | def __deepcopy__(self, memo=None): 208 | if memo is None: 209 | memo = {} 210 | d = memo.get(id(self), missing) 211 | if d is not missing: 212 | return d 213 | memo[id(self)] = d = self.__class__() 214 | dict.__init__(d, deepcopy(self.items(), memo)) 215 | d._keys = self._keys[:] 216 | return d 217 | 218 | def __getstate__(self): 219 | return {'items': dict(self), 'keys': self._keys} 220 | 221 | def __setstate__(self, d): 222 | self._keys = d['keys'] 223 | dict.update(d['items']) 224 | 225 | def __reversed__(self): 226 | return reversed(self._keys) 227 | 228 | def __eq__(self, other): 229 | if isinstance(other, odict): 230 | if not dict.__eq__(self, other): 231 | return False 232 | return self.items() == other.items() 233 | return dict.__eq__(self, other) 234 | 235 | def __ne__(self, other): 236 | return not self.__eq__(other) 237 | 238 | def __cmp__(self, other): 239 | if isinstance(other, odict): 240 | return cmp(self.items(), other.items()) 241 | elif isinstance(other, dict): 242 | return dict.__cmp__(self, other) 243 | return NotImplemented 244 | 245 | @classmethod 246 | def fromkeys(cls, iterable, default=None): 247 | return cls((key, default) for key in iterable) 248 | 249 | def clear(self): 250 | del self._keys[:] 251 | dict.clear(self) 252 | 253 | def copy(self): 254 | return self.__class__(self) 255 | 256 | def items(self): 257 | return zip(self._keys, self.values()) 258 | 259 | def iteritems(self): 260 | return izip(self._keys, self.itervalues()) 261 | 262 | def keys(self): 263 | return self._keys[:] 264 | 265 | def iterkeys(self): 266 | return iter(self._keys) 267 | 268 | def pop(self, key, default=missing): 269 | if default is missing: 270 | return dict.pop(self, key) 271 | elif key not in self: 272 | return default 273 | self._keys.remove(key) 274 | return dict.pop(self, key, default) 275 | 276 | def popitem(self, key): 277 | self._keys.remove(key) 278 | return dict.popitem(key) 279 | 280 | def setdefault(self, key, default=None): 281 | if key not in self: 282 | self._keys.append(key) 283 | dict.setdefault(self, key, default) 284 | 285 | def update(self, *args, **kwargs): 286 | sources = [] 287 | if len(args) == 1: 288 | if hasattr(args[0], 'iteritems'): 289 | sources.append(args[0].iteritems()) 290 | else: 291 | sources.append(iter(args[0])) 292 | elif args: 293 | raise TypeError('expected at most one positional argument') 294 | if kwargs: 295 | sources.append(kwargs.iteritems()) 296 | for iterable in sources: 297 | for key, val in iterable: 298 | self[key] = val 299 | 300 | def values(self): 301 | return map(self.get, self._keys) 302 | 303 | def itervalues(self): 304 | return imap(self.get, self._keys) 305 | 306 | def index(self, item): 307 | return self._keys.index(item) 308 | 309 | def byindex(self, item): 310 | key = self._keys[item] 311 | return (key, dict.__getitem__(self, key)) 312 | 313 | def reverse(self): 314 | self._keys.reverse() 315 | 316 | def sort(self, *args, **kwargs): 317 | self._keys.sort(*args, **kwargs) 318 | 319 | def __repr__(self): 320 | return 'odict.odict(%r)' % self.items() 321 | 322 | __copy__ = copy 323 | __iter__ = iterkeys 324 | 325 | 326 | if __name__ == '__main__': 327 | import doctest 328 | doctest.testmod() 329 | -------------------------------------------------------------------------------- /fidonet/packet.py: -------------------------------------------------------------------------------- 1 | '''A wrapper class for FTN packets. 2 | 3 | Reading a packet 4 | ---------------- 5 | 6 | Read a packet from a file using the ``fidonet.PacketFactory`` 7 | method: 8 | 9 | >>> import fidonet 10 | >>> pkt = fidonet.PacketFactory(open('tests/sample.pkt')) 11 | 12 | Accessing packet data 13 | --------------------- 14 | 15 | You can access packet data as a dictionary: 16 | 17 | >>> pkt['origZone'] 18 | 1 19 | 20 | Or using dot notation: 21 | 22 | >>> pkt.origZone 23 | 1 24 | 25 | Special properties 26 | ------------------ 27 | 28 | The ``origAddr`` and ``destAddr`` properties return the corresponding 29 | address as a ``fidonet.Address`` instance: 30 | 31 | >>> pkt.origAddr.ftn 32 | '1:322/761' 33 | 34 | You can assign an Address object to this property to update the packet: 35 | 36 | >>> from fidonet.address import Address 37 | >>> pkt.origAddr = Address('1:100/100') 38 | >>> pkt.origAddr.ftn 39 | '1:100/100' 40 | 41 | The ``time`` method returns a ``time.struct_time`` instance: 42 | 43 | >>> pkt.time 44 | time.struct_time(tm_year=2011, tm_mon=2, tm_mday=25, tm_hour=21, tm_min=58, tm_sec=17, tm_wday=0, tm_yday=0, tm_isdst=-1) 45 | 46 | Assining a ``time.struct_time`` instance will update the packet: 47 | 48 | >>> import time 49 | >>> pkt.time = time.localtime(1298689930) 50 | >>> pkt.time 51 | time.struct_time(tm_year=2011, tm_mon=2, tm_mday=25, tm_hour=22, tm_min=12, tm_sec=10, tm_wday=0, tm_yday=0, tm_isdst=-1) 52 | 53 | Writing a packet 54 | ---------------- 55 | 56 | Write a packet to an open file using the ``write`` method: 57 | 58 | >>> pkt.write(open('updated.pkt', 'w')) 59 | ''' 60 | 61 | import os 62 | import sys 63 | import bitstring 64 | import time 65 | 66 | from ftnerror import * 67 | from util import * 68 | from bitparser import Container 69 | 70 | class Packet (Container): 71 | 72 | origAddr = ftn_address_property('orig') 73 | destAddr = ftn_address_property('dest') 74 | 75 | def _get_time (self): 76 | return time.struct_time([ 77 | self.year, self.month+1, self.day, 78 | self.hour, self.minute, self.second, 79 | 0, 0, -1]) 80 | 81 | def _set_time(self, t): 82 | self.year = t.tm_year 83 | self.month = t.tm_mon-1 84 | self.day = t.tm_mday 85 | self.hour = t.tm_hour 86 | self.minute = t.tm_min 87 | self.second = t.tm_sec 88 | 89 | time = property(_get_time, _set_time) 90 | 91 | def __str__ (self): 92 | text = [ 93 | '%s -> %s @ %s' % ( 94 | self.origAddr, self.destAddr, 95 | time.strftime('%Y-%m-%d %T', self.time)) 96 | ] 97 | 98 | return '\n'.join(text) 99 | 100 | def __pack__ (self): 101 | if 'capWord' in self: 102 | self['capWordValidationCopy'] = self['capWord'] 103 | 104 | if 'qOrigNode' in self: 105 | self['qOrigNode'] = self['origNode'] 106 | self['qOrigNet'] = self['origNet'] 107 | 108 | -------------------------------------------------------------------------------- /fidonet/packetfactory.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import bitstring 4 | 5 | from ftnerror import * 6 | from formats import * 7 | from packet import Packet 8 | 9 | def PacketFactory(src): 10 | if isinstance(src, bitstring.ConstBitArray): 11 | bits = src 12 | elif hasattr(src, 'read'): 13 | bits = bitstring.BitStream(src) 14 | else: 15 | raise InvalidPacket() 16 | 17 | try: 18 | pkt = fsc0045packet.PacketParser.unpack(bits) 19 | return pkt 20 | except ValueError: 21 | bits.pos = 0 22 | 23 | # If we weren't able to parse it as a type 2.2 packet, we'll 24 | # use the heuristics from FSC-0048 to decide between type 2 25 | # and type 2+. 26 | pkt = fsc0048packet.PacketParser.unpack(bits) 27 | 28 | if pkt.pktVersion != 2: 29 | logging.error('pktVersion != 2') 30 | raise InvalidPacket() 31 | 32 | if pkt.capWord != pkt.capWordValidationCopy \ 33 | or pkt.capWord == 0 \ 34 | or pkt.capWord & 0x01 == 0: 35 | logging.debug('capWord check indicates this ' 36 | 'is an fts-0001 packet.') 37 | bits.pos = 0 38 | pkt = fts0001packet.PacketParser.unpack(bits) 39 | 40 | return pkt 41 | 42 | if __name__ == '__main__': 43 | import sys 44 | 45 | p = PacketFactory(open(sys.argv[1])) 46 | 47 | print '=' * 70 48 | print '%(origZone)s:%(origNet)s/%(origNode)s ->' % p, 49 | print '%(destZone)s:%(destNet)s/%(destNode)s' % p, 50 | print '@ %(year)s-%(month)s-%(day)s %(hour)s:%(minute)s:%(second)s' % p 51 | print '=' * 70 52 | print 53 | 54 | -------------------------------------------------------------------------------- /fidonet/router.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import fnmatch 4 | import logging 5 | 6 | import fidonet 7 | import fidonet.nodelist 8 | from fidonet.nodelist import Node 9 | from fidonet.ftnerror import * 10 | from fidonet.util import commentedfilereader 11 | 12 | class Router (object): 13 | '''Select routes for FTN addresses based on the nodelist and a routing 14 | policy file. 15 | 16 | Policy syntax 17 | ============= 18 | 19 | Router() supports a subset of FrontDoor's routing commands:: 20 | 21 | ::= 22 | | 23 | 24 | ::= 'NO-ROUTE' 25 | | 'DIRECT' 26 | | 'HUB-ROUTE' 27 | | 'HOST-ROUTE' 28 | | 'ZONE-ROUTE' 29 | 30 | ::= 'ROUTE-TO' 31 | 32 | ::= 33 | | ' ' 34 | 35 | ::= | 36 | 37 | ::= '*' 38 | | ':' '*' 39 | | ':' '/' '*' 40 | | ':' '/' 41 | 42 | ::= '@' ':' 43 | 44 | :== [0-9]+ 45 | 46 | DIRECT 47 | ------ 48 | 49 | Route the packet directly to a node. This is the default behavior 50 | absent any other configuration. 51 | 52 | HOST-ROUTE 53 | ---------- 54 | 55 | Route packets to the network host. This simply replaces the node 56 | number with ``0`` (so 1:322/761 would get routed to 1:322/0). 57 | 58 | HUB-ROUTE 59 | --------- 60 | 61 | Route packets to a network hub, if available, otherwise behaves like 62 | ``HOST-ROUTE``. 63 | 64 | ZONE-ROUTE 65 | ---------- 66 | 67 | Route packets to the zonegate for the given address. 68 | 69 | NO-ROUTE 70 | -------- 71 | 72 | Like ``DIRECT``, unless the node is marked ``Hold`` or ``Pvt`` in the 73 | node list, in which case it acts like ``HUB-ROUTE``. 74 | 75 | ROUTE-TO 76 | -------- 77 | 78 | Route packets to the specified target node. 79 | 80 | Address matching 81 | ================ 82 | 83 | The router uses glob-style matching for addresses. This means that 84 | while you can do sane things like this:: 85 | 86 | no-route 1:322/* 87 | 88 | You can also do silly things like this: 89 | 90 | no-route 1:3* 91 | 92 | The latter will apply the ``no-route`` policy to anything in a net that 93 | starts with ``3``. 94 | 95 | Flag matching 96 | ============= 97 | 98 | You can limit address matches to nodes that are flying certain flags in 99 | the nodelist. For example, if you want to apply the ``no-route`` 100 | policy only to nodes capabile of accepting BinkD connections, you might 101 | do this:: 102 | 103 | no-route @IBN:* 104 | 105 | You can combine flags and address patterns. To apply the ``direct`` 106 | policy to nodes in 1:322/* flying the ``IBN`` flag:: 107 | 108 | direct @IBN:1:322/* 109 | 110 | API Examples 111 | ============ 112 | 113 | Make the nodelist index available:: 114 | 115 | >>> from fidonet.nodelist import Nodelist 116 | >>> nl = Nodelist('sqlite:///nl.d/nodelist.idx') 117 | >>> nl.setup() 118 | 119 | Create a new router:: 120 | 121 | >>> router = Router(nl, 'route.cfg') 122 | 123 | Find the route to 1:322/761:: 124 | 125 | >>> router['1:322/761'] 126 | (1:322/761, ('no-route',)) 127 | 128 | Find the route to 2:20/228:: 129 | 130 | >>> router['2:20/228'] 131 | (2:20/0, ('hub-route',)) 132 | 133 | ''' 134 | 135 | def __init__ (self, 136 | nodelist, 137 | route_file=None, 138 | default='direct'): 139 | self.nodelist = nodelist 140 | self.routes = [] 141 | self.default = self.parse_one_line('%s *' % default)[0] 142 | 143 | if route_file is not None: 144 | self.read_route_file(route_file) 145 | 146 | def parse_one_line(self, line): 147 | cmd, args = line.split(None, 1) 148 | args = args.split() 149 | cmd = cmd.replace('-', '_').lower() 150 | 151 | if hasattr(self, 'cmd_%s' % cmd): 152 | return getattr(self, 'cmd_%s' % cmd)(args) 153 | else: 154 | raise InvalidRoute(line) 155 | 156 | def read_route_file (self, route_file): 157 | for line in commentedfilereader(open(route_file)): 158 | rspec = self.parse_one_line(line) 159 | self.routes.append(rspec) 160 | 161 | def cmd_route_to(self, args): 162 | target = fidonet.Address(args.pop(0)) 163 | return (('route-to', target), args) 164 | 165 | def cmd_direct(self, args): 166 | return (('direct',), args) 167 | 168 | def cmd_no_route(self, args): 169 | return (('no-route',), args) 170 | 171 | def cmd_hub_route(self, args): 172 | return (('hub-route',), args) 173 | 174 | def cmd_host_route(self, args): 175 | return (('host-route',), args) 176 | 177 | def cmd_zone_route(self, args): 178 | return (('zone-route',), args) 179 | 180 | def lookup_route(self, addr, node=None): 181 | route = self.default 182 | 183 | for rspec in self.routes: 184 | logging.debug('check %s against %s' % (addr, rspec)) 185 | for pat in rspec[1]: 186 | if pat.startswith('@'): 187 | flag, pat = pat[1:].split(':', 1) 188 | 189 | if node is None: 190 | continue 191 | elif node and not flag in [x.flag_name for x in 192 | node.flags]: 193 | continue 194 | 195 | logging.debug('flag match on %s for %s' % (flag, addr)) 196 | if fnmatch.fnmatch(addr.ftn, pat): 197 | logging.debug('matched %s, pat=%s' % (addr, pat)) 198 | route = rspec[0] 199 | 200 | logging.debug('got route spec = %s' % str(route)) 201 | return route 202 | 203 | def route(self, addr): 204 | addr = fidonet.Address(addr) 205 | host = fidonet.Address(addr, node=0) 206 | session = self.nodelist.broker() 207 | 208 | node = session.query(Node).filter(Node.address==addr.ftn).first() 209 | logging.debug('found node = %s' % node) 210 | 211 | rspec = self.lookup_route(addr, node) 212 | 213 | action = rspec[0] 214 | logging.debug('route spec gives action = %s' % repr(action)) 215 | if action == 'direct': 216 | # no nodelists lookups required for direct routing. 217 | return (addr, rspec) 218 | elif action == 'route-to': 219 | # ...or for route-to, either. 220 | return (fidonet.Address(rspec[1]), rspec) 221 | 222 | # We could make this more efficient using the hub_id field 223 | # in the index, but then it wouldn't work for unknown nodes. 224 | hub = session.query(Node)\ 225 | .filter(Node.zone==addr.zone)\ 226 | .filter(Node.net==addr.net)\ 227 | .filter(Node.kw=='hub').first() 228 | 229 | logging.debug('hub for %s = %s' % (addr, hub)) 230 | logging.debug('host for %s = %s' % (addr, host)) 231 | 232 | if action == 'no-route': 233 | if node is None or node.kw in ['pvt', 'hold']: 234 | if hub: 235 | return (fidonet.Address(hub.address), rspec) 236 | else: 237 | return (host, rspec) 238 | else: 239 | return (fidonet.Address(node.address), rspec) 240 | elif action == 'hub-route': 241 | if hub: 242 | return (fidonet.Address(hub.address), rspec) 243 | else: 244 | return (host, rspec) 245 | elif action == 'host-route': 246 | return (host, rspec) 247 | elif action == 'zone-route': 248 | zonegate = fidonet.Address(addr, net=addr.zone, node=0) 249 | return (zonegate, rspec) 250 | 251 | return (None, None) 252 | 253 | def __getitem__ (self, addr): 254 | return self.route(addr) 255 | 256 | -------------------------------------------------------------------------------- /fidonet/sequence.py: -------------------------------------------------------------------------------- 1 | import os 2 | import errno 3 | import time 4 | import atexit 5 | 6 | class AlreadyLocked (Exception): 7 | pass 8 | 9 | class Sequence(object): 10 | def __init__ (self, path, start=0, locktries=3, lockinterval=1): 11 | self.path = path 12 | self.locked = False 13 | self.start = start 14 | self.locktries=int(locktries) 15 | self.lockinterval=float(lockinterval) 16 | 17 | self.valpath = os.path.join(self.path, 'val') 18 | self.lockpath = os.path.join(self.path, '.lock') 19 | 20 | try: 21 | os.mkdir(path) 22 | except OSError, detail: 23 | if detail.errno == errno.EEXIST: 24 | pass 25 | else: 26 | raise 27 | 28 | try: 29 | self.lock() 30 | if not os.path.exists(self.valpath): 31 | fd = open(self.valpath, 'w') 32 | fd.write('%d' % start) 33 | fd.close() 34 | finally: 35 | self.unlock() 36 | 37 | atexit.register(self.unlock) 38 | 39 | def _lock(self): 40 | try: 41 | os.mkdir(self.lockpath) 42 | self.locked = True 43 | except OSError, detail: 44 | if detail.errno == errno.EEXIST: 45 | raise AlreadyLocked() 46 | else: 47 | raise 48 | 49 | def lock(self): 50 | tries = 0 51 | while tries < self.locktries: 52 | try: 53 | self._lock() 54 | return 55 | except AlreadyLocked: 56 | time.sleep(self.lockinterval) 57 | tries += 1 58 | 59 | raise AlreadyLocked() 60 | 61 | def unlock(self): 62 | if not self.locked: 63 | return 64 | 65 | os.rmdir(self.lockpath) 66 | self.locked = False 67 | 68 | def next(self): 69 | try: 70 | self.lock() 71 | fd = open(self.valpath, 'r+') 72 | try: 73 | val = int(fd.read()) 74 | except ValueError: 75 | val = self.start 76 | fd.seek(0) 77 | fd.write('%d' % (val+1)) 78 | fd.truncate() 79 | fd.close() 80 | finally: 81 | self.unlock() 82 | 83 | return val 84 | 85 | if __name__ == '__main__': 86 | import sys 87 | 88 | s = Sequence(sys.argv[1]) 89 | 90 | for i in range(0,10): 91 | print s.next() 92 | time.sleep(float(sys.argv[2])) 93 | 94 | -------------------------------------------------------------------------------- /fidonet/srif.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | required_fields = ( 5 | 'AKA', 6 | 'RequestList', 7 | 'ResponseList', 8 | 'RemoteStatus', 9 | 'SystemStatus' 10 | ) 11 | 12 | class SRIF (dict): 13 | def __init__ (self, src, *args, **kw): 14 | super(SRIF, self).__init__(*args, **kw) 15 | self.parse(src) 16 | 17 | def parse(self, fd): 18 | for line in (x.strip() for x in fd): 19 | if not line: continue 20 | k, v = line.split(None, 1) 21 | self[k] = v 22 | 23 | for k in required_fields: 24 | if not k in self: 25 | raise ValueError('Invalid SRIF file: missing %s' % k) 26 | 27 | if __name__ == '__main__': 28 | import pprint 29 | 30 | d = SRIF(open(sys.argv[1])) 31 | pprint.pprint(d) 32 | -------------------------------------------------------------------------------- /fidonet/util.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from address import Address 3 | 4 | def ftn_address_property(name): 5 | def _get(self): 6 | return Address( 7 | zone = self.get('%sZone' % name), 8 | net = self.get('%sNet' % name), 9 | node = self.get('%sNode' % name), 10 | point = self.get('%sPoint' % name)) 11 | 12 | def _set(self, addr): 13 | self['%sZone' % name] = addr.zone 14 | self['%sNet' % name] = addr.net 15 | self['%sNode' % name] = addr.node 16 | self['%sPoint' % name] = addr.point 17 | 18 | return property(_get, _set) 19 | 20 | def commentedfilereader(fd): 21 | for line in fd: 22 | line = line.strip() 23 | if not line: 24 | continue 25 | if line.startswith('#'): 26 | continue 27 | 28 | yield line 29 | 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import os 4 | from setuptools import setup, find_packages 5 | 6 | import fidonet 7 | 8 | setup(name = 'python-ftn', 9 | version = fidonet.__version__, 10 | description = 'FTN tools for Python', 11 | long_description=open('README.rst').read(), 12 | license = fidonet.__license__, 13 | author = fidonet.__author__, 14 | author_email = fidonet.__email__, 15 | url = 'http://projects.oddbit.com/python-ftn/', 16 | packages = [ 17 | 'fidonet', 18 | 'fidonet.apps', 19 | 'fidonet.formats', 20 | ], 21 | entry_points = { 22 | 'console_scripts': [ 23 | 'ftn-pack = fidonet.apps.pack:App.run', 24 | 'ftn-unpack = fidonet.apps.unpack:App.run', 25 | 'ftn-toss = fidonet.apps.toss:App.run', 26 | 27 | 'ftn-scanmsg = fidonet.apps.scanmsg:App.run', 28 | 'ftn-editmsg = fidonet.apps.editmsg:App.run', 29 | 'ftn-makemsg = fidonet.apps.makemsg:App.run', 30 | 'ftn-querymsg = fidonet.apps.querymsg:App.run', 31 | 32 | 'ftn-editpkt = fidonet.apps.editpkt:App.run', 33 | 'ftn-scanpkt = fidonet.apps.scanpkt:App.run', 34 | 'ftn-querypkt = fidonet.apps.querypkt:App.run', 35 | 36 | 'ftn-indexnl = fidonet.apps.indexnl:App.run', 37 | 'ftn-querynl = fidonet.apps.querynl:App.run', 38 | 'ftn-route = fidonet.apps.route:App.run', 39 | 'ftn-poll = fidonet.apps.poll:App.run', 40 | 41 | 'ftn-hatch = fidonet.apps.hatch:App.run', 42 | 'ftn-srif = fidonet.apps.srif:App.run', 43 | 'ftn-pftransport = fidonet.apps.pftransport:App.run', 44 | ], 45 | }, 46 | install_requires = [ 47 | 'bitstring', 48 | 'sqlalchemy', 49 | ], 50 | ) 51 | 52 | -------------------------------------------------------------------------------- /tests/route.cfg: -------------------------------------------------------------------------------- 1 | # This is the default unless otherwise specified. 2 | #no-route * 3 | 4 | route-to 1:322/759 * 5 | no-route @IBN:* 6 | hub-route 1:229/* 7 | 8 | 9 | -------------------------------------------------------------------------------- /tests/run-cli-tests: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | makemsg() { 4 | echo 'This is a test of python-ftn' | 5 | ftn-makemsg \ 6 | -o $1 -d $2 \ 7 | -f 'Person One' -t 'Person Two' \ 8 | -s 'This is a test message' 9 | } 10 | 11 | banner() { 12 | echo ====================================================================== 13 | echo "$@" 14 | echo ====================================================================== 15 | } 16 | 17 | set -e 18 | 19 | trap 'rm -rf $workdir' 0 20 | workdir=$(mktemp -d $(pwd)/testXXXXXX) 21 | 22 | banner Generating config 23 | cat > $workdir/ftn.cfg < $workdir/routes.cfg < $workdir/nodelist.000 < $workdir/1.msg 56 | makemsg 1:322/761 99:990/1 > $workdir/2.msg 57 | makemsg 1:322/761 99:990/2 > $workdir/3.msg 58 | makemsg 1:322/761 99:990/3 > $workdir/4.msg 59 | makemsg 1:322/761 1:322/759 > $workdir/5.msg 60 | makemsg 1:322/761 1:100/100 > $workdir/6.msg 61 | 62 | banner Scanning messages 63 | ftn-scanmsg -t $workdir/*.msg 64 | 65 | banner Tossing 66 | ftn-toss -D $workdir -v $workdir/*.msg 67 | makemsg 1:322/761 99:99/88 > $workdir/7.msg 68 | ftn-toss -D $workdir -v $workdir/7.msg 69 | 70 | banner Scanning packets 71 | ftn-scanpkt -m $workdir/*.out 72 | 73 | banner Before edit message 74 | ftn-scanmsg $workdir/1.msg 75 | 76 | ftn-editmsg -g killSent -g private \ 77 | -d 2:2/2 -t 'A. New Person' -s 'Edit Test' $workdir/1.msg 78 | 79 | banner After edit message 80 | ftn-scanmsg $workdir/1.msg 81 | 82 | ls -ltr $workdir 83 | banner Before edit packet 84 | (cd $workdir && ftn-scanpkt 00630000.out) 85 | 86 | ftn-editpkt -d 1:1/1 $workdir/00630000.out 87 | 88 | banner After edit packet 89 | (cd $workdir && ftn-scanpkt 00630000.out) 90 | 91 | echo "Destination is:" $(ftn-querypkt -q destAddr $workdir/00630000.out) 92 | 93 | -------------------------------------------------------------------------------- /tests/sample.msg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larsks/python-ftn/088574b1da2d4eb1015a8688f22694ab25bbdf02/tests/sample.msg -------------------------------------------------------------------------------- /tests/sample.pkt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/larsks/python-ftn/088574b1da2d4eb1015a8688f22694ab25bbdf02/tests/sample.pkt --------------------------------------------------------------------------------