├── .gitignore ├── .gitmodules ├── LICENSE ├── Makefile ├── README.md ├── ansible.cfg ├── defaults └── main.yml ├── filter_plugins └── categorize_wirless.py ├── hosts ├── library └── .gitkeep ├── meta └── main.yml ├── playbook.yml ├── src ├── BinDecHex.lua ├── ansible.lua ├── ansible_test.lua ├── base64.lua ├── copy.lua ├── dkjson.lua ├── fatpack.pl ├── file.lua ├── fileutils.lua ├── lineinfile.lua ├── opkg.lua ├── ping.lua ├── slurp.lua ├── stat.lua ├── ubus.lua └── uci.lua └── test ├── command ├── failing │ ├── binpath.json │ └── command.json └── valid │ ├── binpath.json │ ├── binpath_defaults.json │ ├── command.json │ └── command_stderr.json ├── failing ├── fail_bool.json ├── fail_choice.json ├── fail_dict.json ├── fail_float.json ├── fail_int.json ├── fail_jsonarg.json ├── fail_list.json ├── fail_path.json ├── fail_req.json ├── fail_string.json └── fail_unknown.json └── valid ├── valid.json ├── valid_change.json ├── valid_defaults.json ├── valid_stringly.json └── valid_stringly2.json /.gitignore: -------------------------------------------------------------------------------- 1 | .vagrant/ 2 | *.pyc 3 | library/*.lua 4 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/openwrt-in-vagrant"] 2 | path = test/openwrt-in-vagrant 3 | url = https://github.com/lifeeth/openwrt-in-vagrant 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 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 Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: all 2 | 3 | all: ./library/copy.lua ./library/file.lua ./library/lineinfile.lua ./library/opkg.lua ./library/ping.lua ./library/slurp.lua ./library/stat.lua ./library/ubus.lua ./library/uci.lua 4 | 5 | WHITELIST=io,os,posix.,ubus 6 | FATPACKARGS=--whitelist $(WHITELIST) --truncate 7 | 8 | ./library/%.lua : ./src/%.lua ./src/*.lua 9 | ./src/fatpack.pl --input $^ --output $(dir $@) $(FATPACKARGS) 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Philote 2 | 3 | Ansible orcherstration for Openwrt - with Lua! 4 | 5 | Ansible is build around a collection of modules that get send to the remote 6 | host to execute different tasks or collect information. Those modules are 7 | implemented in python. However on embedded systems such as routers, resources, 8 | in particular flash memory are scarce and a python runtime often not available. 9 | 10 | Those modules communicate with the ansible-toolsuite via well defined interfaces 11 | and are executed via ssh. As each module is a standalone program, there is no 12 | dependency whatsoever on the implementation language. There are existing 13 | attempts like [this](https://github.com/lefant/ansible-openwrt) which already 14 | implement a small set of modules as bash-scripts. 15 | 16 | However the primary author of this project disagrees with some of the 17 | implementation decisions (e.g. sourcing files with key=value-pairs as a kind of 18 | parsing) and is generally a fan of (even rather limited in luas case) typing. So 19 | this project was born. 20 | 21 | As the OpenWrt community seems to have a strange affection for lua, this 22 | repository currently implements the following modules: 23 | - [copy](https://docs.ansible.com/ansible/copy_module.html) 24 | - [file](https://docs.ansible.com/ansible/file_module.html) 25 | - [lineinfile](https://docs.ansible.com/ansible/lineinfile_module.html) 26 | - opkg 27 | - [ping](https://docs.ansible.com/ansible/ping_module.html) 28 | - [stat](https://docs.ansible.com/ansible/stat_module.html) 29 | - ubus 30 | - uci 31 | 32 | Copy, file, lineinfile, stat, ping and opkg are mostly straightforward ports of the 33 | official python modules included in the Ansible v2.1.1.0 release. However, there 34 | were some simplifications made: 35 | - selinux file attributes are not supported 36 | - validation commands are not supported 37 | - file-operations are not guaranteed to be atomic 38 | - permissions can only be specified in octal mode 39 | - check_mode is only partly implemented 40 | 41 | Apart from that, the modules should behave exactly like the upstream modules, 42 | making it possible to use local actions such as 43 | "[template](https://docs.ansible.com/ansible/template_module.html)" which are 44 | built upon those modules. 45 | 46 | # Requirements 47 | 48 | For building the modules, perl and the 49 | [Data::Compare](http://search.cpan.org/~dcantrell/Data-Compare-1.25/lib/Data/Compare.pm) 50 | library are required. 51 | 52 | If you want to use the file related modules (copy, file, lineinfile, stat), the 53 | following opkg packages are required, which are not part of the standard images: 54 | - luaposix 55 | - coreutils-sha1sum 56 | 57 | However, as the opkg-module is independent from those packages, you can install 58 | them in your playbook like this: 59 | 60 | ```yaml 61 | - name: Installing dependencies for file-related modules 62 | opkg: pkg=luaposix,coreutils-sha1sum state=present update_cache=yes 63 | ``` 64 | 65 | # Building/Installation 66 | 67 | Ansible currently has no notion of libraries used within modules (only limited 68 | support for ansibles own core python libraries is available). For more 69 | information please see 70 | [this issue](https://github.com/ansible/ansible/pull/10274). Therefore all 71 | modules that should be used have to be fatpacked (that is, the module all 72 | referenced libraries have to be packed into one giant lua script). This is done 73 | by the [fatpack.pl](./src/fatpack.pl) script. Usage is like this: 74 | 75 | ```bash 76 | ./src/fatpack.pl --input .lua --output ./library/ --whitelist 77 | io,os,posix.,ubus --truncate 78 | ``` 79 | 80 | To make this process easier, a Makefile is provided that packs all modules in 81 | `./src/` and places the fatpacked variants in `library` for you. Just run `make` 82 | in the projects top directory. 83 | 84 | Please note, that this project is currently in **alpha** state. I used it to manage 85 | my personal router (playbook coming soon), but it still might easily lock you 86 | out of your device, eat your hamsters or worse. So please check your playbook 87 | beforehand against a VM (e.g. the one from the openwrt-vagrant project which 88 | can be built from the submodule in `./test/`) or be sure that your router has a 89 | convenient reset/failsafe path. 90 | 91 | Apart form the `./library/` folder, you might want to copy the provided `ansible.cfg` as it configures ansible for better interoperability with the dropbear ssh-daemon used by openwrt. 92 | 93 | # Documentation 94 | 95 | For the following modules, please refer to the upstream documentation 96 | - [copy](https://docs.ansible.com/ansible/copy_module.html) 97 | - [file](https://docs.ansible.com/ansible/file_module.html) 98 | - [lineinfile](https://docs.ansible.com/ansible/lineinfile_module.html) 99 | - [opkg](https://docs.ansible.com/ansible/opkg_module.html) 100 | - [ping](https://docs.ansible.com/ansible/ping_module.html) 101 | - [stat](https://docs.ansible.com/ansible/stat_module.html) 102 | 103 | ## ubus module 104 | 105 | As a replacement for then official setup module, information on the openwrt 106 | system can be gatherd via the ubus interface and will automatically be 107 | integrated into the host_facts for reuse in the playbook like this: 108 | 109 | ```yaml 110 | ubus: cmd=facts 111 | ``` 112 | 113 | Otherwise, this module is a slim wrapper around the 114 | [ubus rpc-bus](https://wiki.openwrt.org/doc/techref/ubus). 115 | 116 | For a list of available ubus-service-providers and their functions, you can 117 | issue a list call. Please note that this call is not really useful in an 118 | automated setting: 119 | ```bash 120 | $ ansible openwrt -i hosts -m ubus -a 'cmd=list' 121 | openwrt | SUCCESS => { 122 | "changed": false, 123 | "invocations": { 124 | "module_args": { 125 | "command": "list" 126 | } 127 | }, 128 | "msg": "Gathered local signatures", 129 | "signatures": { 130 | [...] 131 | "uci": { 132 | [...] 133 | "get": { 134 | "config": 3, 135 | "match": 2, 136 | "option": 3, 137 | "section": 3, 138 | "type": 3, 139 | "ubus_rpc_session": 3 140 | }, 141 | [...] 142 | }, 143 | [...] 144 | } 145 | } 146 | ``` 147 | 148 | Those signatures can then be used to make Calls via ubus: 149 | 150 | ```yaml 151 | ubus: cmd=call path=uci method=get message='{"config":"uhttpd", "section":"main", "option":"listen_http"}"' 152 | ``` 153 | 154 | As you can see, the `ubus_rpc_session` parameter is automatically inserted for 155 | you by the module. The ubus return value is returned in the `result` field of the returned object and can be accessed like this: 156 | 157 | ```yaml 158 | - name: Query http listen ports 159 | ubus: cmd=call path=uci method=get message='{"config":"uhttpd", "section":"main", "option":"listen_http"}"' 160 | register: foo 161 | 162 | - name: Do something 163 | baz: param={{ result.value }} 164 | ``` 165 | 166 | ## UCI-Module 167 | 168 | As most ubus calls will most likely target the 169 | [uci-system](https://wiki.openwrt.org/doc/uci) a dedicated module/ubus-wrapper 170 | for the uci configuration is provided. Basic familiarity with uci is assumed, so 171 | please refer to the upstream [documentation](https://wiki.openwrt.org/doc/uci) 172 | otherwise. Most of the options should map quite naturally to the module 173 | parameters: 174 | 175 | A special warning about types: UCI has two types for values internally: `list` 176 | and `option`. The module tries to infer the type by looking for `,` in the 177 | input. If you need to force a singleentry list, please be sure to set the 178 | `forcelist=yes` parameter. 179 | 180 | | parameter | required | default | choices | comments | 181 | |-----------|----------|---------|-----------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 182 | | name | no | | | Path to the property to change. Syntax is `config.section.option`. _Aliases: path, key_ | 183 | | value | no | | | For set: value to set the property to | 184 | | match | no | | | When present in a set or get op: properties a section must have to be modified or returned | 185 | | values | no | | | For set with match: values to set on matching section | 186 | | forcelist | no | false | Boolean | The module trys to guess the uci config type (list or string) from the supplied value via the existance of `,` in the input. Single entry lists require `forcelist=yes` to be recognized correctly | 187 | | state | no | present | present, absent, set, unset | State of the property | 188 | | op | no | | configs, commit, revert, get| If specified, instead of enforcing a value, either list the available configurations, execute a commit/revert operation, or query properties. | 189 | | reload | no | | Boolean | Whether to reload the configuration from disk before executing. _Aliases: reload_configs, reload-configs_ | 190 | | autocomit | no | true | Boolean | Whether to automatically commit the changes made | 191 | | type | no | | | When creating a new section, a configuration-type is required. Can also be used to qualify a get. Aliases: _section-type_ | 192 | | socket | no | | | Set a nonstandard path to the ubus socket if necessary | 193 | | timeout | no | | | Change the default ubus timeout | 194 | 195 | Examples: 196 | 197 | ```yaml 198 | # Set a value 199 | uci: name="system.@system[0].hostname" value="mysuperduperrouter" 200 | 201 | # Delete a value 202 | uci: name="system.@system[0].hostname" state=absent 203 | 204 | # Revert and commit globally 205 | uci: op=revert 206 | uci: op=commit 207 | 208 | # Only commit/revert a single section 209 | uci: path=dropbear op=revert 210 | uci: path=dropbear op=commit 211 | 212 | # Create the uhttpd.test section with type uhttp 213 | # and set foo=bar 214 | uci: name=uhttpd.test.foo value=bar type="uhttpd" autocommit=false' 215 | 216 | # Remove the uttpd.test section 217 | uci: name=uhttpd.test state="absent" autocommit=true' 218 | 219 | # Get a list of all available configuration files 220 | uci: op=configs 221 | ``` 222 | 223 | An more complex example showing the usage of forcelist: 224 | 225 | ```yaml 226 | - name: Securing uhttpd - Disable listening on wan 227 | uci: name={{ item.key }} value={{ uci.state.network.lan.ipaddr }}:{{ item.port }} forcelist=true autocommit=false 228 | with_items: 229 | - { key: 'uhttpd.main.listen_http', port: '80' } 230 | - { key: 'uhttpd.main.listen_https', port: '443' } 231 | notify: 232 | - uci commit 233 | ``` 234 | 235 | # Contributing 236 | 237 | Give me all your pullrequests :) If you find a bug in one of the provided modules 238 | (quite possible) or want to contribute a new module, feel free to propose a 239 | pullrequest. 240 | To make development of the modules easier, two libraries are provided. The 241 | ansible library in `./src/ansible.lua` tries to provide a easy starting point 242 | for module development similar to ansibles `ansible.module_utils.basic` library. 243 | 244 | It will handle argument parsing for you: 245 | 246 | ```lua 247 | local module = Ansible.new({ 248 | name = { aliases = {"pkg"}, required=true , type='list'}, 249 | state = { default = "present", choices={"present", "installed", "absent", "removed"} }, 250 | force = { default = "", choices={"", "depends", "maintainer", "reinstall", "overwrite", "downgrade", "space", "postinstall", "remove", "checksum", "removal-of-dependent-packages"} } , 251 | update_cache = { default = "no", aliases={ "update-cache" }, type='bool' } 252 | }) 253 | 254 | module:parse(arg[1]) 255 | 256 | local p = module:get_params() 257 | ``` 258 | 259 | And provides some convenience function such as `get_bin_path`, `run_command`, 260 | `fail_json` and `exit_json`. Currently, those are badly underdocumented, but 261 | the names are mostly selfexplanatory, so just look through the functions in the 262 | file. 263 | 264 | ```lua 265 | local opkg_path = module:get_bin_path('echo', true, {'/bin'}) 266 | local rc, out, err = module:run_command(string.format("%s foobar", opkg_path)) 267 | if rc ~= 0 then 268 | module:fail_json({msg="failed to echo foobar", info={rc=rc, out=out, err=err}}) 269 | else 270 | module:exit_json({msg="successfully echod foobar", changed=false}) 271 | end 272 | ``` 273 | 274 | Additionally, the `./src/fileutils.lua` module has various wrappers for various 275 | filesystemrelated tasks. Again: Please look up the functions in the sourcefile 276 | and look how they are used in the provided modules. 277 | 278 | # License 279 | 280 | The libraries and submodules were only included in this repository for 281 | convenience and are available under their own respective licenses: 282 | - [dkjson](http://dkolf.de/src/dkjson-lua.fsl/home) MIT License 283 | - [BinDecHex](http://www.dialectronics.com/Lua/code/BinDecHex.shtml) MIT License 284 | - [openwrt-in-vagrant](https://github.com/lifeeth/openwrt-in-vagrant) MIT License 285 | 286 | All other code is available under the terms and conditions of the AGPL3 license. 287 | For more details please see the [LICENSE file](LICENSE). 288 | 289 | # Trivia 290 | 291 | In Orson Scott Cards marvellous Ender's Game series the term "ansible" refers to 292 | a device for faster than light communication. The philote is the (fictional) 293 | subatomic particle which delivers the actual messages. 294 | -------------------------------------------------------------------------------- /ansible.cfg: -------------------------------------------------------------------------------- 1 | [ssh_connection] 2 | ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o "MACs hmac-sha2-512-etm@openssh.com,hmac-sha2-512,hmac-sha2-256,hmac-sha1,hmac-md5" -o "KexAlgorithms diffie-hellman-group-exchange-sha256,diffie-hellman-group1-sha1,diffie-hellman-group14-sha1" 3 | scp_if_ssh=True 4 | -------------------------------------------------------------------------------- /defaults/main.yml: -------------------------------------------------------------------------------- 1 | ansible_remote_tmp: "/tmp/ansible" # Reduce flash wear on target device 2 | -------------------------------------------------------------------------------- /filter_plugins/categorize_wirless.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python2 2 | 3 | import unittest 4 | from ansible import errors 5 | 6 | def categorize_wireless(uciwireless): 7 | try: 8 | # Basically, we've got a list with tags. All tags can be reconstructed with the ".name" attribute 9 | vals = uciwireless.values() 10 | devs = filter(lambda x: x['.type'] == "wifi-device", vals) 11 | ifaces = filter(lambda x: x['.type'] == "wifi-iface", vals) 12 | 13 | configs = { "all" : [] } 14 | for iface in ifaces: 15 | devname = iface['device'] 16 | dev = uciwireless[devname] 17 | hwmode = dev['hwmode'] 18 | configs.setdefault(hwmode, []) 19 | transformed = { 20 | "device": dev['.name'], 21 | "htmode": dev['htmode'], 22 | "hwmode": dev['hwmode'], 23 | "iface": iface['.name'] 24 | } 25 | configs[hwmode].append(transformed) 26 | configs['all'].append(transformed) 27 | 28 | return configs 29 | except Exception as e: 30 | raise errors.AnsibleFilterError( 31 | 'categorize_wireless plugin error: {0}, uciwireless={1},' 32 | ''.format(str(e), str(uciwireless))) 33 | 34 | class FilterModule(object): 35 | ''' Categorize wireless nics and the associated aps ''' 36 | def filters(self): 37 | return { 38 | 'categorize_wireless': categorize_wireless 39 | } 40 | 41 | 42 | class TestWirelessFilters(unittest.TestCase): 43 | def test_categorize_wireless(self): 44 | testdata = { 45 | "cfg033579": { 46 | ".anonymous": True, 47 | ".index": 1, 48 | ".name": "cfg033579", 49 | ".type": "wifi-iface", 50 | "device": "radio0", 51 | "encryption": "none", 52 | "mode": "ap", 53 | "network": "lan", 54 | "ssid": "OpenWrt" 55 | }, 56 | "cfg063579": { 57 | ".anonymous": True, 58 | ".index": 3, 59 | ".name": "cfg063579", 60 | ".type": "wifi-iface", 61 | "device": "radio1", 62 | "encryption": "none", 63 | "mode": "ap", 64 | "network": "lan", 65 | "ssid": "OpenWrt" 66 | }, 67 | "radio0": { 68 | ".anonymous": False, 69 | ".index": 0, 70 | ".name": "radio0", 71 | ".type": "wifi-device", 72 | "channel": "36", 73 | "disabled": "1", 74 | "htmode": "VHT80", 75 | "hwmode": "11a", 76 | "path": "pci0000:00\/0000:00:00.0\/0000:01:00.0", 77 | "type": "mac80211" 78 | }, 79 | "radio1": { 80 | ".anonymous": False, 81 | ".index": 2, 82 | ".name": "radio1", 83 | ".type": "wifi-device", 84 | "channel": "11", 85 | "disabled": "1", 86 | "htmode": "HT20", 87 | "hwmode": "11g", 88 | "path": "pci0000:00\/0000:00:01.0\/0000:02:00.0", 89 | "type": "mac80211" 90 | } 91 | } 92 | expected = { 93 | "11a": [{"device" : "radio0", "htmode": "VHT80", "iface": "cfg033579", "hwmode": "11a"}], 94 | "11g": [{"device" : "radio1", "htmode": "HT20", "iface" : "cfg063579", "hwmode": "11g"}], 95 | "all": [{"device" : "radio0", "htmode": "VHT80", "iface": "cfg033579", "hwmode": "11a"}, 96 | {"device" : "radio1", "htmode": "HT20", "iface" : "cfg063579", "hwmode": "11g"}] 97 | } 98 | self.assertDictEqual(categorize_wireless(testdata), expected) 99 | 100 | if __name__ == '__main__': 101 | unittest.main() 102 | -------------------------------------------------------------------------------- /hosts: -------------------------------------------------------------------------------- 1 | openwrt ansible_port=2222 ansible_host=127.0.0.1 ansible_user=root ansible_password=root 2 | -------------------------------------------------------------------------------- /library/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/noctux/philote/24a7e44fa0a377a3f7ffd4f4e4e1cc6a9cf39a10/library/.gitkeep -------------------------------------------------------------------------------- /meta/main.yml: -------------------------------------------------------------------------------- 1 | dependencies: [] 2 | -------------------------------------------------------------------------------- /playbook.yml: -------------------------------------------------------------------------------- 1 | - hosts: all 2 | gather_facts: False 3 | tasks: 4 | - name: Gathering facts 5 | ubus: cmd=facts 6 | 7 | - name: Installing dependencies for file-related modules 8 | opkg: pkg=luaposix,coreutils-sha1sum state=present update_cache=yes 9 | 10 | - name: Securing uhttpd - Disable listening on wan 11 | uci: name={{ item.key }} value={{ uci.state.network.lan.ipaddr }}:{{ item.port }} autocommit=false 12 | with_items: 13 | - { key: 'uhttpd.main.listen_http', port: '80' } 14 | - { key: 'uhttpd.main.listen_https', port: '443' } 15 | notify: 16 | - uci commit 17 | - uhttp reload 18 | - name: Securing dropbear - Disable login from wan 19 | uci: name=dropbear.@dropbear[0].Interface value=br-lan autocommit=false 20 | notify: 21 | - uci commit 22 | - dropbear reload 23 | 24 | handlers: 25 | - name: uci commit 26 | raw: uci commit 27 | - name: uhttp reload 28 | raw: /etc/init.d/uhttpd reload 29 | - name: dropbear reload 30 | raw: /etc/init.d/dropbear reload 31 | 32 | -------------------------------------------------------------------------------- /src/BinDecHex.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | /* 3 | * Copyright (c) 2007 Tim Kelly/Dialectronics 4 | * 5 | * Permission is hereby granted, free of charge, to any person obtaining 6 | * a copy of this software and associated documentation files (the 7 | * "Software"), to deal in the Software without restriction, including 8 | * without limitation the rights to use, copy, modify, merge, publish, 9 | * distribute, sublicense, and/or sell copies of the Software, and to permit 10 | * persons to whom the Software is furnished to do so, subject to the 11 | * following conditions: 12 | * 13 | * The above copyright notice and this permission notice shall be 14 | * included in all copies or substantial portions of the Software. 15 | * 16 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 21 | * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 22 | * THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | */ 24 | 25 | --]] 26 | 27 | --[[ 28 | /* 29 | * Copyright (c) 2007 Tim Kelly/Dialectronics 30 | * 31 | * Permission is hereby granted, free of charge, to any person obtaining 32 | * a copy of this software and associated documentation files (the 33 | * "Software"), to deal in the Software without restriction, including 34 | * without limitation the rights to use, copy, modify, merge, publish, 35 | * distribute, sublicense, and/or sell copies of the Software, and to permit 36 | * persons to whom the Software is furnished to do so, subject to the 37 | * following conditions: 38 | * 39 | * The above copyright notice and this permission notice shall be 40 | * included in all copies or substantial portions of the Software. 41 | * 42 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 43 | * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 44 | * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 45 | * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 46 | * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT 47 | * OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 48 | * THE USE OR OTHER DEALINGS IN THE SOFTWARE. 49 | */ 50 | 51 | /* Thanks to Bernard Clabots for string.gfind to make forward compatible to Lua 5.2 */ 52 | 53 | --]] 54 | 55 | module(..., package.seeall); 56 | 57 | string.gfind = string.gfind or string.gmatch 58 | 59 | local hex2bin = { 60 | ["0"] = "0000", 61 | ["1"] = "0001", 62 | ["2"] = "0010", 63 | ["3"] = "0011", 64 | ["4"] = "0100", 65 | ["5"] = "0101", 66 | ["6"] = "0110", 67 | ["7"] = "0111", 68 | ["8"] = "1000", 69 | ["9"] = "1001", 70 | ["a"] = "1010", 71 | ["b"] = "1011", 72 | ["c"] = "1100", 73 | ["d"] = "1101", 74 | ["e"] = "1110", 75 | ["f"] = "1111" 76 | } 77 | 78 | 79 | 80 | local bin2hex = { 81 | ["0000"] = "0", 82 | ["0001"] = "1", 83 | ["0010"] = "2", 84 | ["0011"] = "3", 85 | ["0100"] = "4", 86 | ["0101"] = "5", 87 | ["0110"] = "6", 88 | ["0111"] = "7", 89 | ["1000"] = "8", 90 | ["1001"] = "9", 91 | ["1010"] = "A", 92 | ["1011"] = "B", 93 | ["1100"] = "C", 94 | ["1101"] = "D", 95 | ["1110"] = "E", 96 | ["1111"] = "F" 97 | } 98 | 99 | --[[ 100 | local dec2hex = { 101 | ["0"] = "0", 102 | ["1"] = "1", 103 | ["2"] = "2", 104 | ["3"] = "3", 105 | ["4"] = "4", 106 | ["5"] = "5", 107 | ["6"] = "6", 108 | ["7"] = "7", 109 | ["8"] = "8", 110 | ["9"] = "9", 111 | ["10"] = "A", 112 | ["11"] = "B", 113 | ["12"] = "C", 114 | ["13"] = "D", 115 | ["14"] = "E", 116 | ["15"] = "F" 117 | } 118 | --]] 119 | 120 | 121 | -- These functions are big-endian and take up to 32 bits 122 | 123 | -- Hex2Bin 124 | -- Bin2Hex 125 | -- Hex2Dec 126 | -- Dec2Hex 127 | -- Bin2Dec 128 | -- Dec2Bin 129 | 130 | 131 | function Hex2Bin(s) 132 | 133 | -- s -> hexadecimal string 134 | 135 | local ret = "" 136 | local i = 0 137 | 138 | 139 | for i in string.gfind(s, ".") do 140 | i = string.lower(i) 141 | 142 | ret = ret..hex2bin[i] 143 | 144 | end 145 | 146 | return ret 147 | end 148 | 149 | 150 | function Bin2Hex(s) 151 | 152 | -- s -> binary string 153 | 154 | local l = 0 155 | local h = "" 156 | local b = "" 157 | local rem 158 | 159 | l = string.len(s) 160 | rem = l % 4 161 | l = l-1 162 | h = "" 163 | 164 | -- need to prepend zeros to eliminate mod 4 165 | if (rem > 0) then 166 | s = string.rep("0", 4 - rem)..s 167 | end 168 | 169 | for i = 1, l, 4 do 170 | b = string.sub(s, i, i+3) 171 | h = h..bin2hex[b] 172 | end 173 | 174 | return h 175 | 176 | end 177 | 178 | 179 | function Bin2Dec(s) 180 | 181 | -- s -> binary string 182 | 183 | local num = 0 184 | local ex = string.len(s) - 1 185 | local l = 0 186 | 187 | l = ex + 1 188 | for i = 1, l do 189 | b = string.sub(s, i, i) 190 | if b == "1" then 191 | num = num + 2^ex 192 | end 193 | ex = ex - 1 194 | end 195 | 196 | return string.format("%u", num) 197 | 198 | end 199 | 200 | 201 | 202 | function Dec2Bin(s, num) 203 | 204 | -- s -> Base10 string 205 | -- num -> string length to extend to 206 | 207 | local n 208 | 209 | if (num == nil) then 210 | n = 0 211 | else 212 | n = num 213 | end 214 | 215 | s = string.format("%x", s) 216 | 217 | s = Hex2Bin(s) 218 | 219 | while string.len(s) < n do 220 | s = "0"..s 221 | end 222 | 223 | return s 224 | 225 | end 226 | 227 | 228 | 229 | 230 | function Hex2Dec(s) 231 | 232 | -- s -> hexadecimal string 233 | 234 | local s = Hex2Bin(s) 235 | 236 | return Bin2Dec(s) 237 | 238 | end 239 | 240 | 241 | 242 | function Dec2Hex(s) 243 | 244 | -- s -> Base10 string 245 | 246 | s = string.format("%x", s) 247 | 248 | return s 249 | 250 | end 251 | 252 | 253 | 254 | 255 | -- These functions are big-endian and will extend to 32 bits 256 | 257 | -- BMAnd 258 | -- BMNAnd 259 | -- BMOr 260 | -- BMXOr 261 | -- BMNot 262 | 263 | 264 | function BMAnd(v, m) 265 | 266 | -- v -> hex string to be masked 267 | -- m -> hex string mask 268 | 269 | -- s -> hex string as masked 270 | 271 | -- bv -> binary string of v 272 | -- bm -> binary string mask 273 | 274 | local bv = Hex2Bin(v) 275 | local bm = Hex2Bin(m) 276 | 277 | local i = 0 278 | local s = "" 279 | 280 | while (string.len(bv) < 32) do 281 | bv = "0000"..bv 282 | end 283 | 284 | while (string.len(bm) < 32) do 285 | bm = "0000"..bm 286 | end 287 | 288 | 289 | for i = 1, 32 do 290 | cv = string.sub(bv, i, i) 291 | cm = string.sub(bm, i, i) 292 | if cv == cm then 293 | if cv == "1" then 294 | s = s.."1" 295 | else 296 | s = s.."0" 297 | end 298 | else 299 | s = s.."0" 300 | 301 | end 302 | end 303 | 304 | return Bin2Hex(s) 305 | 306 | end 307 | 308 | 309 | function BMNAnd(v, m) 310 | 311 | -- v -> hex string to be masked 312 | -- m -> hex string mask 313 | 314 | -- s -> hex string as masked 315 | 316 | -- bv -> binary string of v 317 | -- bm -> binary string mask 318 | 319 | local bv = Hex2Bin(v) 320 | local bm = Hex2Bin(m) 321 | 322 | local i = 0 323 | local s = "" 324 | 325 | while (string.len(bv) < 32) do 326 | bv = "0000"..bv 327 | end 328 | 329 | while (string.len(bm) < 32) do 330 | bm = "0000"..bm 331 | end 332 | 333 | 334 | for i = 1, 32 do 335 | cv = string.sub(bv, i, i) 336 | cm = string.sub(bm, i, i) 337 | if cv == cm then 338 | if cv == "1" then 339 | s = s.."0" 340 | else 341 | s = s.."1" 342 | end 343 | else 344 | s = s.."1" 345 | 346 | end 347 | end 348 | 349 | return Bin2Hex(s) 350 | 351 | end 352 | 353 | 354 | 355 | function BMOr(v, m) 356 | 357 | -- v -> hex string to be masked 358 | -- m -> hex string mask 359 | 360 | -- s -> hex string as masked 361 | 362 | -- bv -> binary string of v 363 | -- bm -> binary string mask 364 | 365 | local bv = Hex2Bin(v) 366 | local bm = Hex2Bin(m) 367 | 368 | local i = 0 369 | local s = "" 370 | 371 | while (string.len(bv) < 32) do 372 | bv = "0000"..bv 373 | end 374 | 375 | while (string.len(bm) < 32) do 376 | bm = "0000"..bm 377 | end 378 | 379 | 380 | for i = 1, 32 do 381 | cv = string.sub(bv, i, i) 382 | cm = string.sub(bm, i, i) 383 | if cv == "1" then 384 | s = s.."1" 385 | elseif cm == "1" then 386 | s = s.."1" 387 | else 388 | s = s.."0" 389 | end 390 | end 391 | 392 | return Bin2Hex(s) 393 | 394 | end 395 | 396 | function BMXOr(v, m) 397 | 398 | -- v -> hex string to be masked 399 | -- m -> hex string mask 400 | 401 | -- s -> hex string as masked 402 | 403 | -- bv -> binary string of v 404 | -- bm -> binary string mask 405 | 406 | local bv = Hex2Bin(v) 407 | local bm = Hex2Bin(m) 408 | 409 | local i = 0 410 | local s = "" 411 | 412 | while (string.len(bv) < 32) do 413 | bv = "0000"..bv 414 | end 415 | 416 | while (string.len(bm) < 32) do 417 | bm = "0000"..bm 418 | end 419 | 420 | 421 | for i = 1, 32 do 422 | cv = string.sub(bv, i, i) 423 | cm = string.sub(bm, i, i) 424 | if cv == "1" then 425 | if cm == "0" then 426 | s = s.."1" 427 | else 428 | s = s.."0" 429 | end 430 | elseif cm == "1" then 431 | if cv == "0" then 432 | s = s.."1" 433 | else 434 | s = s.."0" 435 | end 436 | else 437 | -- cv and cm == "0" 438 | s = s.."0" 439 | end 440 | end 441 | 442 | return Bin2Hex(s) 443 | 444 | end 445 | 446 | 447 | function BMNot(v, m) 448 | 449 | -- v -> hex string to be masked 450 | -- m -> hex string mask 451 | 452 | -- s -> hex string as masked 453 | 454 | -- bv -> binary string of v 455 | -- bm -> binary string mask 456 | 457 | local bv = Hex2Bin(v) 458 | local bm = Hex2Bin(m) 459 | 460 | local i = 0 461 | local s = "" 462 | 463 | while (string.len(bv) < 32) do 464 | bv = "0000"..bv 465 | end 466 | 467 | while (string.len(bm) < 32) do 468 | bm = "0000"..bm 469 | end 470 | 471 | 472 | for i = 1, 32 do 473 | cv = string.sub(bv, i, i) 474 | cm = string.sub(bm, i, i) 475 | if cm == "1" then 476 | if cv == "1" then 477 | -- turn off 478 | s = s.."0" 479 | else 480 | -- turn on 481 | s = s.."1" 482 | end 483 | else 484 | -- leave untouched 485 | s = s..cv 486 | 487 | end 488 | end 489 | 490 | return Bin2Hex(s) 491 | 492 | end 493 | 494 | 495 | -- these functions shift right and left, adding zeros to lost or gained bits 496 | -- returned values are 32 bits long 497 | 498 | -- BShRight(v, nb) 499 | -- BShLeft(v, nb) 500 | 501 | 502 | function BShRight(v, nb) 503 | 504 | -- v -> hexstring value to be shifted 505 | -- nb -> number of bits to shift to the right 506 | 507 | -- s -> binary string of v 508 | 509 | local s = Hex2Bin(v) 510 | 511 | while (string.len(s) < 32) do 512 | s = "0000"..s 513 | end 514 | 515 | s = string.sub(s, 1, 32 - nb) 516 | 517 | while (string.len(s) < 32) do 518 | s = "0"..s 519 | end 520 | 521 | return Bin2Hex(s) 522 | 523 | end 524 | 525 | function BShLeft(v, nb) 526 | 527 | -- v -> hexstring value to be shifted 528 | -- nb -> number of bits to shift to the right 529 | 530 | -- s -> binary string of v 531 | 532 | local s = Hex2Bin(v) 533 | 534 | while (string.len(s) < 32) do 535 | s = "0000"..s 536 | end 537 | 538 | s = string.sub(s, nb + 1, 32) 539 | 540 | while (string.len(s) < 32) do 541 | s = s.."0" 542 | end 543 | 544 | return Bin2Hex(s) 545 | 546 | end 547 | -------------------------------------------------------------------------------- /src/ansible.lua: -------------------------------------------------------------------------------- 1 | local Ansible = {} 2 | 3 | local io = require("io") 4 | local json = require("dkjson") 5 | local ubus = require("ubus") 6 | 7 | Ansible.__index = Ansible 8 | 9 | local json_arguments = [===[<>]===] 10 | 11 | function Ansible.new(spec) 12 | local self = setmetatable({}, Ansible) 13 | self.spec = spec 14 | for k,v in pairs(spec) do 15 | v['name'] = k 16 | end 17 | self.params = nil 18 | return self 19 | end 20 | 21 | local function split(str, delimiter) 22 | local toks = {} 23 | 24 | for tok in string.gmatch(str, "[^".. delimiter .. "]+") do 25 | toks[#toks + 1] = tok 26 | end 27 | 28 | return toks 29 | end 30 | 31 | local function append(t1, t2) 32 | for k,v in ipairs(t2) do 33 | t1[#t1 + 1] = v 34 | end 35 | return t1 36 | end 37 | 38 | function Ansible.contains(needle, haystack) 39 | for _,v in pairs(haystack) do 40 | if needle == v then 41 | return true 42 | end 43 | end 44 | 45 | return false 46 | end 47 | 48 | local function findspec(name, spec) 49 | if spec[name] then 50 | return spec[name] 51 | end 52 | 53 | -- check whether an alias exists 54 | for k,v in pairs(spec) do 55 | if type(v) == "table" and v['aliases'] then 56 | if Ansible.contains(name, v['aliases']) then 57 | return v 58 | end 59 | end 60 | end 61 | 62 | return nil 63 | end 64 | 65 | local function starts_with(str, start) 66 | return str:sub(1, #start) == start 67 | end 68 | 69 | local function extract_internal_ansible_params(params) 70 | local copy = {} 71 | for k,v in pairs(params) do 72 | if starts_with(k, "_ansible") then 73 | copy[k] = v 74 | end 75 | end 76 | return copy 77 | end 78 | 79 | local function canonicalize(params, spec) 80 | local copy = {} 81 | for k,v in pairs(params) do 82 | local desc = findspec(k, spec) 83 | if not desc then 84 | -- ignore _ansible parameters 85 | if 1 ~= string.find(k, "_ansible") then 86 | return nil, "no such parameter " .. k 87 | end 88 | else 89 | if copy[desc['name']] then 90 | return nil, "duplicate parameter " .. desc['name'] 91 | end 92 | copy[desc['name']] = v 93 | end 94 | end 95 | 96 | params = copy 97 | 98 | return copy 99 | end 100 | 101 | function Ansible:slurp(path) 102 | local f, err = io.open(path, "r") 103 | if f == nil then 104 | Ansible.fail_json({msg="failed to open file " .. path .. ": " .. err}) 105 | end 106 | local content = f:read("*a") 107 | if content == nil then 108 | self:fail_json({msg="read from file " .. path .. "failed"}) 109 | end 110 | f:close() 111 | return content 112 | end 113 | 114 | function Ansible:unslurp(path, content) 115 | local f, err = io.open(path, "w+") 116 | if f == nil then 117 | Ansible.fail_json({msg="failed to open file " .. path .. ": " .. err}) 118 | end 119 | 120 | local res = f:write(content) 121 | 122 | if not res then 123 | self:fail_json({msg="read from file " .. path .. "failed"}) 124 | end 125 | f:close() 126 | return res 127 | end 128 | 129 | local function parse_dict_from_string(str) 130 | if 1 == string.find(str, "{") then 131 | -- assume json, try to decode it 132 | local dict, pos, err = json.decode(str) 133 | if not err then 134 | return dict 135 | end 136 | elseif string.find(str, "=") then 137 | fields = {} 138 | field_buffer = "" 139 | in_quote = nil 140 | in_escape = false 141 | for c in str:gmatch(".") do 142 | if in_escape then 143 | field_buffer = field_buffer .. c 144 | in_escape = false 145 | elseif c == '\\' then 146 | in_escape = true 147 | elseif not in_quote and ('\'' == c or '"' == c) then 148 | in_quote = c 149 | elseif in_quote and in_quote == c then 150 | in_quote = nil 151 | elseif not in_quote and (',' == c or ' ' == c) then 152 | if string.len(field_buffer) > 0 then 153 | fields[#fields + 1] = field_buffer 154 | end 155 | field_buffer="" 156 | else 157 | field_buffer = field_buffer .. c 158 | end 159 | end 160 | -- append the final field 161 | fields[#fields + 1] = field_buffer 162 | 163 | local dict = {} 164 | 165 | for _,v in ipairs(fields) do 166 | local key, val = string.match(v, "^([^=]+)=(.*)") 167 | 168 | if key and val then 169 | dict[key] = val 170 | end 171 | end 172 | 173 | return dict 174 | end 175 | 176 | return nil, str .. " dictionary requested, could not parse JSON or key=value" 177 | end 178 | 179 | local function check_transform_type(variable, ansibletype) 180 | -- Types: str list dict bool int float path raw jsonarg 181 | if "str" == ansibletype then 182 | if type(variable) == "string" then 183 | return variable 184 | end 185 | elseif "list" == ansibletype then 186 | if type(variable) == "table" then 187 | return variable 188 | end 189 | 190 | if type(variable) == "string" then 191 | return split(variable, ",") 192 | elseif type(variable) == "number" then 193 | return {variable} 194 | end 195 | elseif "dict" == ansibletype then 196 | if type(variable) == "table" then 197 | return variable 198 | elseif type(variable) == "string" then 199 | return parse_dict_from_string(variable) 200 | end 201 | elseif "bool" == ansibletype then 202 | if "boolean" == type(variable) then 203 | return variable 204 | elseif "number" == type(variable) then 205 | return not (0 == variable) 206 | elseif "string" == type(variable) then 207 | local BOOLEANS_TRUE = {'yes', 'on', '1', 'true', 'True'} 208 | local BOOLEANS_FALSE = {'no', 'off', '0', 'false', 'False'} 209 | 210 | if Ansible.contains(variable, BOOLEANS_TRUE) then 211 | return true 212 | elseif Ansible.contains(variable, BOOLEANS_FALSE) then 213 | return false 214 | end 215 | end 216 | elseif "int" == ansibletype or "float" == ansibletype then 217 | if type(variable) == "string" then 218 | local var = tonumber(variable) 219 | if var then 220 | return var 221 | end 222 | elseif type(variable) == "number" then 223 | return variable 224 | end 225 | elseif "path" == ansibletype then 226 | -- A bit basic, i know 227 | if type(variable) == "string" then 228 | return variable 229 | end 230 | elseif "raw" == ansibletype then 231 | return variable 232 | elseif "jsonarg" == ansibletype then 233 | if "table" == type(variable) then 234 | return variable 235 | elseif "string" == type(variable) then 236 | local dict, pos, err = json.decode(variable) 237 | if not err then 238 | return dict 239 | end 240 | end 241 | else 242 | return nil, ansibletype .. " is not a known type" 243 | end 244 | 245 | return nil, tostring(variable) .. " does not conform to type " .. ansibletype 246 | end 247 | 248 | function Ansible:parse(inputfile) 249 | local params, pos, err = json.decode(json_arguments) 250 | 251 | if err then 252 | self:fail_json({msg="INTERNAL: Illegal json input received"}) 253 | end 254 | 255 | self.internal_params = extract_internal_ansible_params(params) 256 | self._diff = self.internal_params['_ansible_diff'] 257 | 258 | -- resolve aliases 259 | params, err = canonicalize(params, self.spec) 260 | 261 | if not params then 262 | self:fail_json({msg="Err: " .. tostring(err)}) 263 | end 264 | 265 | for k,v in pairs(self.spec) do 266 | -- setup defaults 267 | if v['default'] then 268 | if nil == params[k] then 269 | params[k] = v['default'] 270 | end 271 | end 272 | 273 | -- assert requires 274 | if v['required'] then 275 | if not params[k] then 276 | self:fail_json({msg="Required parameter " .. k .. " not provided"}) 277 | end 278 | end 279 | end 280 | 281 | -- check types/choices 282 | for k,v in pairs(params) do 283 | local typedesc = self.spec[k]['type'] 284 | if typedesc then 285 | local val, err = check_transform_type(v, typedesc) 286 | if nil ~= val then 287 | params[k] = val 288 | else 289 | self:fail_json({msg="Err: " .. tostring(err)}) 290 | end 291 | end 292 | 293 | local choices = self.spec[k]['choices'] 294 | if choices then 295 | if not Ansible.contains(v, choices) then 296 | self:fail_json({msg=v .. " not a valid choice for " .. k}) 297 | end 298 | end 299 | end 300 | 301 | self.params = params 302 | 303 | return params 304 | end 305 | 306 | local function file_exists(path) 307 | local f=io.open(path,"r") 308 | if f~=nil then 309 | io.close(f) 310 | return true 311 | else 312 | return false 313 | end 314 | end 315 | 316 | function Ansible:get_bin_path(name, required, candidates) 317 | if not candidates then 318 | candidates = {} 319 | end 320 | 321 | local path = os.getenv("PATH") 322 | if path then 323 | candidates = append(candidates, split(path, ":")) 324 | end 325 | 326 | for _,dir in pairs(candidates) do 327 | local fpath = dir .. "/" .. name 328 | if file_exists(fpath) then 329 | return fpath 330 | end 331 | end 332 | 333 | if required then 334 | self:fail_json({msg="No executable " .. name .. " found in PATH or candidates"}) 335 | end 336 | 337 | return nil 338 | end 339 | 340 | function Ansible:remove_file(path) 341 | local rc, err = os.remove(path) 342 | if nil == rc then 343 | self:fail_json({msg="Internal, execute: failed to remove file " .. path}) 344 | end 345 | return rc 346 | end 347 | 348 | local function get_version() 349 | local version = assert(string.match(_VERSION, "Lua (%d+.%d+)")) 350 | return tonumber(version) -- Aaaah, it hurts to use floating point like this... 351 | end 352 | 353 | function Ansible:run_command(command) 354 | local stdout = os.tmpname() 355 | local stderr = os.tmpname() 356 | 357 | local cmd = string.format("%s >%q 2>%q", command, stdout, stderr) 358 | 359 | local rc = nil 360 | if 5.1 < get_version() then 361 | _, _, rc = os.execute(cmd) 362 | else 363 | rc = os.execute(cmd) 364 | end 365 | 366 | local out = self:slurp(stdout) 367 | local err = self:slurp(stderr) 368 | 369 | self:remove_file(stdout) 370 | self:remove_file(stderr) 371 | 372 | return rc, out, err 373 | end 374 | 375 | function Ansible:copy(src, dest) 376 | local command = string.format("cp -f %q %q", src, dest) 377 | local rc, _, err = self:run_command(command) 378 | 379 | if rc ~= 0 then 380 | return false, err 381 | else 382 | return true, err 383 | end 384 | end 385 | 386 | function Ansible:move(src, dest) 387 | local command = string.format("mv -f %q %q", src, dest) 388 | local rc, _, err = self:run_command(command) 389 | 390 | if rc ~= 0 then 391 | return false, err 392 | else 393 | return true, err 394 | end 395 | end 396 | 397 | function Ansible:fail_json(kwargs) 398 | assert(kwargs['msg']) 399 | kwargs['failed'] = true 400 | if nil == kwargs['changed'] then 401 | kwargs['changed'] = false 402 | end 403 | if nil == kwargs['invocation'] then 404 | kwargs['invocations'] = {module_args=self.params} 405 | end 406 | 407 | io.write(json.encode(kwargs)) 408 | os.exit(1) 409 | end 410 | 411 | function Ansible:exit_json(kwargs) 412 | if nil == kwargs['changed'] then 413 | kwargs['changed'] = false 414 | end 415 | if nil == kwargs['invocation'] then 416 | kwargs['invocations'] = {module_args=self:get_params()} 417 | end 418 | 419 | io.write(json.encode(kwargs)) 420 | os.exit(0) 421 | end 422 | 423 | function Ansible:get_params() 424 | return self.params 425 | end 426 | 427 | function Ansible:ubus_connect() 428 | local p = self:get_params() 429 | local timeout = p['timeout'] 430 | if not timeout then 431 | timeout = 30 432 | end 433 | local socket = p['socket'] 434 | 435 | local conn = ubus.connect(socket, timeout) 436 | if not conn then 437 | self:fail_json({msg="Failed to connect to ubus"}) 438 | end 439 | 440 | return conn 441 | end 442 | 443 | function Ansible:ubus_call(conn, namespace, procedure, arg) 444 | local res, status = conn:call(namespace, procedure, arg) 445 | 446 | if nil ~= status and 0 ~= status then 447 | self:fail_json({msg="Ubus call failed", call={namespace=namespace, procedure=procedure, arg=arg, status=status}}) 448 | end 449 | 450 | return res 451 | end 452 | 453 | function Ansible:backup_local(file) 454 | local backupdest 455 | 456 | if file_exits(file) then 457 | local ext = os.time("%Y-%m-%d@H:%M:%S~") 458 | 459 | backupdest = string.format("%s.%s", file, ext) 460 | 461 | local content = self:slurp(file) 462 | local res = self:unslurp(backupdest, content) 463 | end 464 | 465 | return backupdest 466 | end 467 | 468 | function Ansible:is_dir(path) 469 | local f, err, code = io.open(path, "r") 470 | 471 | if nil == f then 472 | return false, err, code 473 | end 474 | 475 | local ok, err, code = f:read(1) 476 | f:close() 477 | return code == 21, nil, nil 478 | end 479 | 480 | function Ansible:check_mode() 481 | return self.internal_params["_ansible_check_mode"] 482 | end 483 | 484 | return Ansible 485 | -------------------------------------------------------------------------------- /src/ansible_test.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env lua 2 | 3 | local Ansible = require("ansible") 4 | 5 | function main(arg) 6 | local module = Ansible.new({ 7 | required = { required=true }, 8 | choice = { choices={"a", "b", "c", "d"} }, 9 | alias = { aliases={"al", "alia"}}, 10 | default = { default="a" }, 11 | -- Types str list dict bool int float path raw jsonarg 12 | string = { type='str' }, 13 | list = { type='list' }, 14 | bool = { type='bool' }, 15 | int = { type='int' }, 16 | float = { type='float' }, 17 | dict = { type='dict' }, 18 | path = { type='path' }, 19 | raw = { type='raw' }, 20 | jsonarg = { type='jsonarg' }, 21 | reqalias = { aliases={"ra"}, required=true }, 22 | defchoice = { default="foo", choices={"foo", "bar", "baz"}}, 23 | defreq = { default="bar", required=true }, 24 | change = { type='bool' }, 25 | command = {}, 26 | binpath = { type='dict' } 27 | }) 28 | 29 | module:parse(arg[1]) 30 | 31 | local p = module:get_params(); 32 | 33 | if p["command"] then 34 | local rc, out, err = module:run_command(p["command"]) 35 | if 0 == rc then 36 | module:exit_json({msg="Success", rc=rc, out=out, err=err}) 37 | else 38 | module:fail_json({msg="Failure", rc=rc, out=out, err=err}) 39 | end 40 | elseif p["binpath"] then 41 | local binspec = p["binpath"] 42 | local binpath = module:get_bin_path(binspec["name"], binspec["required"], binspec["candidates"]) 43 | module:exit_json({msg="This is binpath", binpath=binpath}) 44 | elseif p["change"] then 45 | module:exit_json({msg="This is an echo", changed=true}) 46 | else 47 | module:exit_json({msg="This is an echo"}) 48 | end 49 | end 50 | 51 | main(arg) 52 | -------------------------------------------------------------------------------- /src/base64.lua: -------------------------------------------------------------------------------- 1 | -- Lua 5.1+ base64 v3.0 (c) 2009 by Alex Kloss , 2 | -- 2018 Piotr Śliwka 3 | -- licensed under the terms of the LGPL2 4 | 5 | local base64 = {} 6 | 7 | -- character table string 8 | local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' 9 | 10 | -- encoding 11 | function base64.encode(data) 12 | return ((data:gsub('.', function(x) 13 | local r,b='',x:byte() 14 | for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end 15 | return r; 16 | end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) 17 | if (#x < 6) then return '' end 18 | local c=0 19 | for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end 20 | return b:sub(c+1,c+1) 21 | end)..({ '', '==', '=' })[#data%3+1]) 22 | end 23 | 24 | -- decoding 25 | function base64.decode(data) 26 | data = string.gsub(data, '[^'..b..'=]', '') 27 | return (data:gsub('.', function(x) 28 | if (x == '=') then return '' end 29 | local r,f='',(b:find(x)-1) 30 | for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end 31 | return r; 32 | end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x) 33 | if (#x ~= 8) then return '' end 34 | local c=0 35 | for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end 36 | return string.char(c) 37 | end)) 38 | end 39 | 40 | return base64 41 | -------------------------------------------------------------------------------- /src/copy.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local File = require("fileutils") 5 | local os = require("os") 6 | 7 | 8 | function adjust_recursive_directory_permissions(pre_existing_dir, new_directory_list, index, module, directory_args, changed) 9 | -- Walk the new directories list and make sure that permissions are as we would expect 10 | 11 | local changed = false 12 | 13 | if index <= #new_directory_list then 14 | local working_dir = File.join(pre_existing_dir, new_directory_list[i]) 15 | directory_args['path'] = working_dir 16 | changed = File.set_fs_attributes_if_different(module, directory_args, changed, nil) 17 | changed = adjust_recursive_directory_permissions(working_dir, new_directory_list, index+1, module, directory_args, changed) 18 | end 19 | 20 | return changed 21 | end 22 | 23 | function main(arg) 24 | local module = Ansible.new( 25 | { src = { required=true } 26 | , original_basename = { aliases={"_original_basename"}, required=false } 27 | , content = { required=false } 28 | , path = { aliases={'dest'}, required=true } 29 | , backup = { default=false, type='bool' } 30 | , force = { default=true, aliases={'thirsty'}, type='bool' } 31 | , validate = { required=false, type='str' } 32 | , directory_mode = { required=false } 33 | , remote_src = { required=false, type='bool' } 34 | -- sha256sum, to check if the copy was successful - currently ignored 35 | , checksum = {} 36 | 37 | 38 | -- file common args 39 | -- , src = {} 40 | , mode = { type='raw' } 41 | , owner = {} 42 | , group = {} 43 | 44 | -- Selinux to ignore 45 | , seuser = {} 46 | , serole = {} 47 | , selevel = {} 48 | , setype = {} 49 | 50 | , follow = {type='bool', default=false} 51 | 52 | -- not taken by the file module, but other modules call file so it must ignore them 53 | , content = {} 54 | , backup = {} 55 | -- , force = {} 56 | , remote_src = {} 57 | , regexp = {} 58 | , delimiter = {} 59 | -- , directory_mode = {} 60 | } 61 | ) 62 | 63 | module:parse(arg[1]) 64 | 65 | local p = module:get_params() 66 | 67 | local src = File.expanduser(p['src']) 68 | local dest = File.expanduser(p['path']) 69 | local backup = p['backup'] 70 | local force = p['force'] 71 | local _original_basename = p['_original_basename'] 72 | local validate = p['validate'] 73 | local follow = p['follow'] 74 | local mode = p['mode'] 75 | local remote_src = p['remote_src'] 76 | 77 | if not File.exists(src) then 78 | module:fail_json({msg="Source " .. src .. " not found"}) 79 | end 80 | if not File.readable(src) then 81 | module:fail_json({msg="Source " .. src .. " not readable"}) 82 | end 83 | if File.isdir(src) then 84 | module:fail_json({msg="Remote copy does not support recursive copy of directory: " .. src}) 85 | end 86 | 87 | local checksum_src = File.sha1(module, src) 88 | local checksum_dest = nil 89 | local md5sum_src = File.md5(module, src) 90 | 91 | local changed = false 92 | 93 | -- Special handling for recursive copy - create intermediate dirs 94 | if _original_basename and string.match(dest, "/$") then 95 | dest = File.join(dest, orignal_basename) 96 | local dirname = File.dirname(dest) 97 | if not File.exists(dirname) and File.isabs(dirname) then 98 | local pre_existing_dir, new_directory_list = File.split_pre_existing_dir(dirname) 99 | File.mkdirs(dirname) 100 | local directory_args = p 101 | local direcotry_mode = p['directory_mode'] 102 | adjust_recursive_directory_permissions(pre_existing_dir, new_directory_list, 1, module, directory_args, changed) 103 | end 104 | end 105 | 106 | if File.exists(dest) then 107 | if File.islnk(dest) and follow then 108 | dest = File.realpath(dest) 109 | end 110 | if not force then 111 | module:exit_json({msg="file already exists", src=src, dest=dest, changed=false}) 112 | end 113 | if File.isdir(dest) then 114 | local basename = File.basename(src) 115 | if _original_basename then 116 | basename = _original_basename 117 | end 118 | dest = File.join(dest, basename) 119 | end 120 | if File.readable(dest) then 121 | checksum_dest = File.sha1(module, dest) 122 | end 123 | else 124 | if not File.exists(File.dirname(dest)) then 125 | if nil == File.stat(File.dirname(dest)) then 126 | module:fail_json({msg="Destination directory " .. File.dirname(dest) .. " is not accessible"}) 127 | end 128 | module:fail_json({msg="Destination directory " .. File.dirname(dest) .. " does not exist"}) 129 | end 130 | end 131 | 132 | if not File.writeable(File.dirname(dest)) then 133 | module:fail_json({msg="Destination " .. File.dirname(dest) .. " not writeable"}) 134 | end 135 | 136 | local backup_file = nil 137 | if checksum_src ~= checksum_dest or File.islnk(dest) then 138 | if not module:check_mode() then 139 | if backup and File.exists(dest) then 140 | backup_file = module:backup_local(dest) 141 | end 142 | 143 | local function err(res, msg) 144 | if not res then 145 | module:fail_json({msg="failed to copy: " .. src .. " to " .. dest .. ": " .. msg}) 146 | end 147 | end 148 | 149 | local res, msg 150 | -- allow for conversion from symlink 151 | if File.islnk(dest) then 152 | res, msg = File.unlink(dest) 153 | err(res, msg) 154 | res, msg = File.touch(dest) 155 | err(res, msg) 156 | end 157 | if validate then 158 | -- FIXME: Validate is currently unsupported 159 | end 160 | if remote_src then 161 | local tmpname, msg = File.mkstemp(File.dirname(dest) .. "/ansibltmp_XXXXXX") 162 | err(tmpname, msg) 163 | res, msg = module:copy(src, tmpdest) 164 | err(res, msg) 165 | res, msg = module:move(tmpdest, dest) 166 | err(res, msg) 167 | else 168 | res, msg = module:move(src, dest) 169 | err(res, msg) 170 | end 171 | end 172 | changed = true 173 | else 174 | changed = false 175 | end 176 | 177 | res_args = { dest=dest, src=src, md5sum=md5sum_src, checksum=checksum_src, changed=changed } 178 | if backup_file then 179 | res_args['backup_file'] = backup_file 180 | end 181 | 182 | p['dest'] = dest 183 | if not module:check_mode() then 184 | local file_args = p 185 | res_args['changed'] = File.set_fs_attributes_if_different(module, file_args, res_args['changed'], nil) 186 | end 187 | 188 | res_args['msg'] = "Dummy" 189 | 190 | module:exit_json(res_args) 191 | end 192 | 193 | main(arg) 194 | -------------------------------------------------------------------------------- /src/dkjson.lua: -------------------------------------------------------------------------------- 1 | -- Module options: 2 | local always_try_using_lpeg = true 3 | local register_global_module_table = false 4 | local global_module_name = 'json' 5 | 6 | --[==[ 7 | 8 | David Kolf's JSON module for Lua 5.1/5.2 9 | 10 | Version 2.5 11 | 12 | 13 | For the documentation see the corresponding readme.txt or visit 14 | . 15 | 16 | You can contact the author by sending an e-mail to 'david' at the 17 | domain 'dkolf.de'. 18 | 19 | 20 | Copyright (C) 2010-2013 David Heiko Kolf 21 | 22 | Permission is hereby granted, free of charge, to any person obtaining 23 | a copy of this software and associated documentation files (the 24 | "Software"), to deal in the Software without restriction, including 25 | without limitation the rights to use, copy, modify, merge, publish, 26 | distribute, sublicense, and/or sell copies of the Software, and to 27 | permit persons to whom the Software is furnished to do so, subject to 28 | the following conditions: 29 | 30 | The above copyright notice and this permission notice shall be 31 | included in all copies or substantial portions of the Software. 32 | 33 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 34 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 35 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 36 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS 37 | BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN 38 | ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 39 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 40 | SOFTWARE. 41 | 42 | --]==] 43 | 44 | -- global dependencies: 45 | local pairs, type, tostring, tonumber, getmetatable, setmetatable, rawset = 46 | pairs, type, tostring, tonumber, getmetatable, setmetatable, rawset 47 | local error, require, pcall, select = error, require, pcall, select 48 | local floor, huge = math.floor, math.huge 49 | local strrep, gsub, strsub, strbyte, strchar, strfind, strlen, strformat = 50 | string.rep, string.gsub, string.sub, string.byte, string.char, 51 | string.find, string.len, string.format 52 | local strmatch = string.match 53 | local concat = table.concat 54 | 55 | local json = { version = "dkjson 2.5" } 56 | 57 | if register_global_module_table then 58 | _G[global_module_name] = json 59 | end 60 | 61 | local _ENV = nil -- blocking globals in Lua 5.2 62 | 63 | pcall (function() 64 | -- Enable access to blocked metatables. 65 | -- Don't worry, this module doesn't change anything in them. 66 | local debmeta = require "debug".getmetatable 67 | if debmeta then getmetatable = debmeta end 68 | end) 69 | 70 | json.null = setmetatable ({}, { 71 | __tojson = function () return "null" end 72 | }) 73 | 74 | local function isarray (tbl) 75 | local max, n, arraylen = 0, 0, 0 76 | for k,v in pairs (tbl) do 77 | if k == 'n' and type(v) == 'number' then 78 | arraylen = v 79 | if v > max then 80 | max = v 81 | end 82 | else 83 | if type(k) ~= 'number' or k < 1 or floor(k) ~= k then 84 | return false 85 | end 86 | if k > max then 87 | max = k 88 | end 89 | n = n + 1 90 | end 91 | end 92 | if max > 10 and max > arraylen and max > n * 2 then 93 | return false -- don't create an array with too many holes 94 | end 95 | return true, max 96 | end 97 | 98 | local escapecodes = { 99 | ["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b", ["\f"] = "\\f", 100 | ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t" 101 | } 102 | 103 | local function escapeutf8 (uchar) 104 | local value = escapecodes[uchar] 105 | if value then 106 | return value 107 | end 108 | local a, b, c, d = strbyte (uchar, 1, 4) 109 | a, b, c, d = a or 0, b or 0, c or 0, d or 0 110 | if a <= 0x7f then 111 | value = a 112 | elseif 0xc0 <= a and a <= 0xdf and b >= 0x80 then 113 | value = (a - 0xc0) * 0x40 + b - 0x80 114 | elseif 0xe0 <= a and a <= 0xef and b >= 0x80 and c >= 0x80 then 115 | value = ((a - 0xe0) * 0x40 + b - 0x80) * 0x40 + c - 0x80 116 | elseif 0xf0 <= a and a <= 0xf7 and b >= 0x80 and c >= 0x80 and d >= 0x80 then 117 | value = (((a - 0xf0) * 0x40 + b - 0x80) * 0x40 + c - 0x80) * 0x40 + d - 0x80 118 | else 119 | return "" 120 | end 121 | if value <= 0xffff then 122 | return strformat ("\\u%.4x", value) 123 | elseif value <= 0x10ffff then 124 | -- encode as UTF-16 surrogate pair 125 | value = value - 0x10000 126 | local highsur, lowsur = 0xD800 + floor (value/0x400), 0xDC00 + (value % 0x400) 127 | return strformat ("\\u%.4x\\u%.4x", highsur, lowsur) 128 | else 129 | return "" 130 | end 131 | end 132 | 133 | local function fsub (str, pattern, repl) 134 | -- gsub always builds a new string in a buffer, even when no match 135 | -- exists. First using find should be more efficient when most strings 136 | -- don't contain the pattern. 137 | if strfind (str, pattern) then 138 | return gsub (str, pattern, repl) 139 | else 140 | return str 141 | end 142 | end 143 | 144 | local function quotestring (value) 145 | -- based on the regexp "escapable" in https://github.com/douglascrockford/JSON-js 146 | value = fsub (value, "[%z\1-\31\"\\\127]", escapeutf8) 147 | if strfind (value, "[\194\216\220\225\226\239]") then 148 | value = fsub (value, "\194[\128-\159\173]", escapeutf8) 149 | value = fsub (value, "\216[\128-\132]", escapeutf8) 150 | value = fsub (value, "\220\143", escapeutf8) 151 | value = fsub (value, "\225\158[\180\181]", escapeutf8) 152 | value = fsub (value, "\226\128[\140-\143\168-\175]", escapeutf8) 153 | value = fsub (value, "\226\129[\160-\175]", escapeutf8) 154 | value = fsub (value, "\239\187\191", escapeutf8) 155 | value = fsub (value, "\239\191[\176-\191]", escapeutf8) 156 | end 157 | return "\"" .. value .. "\"" 158 | end 159 | json.quotestring = quotestring 160 | 161 | local function replace(str, o, n) 162 | local i, j = strfind (str, o, 1, true) 163 | if i then 164 | return strsub(str, 1, i-1) .. n .. strsub(str, j+1, -1) 165 | else 166 | return str 167 | end 168 | end 169 | 170 | -- locale independent num2str and str2num functions 171 | local decpoint, numfilter 172 | 173 | local function updatedecpoint () 174 | decpoint = strmatch(tostring(0.5), "([^05+])") 175 | -- build a filter that can be used to remove group separators 176 | numfilter = "[^0-9%-%+eE" .. gsub(decpoint, "[%^%$%(%)%%%.%[%]%*%+%-%?]", "%%%0") .. "]+" 177 | end 178 | 179 | updatedecpoint() 180 | 181 | local function num2str (num) 182 | return replace(fsub(tostring(num), numfilter, ""), decpoint, ".") 183 | end 184 | 185 | local function str2num (str) 186 | local num = tonumber(replace(str, ".", decpoint)) 187 | if not num then 188 | updatedecpoint() 189 | num = tonumber(replace(str, ".", decpoint)) 190 | end 191 | return num 192 | end 193 | 194 | local function addnewline2 (level, buffer, buflen) 195 | buffer[buflen+1] = "\n" 196 | buffer[buflen+2] = strrep (" ", level) 197 | buflen = buflen + 2 198 | return buflen 199 | end 200 | 201 | function json.addnewline (state) 202 | if state.indent then 203 | state.bufferlen = addnewline2 (state.level or 0, 204 | state.buffer, state.bufferlen or #(state.buffer)) 205 | end 206 | end 207 | 208 | local encode2 -- forward declaration 209 | 210 | local function addpair (key, value, prev, indent, level, buffer, buflen, tables, globalorder, state) 211 | local kt = type (key) 212 | if kt ~= 'string' and kt ~= 'number' then 213 | return nil, "type '" .. kt .. "' is not supported as a key by JSON." 214 | end 215 | if prev then 216 | buflen = buflen + 1 217 | buffer[buflen] = "," 218 | end 219 | if indent then 220 | buflen = addnewline2 (level, buffer, buflen) 221 | end 222 | buffer[buflen+1] = quotestring (key) 223 | buffer[buflen+2] = ":" 224 | return encode2 (value, indent, level, buffer, buflen + 2, tables, globalorder, state) 225 | end 226 | 227 | local function appendcustom(res, buffer, state) 228 | local buflen = state.bufferlen 229 | if type (res) == 'string' then 230 | buflen = buflen + 1 231 | buffer[buflen] = res 232 | end 233 | return buflen 234 | end 235 | 236 | local function exception(reason, value, state, buffer, buflen, defaultmessage) 237 | defaultmessage = defaultmessage or reason 238 | local handler = state.exception 239 | if not handler then 240 | return nil, defaultmessage 241 | else 242 | state.bufferlen = buflen 243 | local ret, msg = handler (reason, value, state, defaultmessage) 244 | if not ret then return nil, msg or defaultmessage end 245 | return appendcustom(ret, buffer, state) 246 | end 247 | end 248 | 249 | function json.encodeexception(reason, value, state, defaultmessage) 250 | return quotestring("<" .. defaultmessage .. ">") 251 | end 252 | 253 | encode2 = function (value, indent, level, buffer, buflen, tables, globalorder, state) 254 | local valtype = type (value) 255 | local valmeta = getmetatable (value) 256 | valmeta = type (valmeta) == 'table' and valmeta -- only tables 257 | local valtojson = valmeta and valmeta.__tojson 258 | if valtojson then 259 | if tables[value] then 260 | return exception('reference cycle', value, state, buffer, buflen) 261 | end 262 | tables[value] = true 263 | state.bufferlen = buflen 264 | local ret, msg = valtojson (value, state) 265 | if not ret then return exception('custom encoder failed', value, state, buffer, buflen, msg) end 266 | tables[value] = nil 267 | buflen = appendcustom(ret, buffer, state) 268 | elseif value == nil then 269 | buflen = buflen + 1 270 | buffer[buflen] = "null" 271 | elseif valtype == 'number' then 272 | local s 273 | if value ~= value or value >= huge or -value >= huge then 274 | -- This is the behaviour of the original JSON implementation. 275 | s = "null" 276 | else 277 | s = num2str (value) 278 | end 279 | buflen = buflen + 1 280 | buffer[buflen] = s 281 | elseif valtype == 'boolean' then 282 | buflen = buflen + 1 283 | buffer[buflen] = value and "true" or "false" 284 | elseif valtype == 'string' then 285 | buflen = buflen + 1 286 | buffer[buflen] = quotestring (value) 287 | elseif valtype == 'table' then 288 | if tables[value] then 289 | return exception('reference cycle', value, state, buffer, buflen) 290 | end 291 | tables[value] = true 292 | level = level + 1 293 | local isa, n = isarray (value) 294 | if n == 0 and valmeta and valmeta.__jsontype == 'object' then 295 | isa = false 296 | end 297 | local msg 298 | if isa then -- JSON array 299 | buflen = buflen + 1 300 | buffer[buflen] = "[" 301 | for i = 1, n do 302 | buflen, msg = encode2 (value[i], indent, level, buffer, buflen, tables, globalorder, state) 303 | if not buflen then return nil, msg end 304 | if i < n then 305 | buflen = buflen + 1 306 | buffer[buflen] = "," 307 | end 308 | end 309 | buflen = buflen + 1 310 | buffer[buflen] = "]" 311 | else -- JSON object 312 | local prev = false 313 | buflen = buflen + 1 314 | buffer[buflen] = "{" 315 | local order = valmeta and valmeta.__jsonorder or globalorder 316 | if order then 317 | local used = {} 318 | n = #order 319 | for i = 1, n do 320 | local k = order[i] 321 | local v = value[k] 322 | if v then 323 | used[k] = true 324 | buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) 325 | prev = true -- add a seperator before the next element 326 | end 327 | end 328 | for k,v in pairs (value) do 329 | if not used[k] then 330 | buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) 331 | if not buflen then return nil, msg end 332 | prev = true -- add a seperator before the next element 333 | end 334 | end 335 | else -- unordered 336 | for k,v in pairs (value) do 337 | buflen, msg = addpair (k, v, prev, indent, level, buffer, buflen, tables, globalorder, state) 338 | if not buflen then return nil, msg end 339 | prev = true -- add a seperator before the next element 340 | end 341 | end 342 | if indent then 343 | buflen = addnewline2 (level - 1, buffer, buflen) 344 | end 345 | buflen = buflen + 1 346 | buffer[buflen] = "}" 347 | end 348 | tables[value] = nil 349 | else 350 | return exception ('unsupported type', value, state, buffer, buflen, 351 | "type '" .. valtype .. "' is not supported by JSON.") 352 | end 353 | return buflen 354 | end 355 | 356 | function json.encode (value, state) 357 | state = state or {} 358 | local oldbuffer = state.buffer 359 | local buffer = oldbuffer or {} 360 | state.buffer = buffer 361 | updatedecpoint() 362 | local ret, msg = encode2 (value, state.indent, state.level or 0, 363 | buffer, state.bufferlen or 0, state.tables or {}, state.keyorder, state) 364 | if not ret then 365 | error (msg, 2) 366 | elseif oldbuffer == buffer then 367 | state.bufferlen = ret 368 | return true 369 | else 370 | state.bufferlen = nil 371 | state.buffer = nil 372 | return concat (buffer) 373 | end 374 | end 375 | 376 | local function loc (str, where) 377 | local line, pos, linepos = 1, 1, 0 378 | while true do 379 | pos = strfind (str, "\n", pos, true) 380 | if pos and pos < where then 381 | line = line + 1 382 | linepos = pos 383 | pos = pos + 1 384 | else 385 | break 386 | end 387 | end 388 | return "line " .. line .. ", column " .. (where - linepos) 389 | end 390 | 391 | local function unterminated (str, what, where) 392 | return nil, strlen (str) + 1, "unterminated " .. what .. " at " .. loc (str, where) 393 | end 394 | 395 | local function scanwhite (str, pos) 396 | while true do 397 | pos = strfind (str, "%S", pos) 398 | if not pos then return nil end 399 | local sub2 = strsub (str, pos, pos + 1) 400 | if sub2 == "\239\187" and strsub (str, pos + 2, pos + 2) == "\191" then 401 | -- UTF-8 Byte Order Mark 402 | pos = pos + 3 403 | elseif sub2 == "//" then 404 | pos = strfind (str, "[\n\r]", pos + 2) 405 | if not pos then return nil end 406 | elseif sub2 == "/*" then 407 | pos = strfind (str, "*/", pos + 2) 408 | if not pos then return nil end 409 | pos = pos + 2 410 | else 411 | return pos 412 | end 413 | end 414 | end 415 | 416 | local escapechars = { 417 | ["\""] = "\"", ["\\"] = "\\", ["/"] = "/", ["b"] = "\b", ["f"] = "\f", 418 | ["n"] = "\n", ["r"] = "\r", ["t"] = "\t" 419 | } 420 | 421 | local function unichar (value) 422 | if value < 0 then 423 | return nil 424 | elseif value <= 0x007f then 425 | return strchar (value) 426 | elseif value <= 0x07ff then 427 | return strchar (0xc0 + floor(value/0x40), 428 | 0x80 + (floor(value) % 0x40)) 429 | elseif value <= 0xffff then 430 | return strchar (0xe0 + floor(value/0x1000), 431 | 0x80 + (floor(value/0x40) % 0x40), 432 | 0x80 + (floor(value) % 0x40)) 433 | elseif value <= 0x10ffff then 434 | return strchar (0xf0 + floor(value/0x40000), 435 | 0x80 + (floor(value/0x1000) % 0x40), 436 | 0x80 + (floor(value/0x40) % 0x40), 437 | 0x80 + (floor(value) % 0x40)) 438 | else 439 | return nil 440 | end 441 | end 442 | 443 | local function scanstring (str, pos) 444 | local lastpos = pos + 1 445 | local buffer, n = {}, 0 446 | while true do 447 | local nextpos = strfind (str, "[\"\\]", lastpos) 448 | if not nextpos then 449 | return unterminated (str, "string", pos) 450 | end 451 | if nextpos > lastpos then 452 | n = n + 1 453 | buffer[n] = strsub (str, lastpos, nextpos - 1) 454 | end 455 | if strsub (str, nextpos, nextpos) == "\"" then 456 | lastpos = nextpos + 1 457 | break 458 | else 459 | local escchar = strsub (str, nextpos + 1, nextpos + 1) 460 | local value 461 | if escchar == "u" then 462 | value = tonumber (strsub (str, nextpos + 2, nextpos + 5), 16) 463 | if value then 464 | local value2 465 | if 0xD800 <= value and value <= 0xDBff then 466 | -- we have the high surrogate of UTF-16. Check if there is a 467 | -- low surrogate escaped nearby to combine them. 468 | if strsub (str, nextpos + 6, nextpos + 7) == "\\u" then 469 | value2 = tonumber (strsub (str, nextpos + 8, nextpos + 11), 16) 470 | if value2 and 0xDC00 <= value2 and value2 <= 0xDFFF then 471 | value = (value - 0xD800) * 0x400 + (value2 - 0xDC00) + 0x10000 472 | else 473 | value2 = nil -- in case it was out of range for a low surrogate 474 | end 475 | end 476 | end 477 | value = value and unichar (value) 478 | if value then 479 | if value2 then 480 | lastpos = nextpos + 12 481 | else 482 | lastpos = nextpos + 6 483 | end 484 | end 485 | end 486 | end 487 | if not value then 488 | value = escapechars[escchar] or escchar 489 | lastpos = nextpos + 2 490 | end 491 | n = n + 1 492 | buffer[n] = value 493 | end 494 | end 495 | if n == 1 then 496 | return buffer[1], lastpos 497 | elseif n > 1 then 498 | return concat (buffer), lastpos 499 | else 500 | return "", lastpos 501 | end 502 | end 503 | 504 | local scanvalue -- forward declaration 505 | 506 | local function scantable (what, closechar, str, startpos, nullval, objectmeta, arraymeta) 507 | local len = strlen (str) 508 | local tbl, n = {}, 0 509 | local pos = startpos + 1 510 | if what == 'object' then 511 | setmetatable (tbl, objectmeta) 512 | else 513 | setmetatable (tbl, arraymeta) 514 | end 515 | while true do 516 | pos = scanwhite (str, pos) 517 | if not pos then return unterminated (str, what, startpos) end 518 | local char = strsub (str, pos, pos) 519 | if char == closechar then 520 | return tbl, pos + 1 521 | end 522 | local val1, err 523 | val1, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) 524 | if err then return nil, pos, err end 525 | pos = scanwhite (str, pos) 526 | if not pos then return unterminated (str, what, startpos) end 527 | char = strsub (str, pos, pos) 528 | if char == ":" then 529 | if val1 == nil then 530 | return nil, pos, "cannot use nil as table index (at " .. loc (str, pos) .. ")" 531 | end 532 | pos = scanwhite (str, pos + 1) 533 | if not pos then return unterminated (str, what, startpos) end 534 | local val2 535 | val2, pos, err = scanvalue (str, pos, nullval, objectmeta, arraymeta) 536 | if err then return nil, pos, err end 537 | tbl[val1] = val2 538 | pos = scanwhite (str, pos) 539 | if not pos then return unterminated (str, what, startpos) end 540 | char = strsub (str, pos, pos) 541 | else 542 | n = n + 1 543 | tbl[n] = val1 544 | end 545 | if char == "," then 546 | pos = pos + 1 547 | end 548 | end 549 | end 550 | 551 | scanvalue = function (str, pos, nullval, objectmeta, arraymeta) 552 | pos = pos or 1 553 | pos = scanwhite (str, pos) 554 | if not pos then 555 | return nil, strlen (str) + 1, "no valid JSON value (reached the end)" 556 | end 557 | local char = strsub (str, pos, pos) 558 | if char == "{" then 559 | return scantable ('object', "}", str, pos, nullval, objectmeta, arraymeta) 560 | elseif char == "[" then 561 | return scantable ('array', "]", str, pos, nullval, objectmeta, arraymeta) 562 | elseif char == "\"" then 563 | return scanstring (str, pos) 564 | else 565 | local pstart, pend = strfind (str, "^%-?[%d%.]+[eE]?[%+%-]?%d*", pos) 566 | if pstart then 567 | local number = str2num (strsub (str, pstart, pend)) 568 | if number then 569 | return number, pend + 1 570 | end 571 | end 572 | pstart, pend = strfind (str, "^%a%w*", pos) 573 | if pstart then 574 | local name = strsub (str, pstart, pend) 575 | if name == "true" then 576 | return true, pend + 1 577 | elseif name == "false" then 578 | return false, pend + 1 579 | elseif name == "null" then 580 | return nullval, pend + 1 581 | end 582 | end 583 | return nil, pos, "no valid JSON value at " .. loc (str, pos) 584 | end 585 | end 586 | 587 | local function optionalmetatables(...) 588 | if select("#", ...) > 0 then 589 | return ... 590 | else 591 | return {__jsontype = 'object'}, {__jsontype = 'array'} 592 | end 593 | end 594 | 595 | function json.decode (str, pos, nullval, ...) 596 | local objectmeta, arraymeta = optionalmetatables(...) 597 | return scanvalue (str, pos, nullval, objectmeta, arraymeta) 598 | end 599 | 600 | function json.use_lpeg () 601 | local g = require ("lpeg") 602 | 603 | if g.version() == "0.11" then 604 | error "due to a bug in LPeg 0.11, it cannot be used for JSON matching" 605 | end 606 | 607 | local pegmatch = g.match 608 | local P, S, R = g.P, g.S, g.R 609 | 610 | local function ErrorCall (str, pos, msg, state) 611 | if not state.msg then 612 | state.msg = msg .. " at " .. loc (str, pos) 613 | state.pos = pos 614 | end 615 | return false 616 | end 617 | 618 | local function Err (msg) 619 | return g.Cmt (g.Cc (msg) * g.Carg (2), ErrorCall) 620 | end 621 | 622 | local SingleLineComment = P"//" * (1 - S"\n\r")^0 623 | local MultiLineComment = P"/*" * (1 - P"*/")^0 * P"*/" 624 | local Space = (S" \n\r\t" + P"\239\187\191" + SingleLineComment + MultiLineComment)^0 625 | 626 | local PlainChar = 1 - S"\"\\\n\r" 627 | local EscapeSequence = (P"\\" * g.C (S"\"\\/bfnrt" + Err "unsupported escape sequence")) / escapechars 628 | local HexDigit = R("09", "af", "AF") 629 | local function UTF16Surrogate (match, pos, high, low) 630 | high, low = tonumber (high, 16), tonumber (low, 16) 631 | if 0xD800 <= high and high <= 0xDBff and 0xDC00 <= low and low <= 0xDFFF then 632 | return true, unichar ((high - 0xD800) * 0x400 + (low - 0xDC00) + 0x10000) 633 | else 634 | return false 635 | end 636 | end 637 | local function UTF16BMP (hex) 638 | return unichar (tonumber (hex, 16)) 639 | end 640 | local U16Sequence = (P"\\u" * g.C (HexDigit * HexDigit * HexDigit * HexDigit)) 641 | local UnicodeEscape = g.Cmt (U16Sequence * U16Sequence, UTF16Surrogate) + U16Sequence/UTF16BMP 642 | local Char = UnicodeEscape + EscapeSequence + PlainChar 643 | local String = P"\"" * g.Cs (Char ^ 0) * (P"\"" + Err "unterminated string") 644 | local Integer = P"-"^(-1) * (P"0" + (R"19" * R"09"^0)) 645 | local Fractal = P"." * R"09"^0 646 | local Exponent = (S"eE") * (S"+-")^(-1) * R"09"^1 647 | local Number = (Integer * Fractal^(-1) * Exponent^(-1))/str2num 648 | local Constant = P"true" * g.Cc (true) + P"false" * g.Cc (false) + P"null" * g.Carg (1) 649 | local SimpleValue = Number + String + Constant 650 | local ArrayContent, ObjectContent 651 | 652 | -- The functions parsearray and parseobject parse only a single value/pair 653 | -- at a time and store them directly to avoid hitting the LPeg limits. 654 | local function parsearray (str, pos, nullval, state) 655 | local obj, cont 656 | local npos 657 | local t, nt = {}, 0 658 | repeat 659 | obj, cont, npos = pegmatch (ArrayContent, str, pos, nullval, state) 660 | if not npos then break end 661 | pos = npos 662 | nt = nt + 1 663 | t[nt] = obj 664 | until cont == 'last' 665 | return pos, setmetatable (t, state.arraymeta) 666 | end 667 | 668 | local function parseobject (str, pos, nullval, state) 669 | local obj, key, cont 670 | local npos 671 | local t = {} 672 | repeat 673 | key, obj, cont, npos = pegmatch (ObjectContent, str, pos, nullval, state) 674 | if not npos then break end 675 | pos = npos 676 | t[key] = obj 677 | until cont == 'last' 678 | return pos, setmetatable (t, state.objectmeta) 679 | end 680 | 681 | local Array = P"[" * g.Cmt (g.Carg(1) * g.Carg(2), parsearray) * Space * (P"]" + Err "']' expected") 682 | local Object = P"{" * g.Cmt (g.Carg(1) * g.Carg(2), parseobject) * Space * (P"}" + Err "'}' expected") 683 | local Value = Space * (Array + Object + SimpleValue) 684 | local ExpectedValue = Value + Space * Err "value expected" 685 | ArrayContent = Value * Space * (P"," * g.Cc'cont' + g.Cc'last') * g.Cp() 686 | local Pair = g.Cg (Space * String * Space * (P":" + Err "colon expected") * ExpectedValue) 687 | ObjectContent = Pair * Space * (P"," * g.Cc'cont' + g.Cc'last') * g.Cp() 688 | local DecodeValue = ExpectedValue * g.Cp () 689 | 690 | function json.decode (str, pos, nullval, ...) 691 | local state = {} 692 | state.objectmeta, state.arraymeta = optionalmetatables(...) 693 | local obj, retpos = pegmatch (DecodeValue, str, pos, nullval, state) 694 | if state.msg then 695 | return nil, state.pos, state.msg 696 | else 697 | return obj, retpos 698 | end 699 | end 700 | 701 | -- use this function only once: 702 | json.use_lpeg = function () return json end 703 | 704 | json.using_lpeg = true 705 | 706 | return json -- so you can get the module using json = require "dkjson".use_lpeg() 707 | end 708 | 709 | if always_try_using_lpeg then 710 | pcall (json.use_lpeg) 711 | end 712 | 713 | return json 714 | 715 | -------------------------------------------------------------------------------- /src/fatpack.pl: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env perl 2 | 3 | use strict; 4 | use warnings; 5 | 6 | use Getopt::Long; 7 | use File::Basename qw/dirname basename/; 8 | use Storable qw/dclone/; 9 | use Data::Compare; 10 | use Data::Dumper; 11 | 12 | my $outdir; 13 | my $truncate = ''; 14 | my $inputfile; 15 | my $usage=''; 16 | my @whitelist=('io'); 17 | 18 | GetOptions('output=s' => \$outdir, 'truncate' => \$truncate, 'input=s' => \$inputfile, 'help' => \$usage, 'whitelist=s' => \@whitelist); 19 | 20 | @whitelist = uniq(split(/,/, join(',', @whitelist))); 21 | my $whitelisted = join '|', map{ "^" . $_ } map{quotemeta} sort {length($b)<=>length($a)}@whitelist; 22 | my $whitelistre = qr/($whitelisted)/; 23 | 24 | if ( $usage 25 | || ((!$outdir) || (! -d $outdir)) 26 | || ((!$inputfile) || (! -f $inputfile))) { 27 | print <<"EOF"; 28 | $0 --input --output [--truncate] [--whitelist ,] 29 | --help Print this help message 30 | --input file to fatpack. Expects all libs to reside in basedir(file) 31 | --output output directory for fatpacked files 32 | --truncate unconditionally override in outdir (default=false) 33 | --whitelist modules not to fatpack 34 | EOF 35 | exit -1; 36 | } 37 | 38 | sub uniq { 39 | my %seen; 40 | grep !$seen{$_}++, @_; 41 | } 42 | 43 | sub slurp { 44 | my $filename = shift; 45 | return do { 46 | local $/; 47 | open my $file, '<:encoding(UTF-8)', $filename or die "Failed to open file $filename"; 48 | <$file>; 49 | }; 50 | } 51 | 52 | sub extractIncludes { 53 | # get all requires from a given modules 54 | my $filecontent = shift; 55 | 56 | my @requires = ($filecontent =~ m/require\((.*?)\)/g); 57 | @requires = map {sanitizeRequire($_)} @requires; 58 | 59 | return \@requires; 60 | } 61 | 62 | sub sanitizeRequire { 63 | my $require = shift; 64 | # remove all quotes and whitespaces from requires 65 | $require =~ s/^['"\s]+|['"\s]+$//g; 66 | return $require; 67 | } 68 | 69 | sub fix { 70 | my $op = shift; 71 | my $old = shift; 72 | my $new = $old; 73 | 74 | do { 75 | $old = $new; 76 | $new = dclone($old); 77 | $new = $op->($new); 78 | } while (!Compare($old, $new)); 79 | 80 | return $new; 81 | } 82 | 83 | sub getModule { 84 | my $includedir = shift; 85 | my $modulename = shift; 86 | 87 | return slurp("$includedir/$modulename.lua"); 88 | } 89 | 90 | sub fatpack { 91 | my $mainmodule = shift; 92 | my $modules = shift; 93 | 94 | # split the module in shebang+header and the actual code 95 | $modules->{$mainmodule} =~ /^(?(#!.*\n|--.*\n)+)(?(.|\n)*)/; 96 | 97 | my $head = $+{head}; 98 | my $tail = $+{tail}; 99 | 100 | # Build the fatpacked script 101 | my $packed = $head; 102 | 103 | $packed .= <<"EOF"; 104 | 105 | do 106 | local _ENV = _ENV 107 | EOF 108 | 109 | foreach my $module (sort keys %{$modules}) { 110 | next if ($module eq $mainmodule); 111 | 112 | my $effcontent = %{$modules}{$module}; 113 | # Strip the shebang 114 | $effcontent =~ s/^#!.*\n//g; 115 | 116 | $packed .= <<"EOF"; 117 | package.preload["$module"] = function( ... ) 118 | local arg = _G.arg; 119 | _ENV = _ENV; 120 | 121 | $effcontent 122 | end 123 | EOF 124 | } 125 | 126 | $packed .= "\nend\n"; 127 | 128 | $packed .= $tail; 129 | 130 | return $packed; 131 | } 132 | 133 | sub unslurp { 134 | my $dst = shift; 135 | my $content = shift; 136 | 137 | if (-f $dst && !$truncate) { 138 | print STDERR "$dst already exists and --truncate was not specified\n"; 139 | exit(1); 140 | } 141 | 142 | open(my $fh, '>', $dst) or die "Could not open file '$dst'"; 143 | print $fh $content; 144 | close $fh; 145 | } 146 | 147 | sub main { 148 | my $includedir = dirname($inputfile); 149 | 150 | my $inputmodule = basename($inputfile, ".lua"); 151 | 152 | my $modules = { 153 | $inputmodule => getModule($includedir, $inputmodule), 154 | }; 155 | 156 | my $process = sub { 157 | my $modules = shift; 158 | 159 | my @new = (); 160 | 161 | # gather all includes in all modules 162 | while(my ($key, $value) = each %{$modules}) { 163 | my $extracted = extractIncludes($value); 164 | push @new, @$extracted; 165 | } 166 | 167 | # Add all new modules and their contents 168 | foreach my $module (@new) { 169 | if (! exists $modules->{$module} && $module !~ $whitelistre) { 170 | $modules->{$module} = getModule($includedir, $module); 171 | } 172 | } 173 | 174 | return $modules; 175 | }; 176 | 177 | $modules = fix($process, $modules); 178 | 179 | my $fat = fatpack($inputmodule, $modules); 180 | 181 | unslurp("$outdir/$inputmodule.lua", $fat); 182 | } 183 | 184 | main(); 185 | -------------------------------------------------------------------------------- /src/file.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local File = require("fileutils") 5 | local Errno = require("posix.errno") 6 | local unistd = require("posix.unistd") 7 | local time = require("posix.time") 8 | 9 | local function get_state(path) 10 | -- Find the current state 11 | 12 | if File.lexists(path) then 13 | local stat = File.stat(path) 14 | if File.islnk(path) then 15 | return 'link' 16 | elseif File.isdir(path) then 17 | return 'directory' 18 | elseif stat ~= nil and stat['st_nlink'] > 1 then 19 | return 'hard' 20 | else 21 | -- could be many other things but defaulting to file 22 | return 'file' 23 | end 24 | end 25 | 26 | return 'absent' 27 | end 28 | 29 | local function append(t1, t2) 30 | for k,v in ipairs(t2) do 31 | t1[#t1 + 1] = v 32 | end 33 | return t1 34 | end 35 | 36 | local function deepcopy(orig) 37 | local orig_type = type(orig) 38 | local copy 39 | if orig_type == 'table' then 40 | copy = {} 41 | for orig_key, orig_value in next, orig, nil do 42 | copy[deepcopy(orig_key)] = deepcopy(orig_value) 43 | end 44 | setmetatable(copy, deepcopy(getmetatable(orig))) 45 | else -- number, string, boolean, etc 46 | copy = orig 47 | end 48 | return copy 49 | end 50 | 51 | local function recursive_set_attributes(module, path, follow, file_args) 52 | local changed = false 53 | local out = {} 54 | for _, entry in ipairs(File.walk(path, false)) do 55 | local root = entry['root'] 56 | local fsobjs = append(entry['dirs'], entry['files']) 57 | 58 | for _, fsobj in ipairs(fsobjs) do 59 | fsname = File.join(root, {fsobj}) 60 | out[#out + 1] = fsname 61 | 62 | if not File.islnk(fsname) then 63 | local tmp_file_args = deepcopy(file_args) 64 | tmp_file_args['path'] = fsname 65 | changed = changed or File.set_fs_attributes_if_different(module, tmp_file_args, changed, nil) 66 | else 67 | local tmp_file_args = deepcopy(file_args) 68 | tmp_file_args['path'] = fsname 69 | changed = changed or File.set_fs_attributes_if_different(module, tmp_file_args, changed, nil) 70 | if follow then 71 | fsname = File.join(root, {File.readlink(fsname)}) 72 | if File.isdir(fsname) then 73 | changed = changed or recursive_set_attributes(module, fsname, follow, file_args) 74 | end 75 | tmp_file_args = deepcopy(file_args) 76 | tmp_file_args['path'] = fsname 77 | changed = changed or File.set_fs_attributes_if_different(module, tmp_file_args, changed, nil) 78 | end 79 | end 80 | end 81 | end 82 | 83 | return changed 84 | end 85 | 86 | local function strip(str, chars) 87 | str = string.gsub(str, string.format("^[%s]+", chars), "") 88 | str = string.gsub(str, string.format("[%s]+$", chars), "") 89 | return str 90 | end 91 | 92 | local function lstrip(str, chars) 93 | return string.gsub(str, string.format("^[%s]+", chars), "") 94 | end 95 | 96 | local function rstrip(str, chars) 97 | return string.gsub(str, string.format("[%s]+$", chars), "") 98 | end 99 | 100 | local function split(str, delimiter) 101 | local toks = {} 102 | 103 | for tok in string.gmatch(str, "[^".. delimiter .. "]+") do 104 | toks[#toks + 1] = tok 105 | end 106 | 107 | return toks 108 | end 109 | 110 | function main(arg) 111 | local module = Ansible.new( 112 | { state = { choices={'file', 'directory', 'link', 'hard', 'touch', 'absent' } } 113 | , path = { aliases={'dest', 'name'}, required=true } 114 | , original_basename = { aliases={"_original_basename"}, required=false } 115 | , recurse = { default=false, type='bool' } 116 | , force = { required=false, default=false, type='bool' } 117 | , diff_peek = {} 118 | , validate = { required=false } 119 | , src = {required=false} 120 | 121 | -- file common args 122 | -- , src = {} 123 | , mode = { type='raw' } 124 | , owner = {} 125 | , group = {} 126 | 127 | -- Selinux to ignore 128 | , seuser = {} 129 | , serole = {} 130 | , selevel = {} 131 | , setype = {} 132 | 133 | , follow = {type='bool', default=false} 134 | 135 | -- not taken by the file module, but other modules call file so it must ignore them 136 | , content = {} 137 | , backup = {} 138 | , force = {} 139 | , remote_src = {} 140 | , regexp = {} 141 | , delimiter = {} 142 | , directory_mode = {} 143 | } 144 | ) 145 | 146 | module:parse(arg[1]) 147 | 148 | -- FIXME: properly implement checkmode handling in module 149 | -- NB: This module is already capable of performing check_mode 150 | local checkmode = false 151 | 152 | local params = module:get_params() 153 | 154 | local state = params['state'] 155 | local force = params['force'] 156 | local diff_peek = params['diff_peek'] 157 | local src = params['src'] 158 | local follow = params['follow'] 159 | 160 | -- modify source as we later reload and pass, specially relevant when used by other modules 161 | path = File.expanduser(params['path']) 162 | params['path'] = path 163 | 164 | -- short-circuit for diff_peek 165 | if nil ~= diff_peek then 166 | local appears_binary = false 167 | 168 | local f, err = io.open(path, "r") 169 | if f ~= nil then 170 | local content = f:read(8192) 171 | if Ansible.contains('\x00', content) then 172 | appears_binary = true 173 | end 174 | end 175 | 176 | module.exit_json({path=path, changed=False, msg="Dummy", appears_binary=appears_binary}) 177 | end 178 | 179 | prev_state = get_state(path) 180 | 181 | -- state should default to file, but since that creates many conflicts 182 | -- default to 'current' when it exists 183 | if nil == state then 184 | if prev_state ~= 'absent' then 185 | state = prev_state 186 | else 187 | state = 'file' 188 | end 189 | end 190 | 191 | -- source is both the source of a symlink or an informational passing of the src for a template module 192 | -- or copy module, even if this module never uses it, it is needed to key off some things 193 | if src ~= nil then 194 | src = File.expanduser(src) 195 | else 196 | if 'link' == state or 'hard' == state then 197 | if follow and 'link' == state then 198 | -- use the current target of the link as the source 199 | src = File.realpath(path) 200 | else 201 | module:fail_json({msg='src and dest are required for creating links'}) 202 | end 203 | end 204 | end 205 | 206 | -- _original_basename is used by other modules that depend on file 207 | if File.isdir(path) and ("link" ~= state and "absent" ~= state) then 208 | local basename = nil 209 | if params['_original_basename'] then 210 | basename = params['_original_basename'] 211 | elseif src ~= nil then 212 | basename = File.basename(src) 213 | end 214 | if basename then 215 | path = File.join(path, {basename}) 216 | params['path'] = path 217 | end 218 | end 219 | 220 | -- make sure the target path is a directory when we're doing a recursive operation 221 | local recurse = params['recurse'] 222 | if recurse and state ~= 'directory' then 223 | module:fail_json({path=path, msg="recurse option requires state to be directory"}) 224 | end 225 | 226 | -- File args are inlined... 227 | local changed = false 228 | local diff = { before = {path=path} 229 | , after = {path=path}} 230 | 231 | local state_change = false 232 | if prev_state ~= state then 233 | diff['before']['state'] = prev_state 234 | diff['after']['state'] = state 235 | state_change = true 236 | end 237 | 238 | if state == 'absent' then 239 | if state_change then 240 | if not check_mode then 241 | if prev_state == 'directory' then 242 | local err = File.rmtree(path, {ignore_errors=false}) 243 | if err then 244 | module:fail_json({msg="rmtree failed"}) 245 | end 246 | else 247 | local status, errstr, errno = File.unlink(path) 248 | if not status then 249 | module:fail_json({path=path, msg="unlinking failed: " .. errstr}) 250 | end 251 | end 252 | end 253 | module:exit_json({path=path, changed=true, msg="dummy", diff=diff}) 254 | else 255 | module:exit_json({path=path, changed=false, msg="dummy"}) 256 | end 257 | elseif state == 'file' then 258 | if state_change then 259 | if follow and prev_state == 'link' then 260 | -- follow symlink and operate on original 261 | path = File.realpath(path) 262 | prev_state = get_state(path) 263 | path['path'] = path 264 | end 265 | end 266 | 267 | if prev_state ~= 'file' and prev_state ~= 'hard' then 268 | -- file is not absent and any other state is a conflict 269 | module:fail_json({path = path, msg=string.format("file (%s) is %s, cannot continue", path, prev_state)}) 270 | end 271 | 272 | changed = File.set_fs_attributes_if_different(module, params, changed, diff) 273 | module:exit_json({path=path, changed=changed, msg="dummy", diff=diff}) 274 | elseif state == 'directory' then 275 | if follow and prev_state == 'link' then 276 | path = File.realpath(path) 277 | prev_state = get_state(path) 278 | end 279 | 280 | if prev_state == 'absent' then 281 | if module:check_mode() then 282 | module:exit_json({changed=true, msg="dummy", diff=diff}) 283 | end 284 | changed = true 285 | local curpath = '' 286 | 287 | -- Split the path so we can apply filesystem attributes recursively 288 | -- from the root (/) directory for absolute paths or the base path 289 | -- of a relative path. We can then walk the appropriate directory 290 | -- path to apply attributes. 291 | 292 | local segments = split(strip(path, '/'), '/') 293 | for _, dirname in ipairs(segments) do 294 | curpath = curpath .. '/' .. dirname 295 | -- remove lieading slash if we're creating a relative path 296 | if not File.isabs(path) then 297 | curpath = lstrip(curpath, "/") 298 | end 299 | if not File.exists(curpath) then 300 | local status, errstr, errno = File.mkdir(path) 301 | if not status then 302 | if not (errno == Errno.EEXIST and File.isdir(curpath)) then 303 | module:fail_json({path=path, msg="There was an issue creating " .. curpath .. " as requested: " .. errstr}) 304 | end 305 | end 306 | tmp_file_args = deepcopy(params) 307 | tmp_file_args['path'] = curpath 308 | changed = File.set_fs_attributes_if_different(module, params, changed, diff) 309 | end 310 | end 311 | elseif prev_state ~= 'directory' then 312 | module:fail_json({path=path, msg=path .. "already exists as a " .. prev_state}) 313 | end 314 | 315 | changed = File.set_fs_attributes_if_different(module, params, changed, diff) 316 | 317 | if recurse then 318 | changed = changed or recursive_set_attributes(module, params['path'], follow, params) 319 | end 320 | 321 | module:exit_json({path=path, changed=changed, diff=diff, msg="Dummy"}) 322 | 323 | elseif state == 'link' or state == 'hard' then 324 | local relpath 325 | if File.isdir(path) and not File.islnk(path) then 326 | relpath = path 327 | else 328 | relpath = File.dirname(path) 329 | end 330 | 331 | local absrc = File.join(relpath, {src}) 332 | if not File.exists(absrc) and not force then 333 | module:fail_json({path=path, src=src, msg='src file does not exist, use "force=yes" if you really want to create the link ' .. absrc}) 334 | end 335 | 336 | if state == 'hard' then 337 | if not File.isabs(src) then 338 | module:fail_json({msg="absolute paths are required"}) 339 | end 340 | elseif pref_state == 'directory' then 341 | if not force then 342 | module:fail_json({path=path, msg="refusing to convert between " .. prev_state .. " and " .. state .. " for " .. path}) 343 | else 344 | local lsdir = File.listdir(path) 345 | if lsdir and #lsdir > 0 then 346 | -- refuse to replace a directory that has files in it 347 | module:fail_json({path=path, msg="the directory " .. path .. " is not empty, refusing to convert it"}) 348 | end 349 | end 350 | elseif (prev_state == "file" or prev_state == "hard") and not force then 351 | module:fail_json({path=path, msg="refusing to convert between " .. prev_state .. " and " .. state .. " for " .. path}) 352 | end 353 | 354 | if prev_state == 'absent' then 355 | changed = true 356 | elseif prev_state == 'link' then 357 | local old_src = File.readlink(path) 358 | if old_src ~= src then 359 | changed = true 360 | end 361 | elseif prev_state == 'hard' then 362 | if not (state == 'hard' and File.stat(path)['st_ino'] == File.stat(src)['st_ino']) then 363 | changed = true 364 | if not force then 365 | module:fail_json({dest=path, src=src, msg='Cannot link, different hard link exists at destination'}) 366 | end 367 | end 368 | elseif prev_state == 'file' or prev_state == 'directory' then 369 | changed = true 370 | if not force then 371 | module:fail_json({dest=path, src=src, msg='Cannot link, ' .. prev_state .. ' exists at destination'}) 372 | end 373 | else 374 | module:fail_json({dest=path, src=src, msg='unexpected position reached'}) 375 | end 376 | 377 | if changed and not module:check_mode() then 378 | if prev_state ~= absent then 379 | -- try to replace automically 380 | local tmppath = string.format("%s/.%d.%d.tmp", File.dirname(path), unistd.getpid(), time.time()) 381 | 382 | local status, errstr, errno 383 | if prev_state == 'directory' and (state == 'hard' or state == 'link')then 384 | status, errstr, errno = File.rmdir(path) 385 | end 386 | if state == 'hard' then 387 | status, errstr, errno = File.link(src, tmppath) 388 | else 389 | status, errstr, errno = File.symlink(src, tmppath) 390 | end 391 | if status then 392 | status, errstr, errno = File.rename(tmppath, path) 393 | end 394 | if not status then 395 | if File.exists(tmppath) then 396 | File.unlink(tmppath) 397 | end 398 | module:fail_json({path=path, msg='Error while replacing ' .. errstr}) 399 | end 400 | else 401 | local status, errstr, errno 402 | if state == 'hard' then 403 | status, errstr, errno = File.link(src, path) 404 | else 405 | status, errstr, errno = File.symlink(src, path) 406 | end 407 | if not status then 408 | module:fail_json({path=path, msg='Error while linking: ' .. errstr}) 409 | end 410 | end 411 | end 412 | 413 | if module:check_mode() and not File.exists(path) then 414 | module:exit_json({dest=path, src=src, msg="dummy", changed=changed, diff=diff}) 415 | end 416 | 417 | changed = File.set_fs_attributes_if_different(module, params, changed, diff) 418 | module:exit_json({dest=path, src=src, msg="dummy", changed=changed, diff=diff}) 419 | 420 | elseif state == 'touch' then 421 | if not module:check_mode() then 422 | local status, errmsg 423 | if prev_state == 'absent' then 424 | status, errmsg = File.touch(path) 425 | if not status then 426 | module:fail_json({path=path, msg='Error, could not touch target: ' .. errmsg}) 427 | end 428 | elseif prev_state == 'file' or prev_state == 'directory' or prev_state == 'hard' then 429 | status, errmsg = File.utime(path) 430 | if not status then 431 | module:fail_json({path=path, msg='Error while touching existing target: ' .. errmsg}) 432 | end 433 | else 434 | module:fail_json({msg='Cannot touch other than files, directories, and hardlinks (' .. path .. " is " .. prev_state .. ")"}) 435 | end 436 | 437 | -- FIXME: SORRY, we can't replicate the catching of SystemExit as far as I know... 438 | -- so we _may_ leak a file 439 | File.set_fs_attributes_if_different(module, params, true, diff) 440 | end 441 | 442 | module:exit_json({dest=path, changed=true, diff=diff, msg="dummy"}) 443 | end 444 | 445 | module.fail_json({path=path, msg='unexpected position reached'}) 446 | end 447 | 448 | main(arg) 449 | -------------------------------------------------------------------------------- /src/fileutils.lua: -------------------------------------------------------------------------------- 1 | local FileUtil = {} 2 | 3 | local unistd = require("posix.unistd") 4 | local stat = require("posix.sys.stat") 5 | local stdlib = require("posix.stdlib") 6 | local libgen = require("posix.libgen") 7 | local pwd = require("posix.pwd") 8 | local grp = require("posix.grp") 9 | local os = require("os") 10 | local bm = require("BinDecHex") 11 | local perrno = require("posix.errno") 12 | local utime = require("posix.utime") 13 | local stdio = require("posix.stdio") 14 | local dirent = require("posix.dirent") 15 | 16 | FileUtil.__index = FileUtil 17 | 18 | function FileUtil.md5(module, path) 19 | local command = string.format("md5sum %q", path) 20 | local res, out, err = module:run_command(command) 21 | 22 | if res ~= 0 then 23 | module:fail_json({msg="Failed to determine the md5sum for " .. path, error=err}) 24 | end 25 | 26 | local md5sum = string.match(out, "^[^%s\n]+") 27 | 28 | return md5sum 29 | end 30 | 31 | function FileUtil.sha1(module, path) 32 | local command = string.format("sha1sum %q", path) 33 | local res, out, err = module:run_command(command) 34 | 35 | if res ~= 0 then 36 | module:fail_json({msg="Failed to determine the sha1sum for " .. path, error=err}) 37 | end 38 | 39 | local sha1sum = string.match(out, "^[^%s\n]+") 40 | 41 | return sha1sum 42 | end 43 | 44 | function FileUtil.expanduser(path) 45 | if path == nil then 46 | return nil 47 | end 48 | local home = os.getenv("HOME") 49 | 50 | return string.gsub(path, "^~", home) 51 | end 52 | 53 | function FileUtil.lexists(path) 54 | local status, errstr, errno = unistd.access(path, "f") 55 | 56 | return 0 == status, errstr, errno 57 | end 58 | 59 | function FileUtil.exists(path) 60 | local status, errstr, errno = unistd.access(path, "f") 61 | 62 | return 0 == status, errstr, errno 63 | end 64 | 65 | function FileUtil.readable(path) 66 | local status, errstr, errno = unistd.access(path, "r") 67 | 68 | return 0 == status, errstr, errno 69 | end 70 | 71 | function FileUtil.writeable(path) 72 | local status, errstr, errno = unistd.access(path, "w") 73 | 74 | return 0 == status, errstr, errno 75 | end 76 | 77 | function FileUtil.isdir(path) 78 | local pstat = stat.stat(path) 79 | 80 | if pstat then 81 | return 0 ~= stat.S_ISDIR(pstat['st_mode']) 82 | else 83 | return false 84 | end 85 | end 86 | 87 | function FileUtil.islnk(path) 88 | local pstat = stat.lstat(path) 89 | 90 | if pstat then 91 | return 0 ~= stat.S_ISLNK(pstat['st_mode']) 92 | else 93 | return false 94 | end 95 | end 96 | 97 | function FileUtil.stat(path) 98 | return stat.stat(path) 99 | end 100 | 101 | function FileUtil.lstat(path) 102 | return stat.lstat(path) 103 | end 104 | 105 | function FileUtil.realpath(path) 106 | return stdlib.realpath(path) 107 | end 108 | 109 | function FileUtil.readlink(path) 110 | return unistd.readlink(path) 111 | end 112 | 113 | function FileUtil.basename(path) 114 | return libgen.basename(path) 115 | end 116 | 117 | function FileUtil.dirname(path) 118 | return libgen.dirname(path) 119 | end 120 | 121 | function FileUtil.rmtree(path, opts) 122 | local args = "-r" 123 | 124 | if opts['ignore_errors'] then 125 | args = args .. "f" 126 | end 127 | 128 | local cmd = string.format("rm %s %q", args, path) 129 | 130 | local rc = nil 131 | if 5.1 < get_version() then 132 | _, _, rc = os.execute(cmd) 133 | else 134 | rc = os.execute(cmd) 135 | end 136 | 137 | return rc ~= 0 138 | end 139 | 140 | function FileUtil.unlink(path) 141 | local status, errstr, errno = unistd.unlink(path) 142 | 143 | return 0 == status, errstr, errno 144 | end 145 | 146 | function FileUtil.get_user_and_group(path) 147 | local stat = FileUtil.stat(path) 148 | if stat then 149 | return stat['st_uid'], stat['st_gid'] 150 | else 151 | return nil, nil 152 | end 153 | end 154 | 155 | function FileUtil.parse_owner(owner) 156 | local uid = tonumber(owner) 157 | if (uid == nil) then 158 | local pwnam = pwd.getpwnam(owner) 159 | if pwnam ~= nil then 160 | uid = pwnam['pw_uid'] 161 | end 162 | end 163 | return uid 164 | end 165 | 166 | function FileUtil.parse_group(group) 167 | local gid = tonumber(group) 168 | if (gid == nil) then 169 | local grnam = grp.getgrnam(group) 170 | if grnam ~= nil then 171 | gid = grnam['gr_gid'] 172 | end 173 | end 174 | return gid 175 | end 176 | 177 | function FileUtil.lchown(path, uid, gid) 178 | local ret, errstr, errno 179 | -- lchown is only present in luaposix since 30.07.2016 180 | if unistd['lchown'] then 181 | ret, errstr, errno = unistd.lchown(path, uid, gid) 182 | else 183 | ret, errstr, errno = unistd.chown(path, uid, gid) 184 | end 185 | return ret == 0, errstr, errno 186 | end 187 | 188 | function FileUtil.set_owner_if_different(module, path, owner, changed, diff) 189 | path = FileUtil.expanduser(path) 190 | if owner == nil then 191 | return changed 192 | end 193 | local orig_uid, orig_gid = FileUtil.get_user_and_group(path) 194 | local uid = FileUtil.parse_owner(owner) 195 | if nil == uid then 196 | module:fail_json({path=path, msg='chown failed: failed to look up user ' .. tostring(owner)}) 197 | end 198 | if orig_uid ~= uid then 199 | if nil ~= diff then 200 | if nil == diff['before'] then 201 | diff['before'] = {} 202 | end 203 | diff['before']['owner'] = orig_uid 204 | if nil == diff['after'] then 205 | diff['after'] = {} 206 | end 207 | diff['after']['owner'] = uid 208 | end 209 | 210 | if module:check_mode() then 211 | return true 212 | end 213 | -- FIXME: sorry if there is no chown we fail the sematic slightly... but i don't care 214 | if not FileUtil.lchown(path, uid, -1) then 215 | module:fail_json({path=path, msg='chown failed'}) 216 | end 217 | changed = true 218 | end 219 | return changed 220 | end 221 | 222 | function FileUtil.set_group_if_different(module, path, group, changed, diff) 223 | path = FileUtil.expanduser(path) 224 | if group == nil then 225 | return changed 226 | end 227 | local orig_uid, orig_gid = FileUtil.get_user_and_group(path) 228 | local gid = FileUtil.parse_group(group) 229 | if nil == gid then 230 | module:fail_json({path=path, msg='chgrp failed: failed to look up group ' .. tostring(group)}) 231 | end 232 | if orig_gid ~= gid then 233 | if nil ~= diff then 234 | if nil == diff['before'] then 235 | diff['before'] = {} 236 | end 237 | diff['before']['group'] = orig_gid 238 | if nil == diff['after'] then 239 | diff['after'] = {} 240 | end 241 | diff['after']['group'] = gid 242 | end 243 | 244 | if module:check_mode() then 245 | return true 246 | end 247 | -- FIXME: sorry if there is no chown we fail the sematic slightly... but i don't care 248 | if not FileUtil.lchown(path, -1, gid) then 249 | module:fail_json({path=path, msg='chgrp failed'}) 250 | end 251 | changed = true 252 | end 253 | return changed 254 | end 255 | 256 | local function tohex(int) 257 | return bm.Dec2Hex(string.format("%d", int)) 258 | end 259 | 260 | function FileUtil.S_IMODE(mode) 261 | -- man 2 stat 262 | -- "... and the least significant 9 bits (0777) as the file permission bits" 263 | return tonumber(bm.Hex2Dec(bm.BMAnd(tohex(mode), tohex(0x1ff)))) 264 | end 265 | 266 | function FileUtil.lchmod(path, mode) 267 | if not FileUtil.islnk(path) then 268 | local ret, errstr, errno = stat.chmod(path, mode) 269 | return ret == 0, errstr, errno 270 | end 271 | return true, nil, nil 272 | end 273 | 274 | function FileUtil.set_mode_if_different(module, path, mode, changed, diff) 275 | path = FileUtil.expanduser(path) 276 | local path_stat = FileUtil.lstat(path) 277 | 278 | if mode == nil then 279 | return changed 280 | end 281 | 282 | if type(mode) ~= "number" then 283 | mode = tonumber(mode, 8) 284 | if nil == mode then 285 | module:fail_json({path=path, msg="mode must be in octal form (currently symbolic form is not supported, sorry)"}) 286 | end 287 | end 288 | if mode ~= FileUtil.S_IMODE(mode) then 289 | -- prevent mode from having extra info or being invald long number 290 | module:fail_json({path=path, msg="Invalid mode supplied, only permission info is allowed", details=mode}) 291 | end 292 | 293 | local prev_mode = FileUtil.S_IMODE(path_stat['st_mode']) 294 | 295 | if prev_mode ~= mode then 296 | if nil ~= diff then 297 | if nil == diff['before'] then 298 | diff['before'] = {} 299 | end 300 | diff['before']['mode'] = string.format("%o", prev_mode) 301 | if nil == diff['after'] then 302 | diff['after'] = {} 303 | end 304 | diff['after']['mode'] = string.format("%o", mode) 305 | end 306 | 307 | if module:check_mode() then 308 | return true 309 | end 310 | 311 | local res, errstr, errno = FileUtil.lchmod(path, mode) 312 | if not res then 313 | if errno ~= perrno['EPERM'] and errno ~= perrno['ELOOP'] then 314 | module:fail_json({path=path, msg='chmod failed', details=errstr}) 315 | end 316 | end 317 | 318 | path_stat = FileUtil.lstat(path) 319 | local new_mode = FileUtil.S_IMODE(path_stat['st_mode']) 320 | 321 | if new_mode ~= prev_mode then 322 | changed = true 323 | end 324 | end 325 | return changed 326 | end 327 | 328 | function FileUtil.set_fs_attributes_if_different(module, file_args, changed, diff) 329 | changed = FileUtil.set_owner_if_different(module, file_args['path'], file_args['owner'], changed, diff) 330 | changed = FileUtil.set_group_if_different(module, file_args['path'], file_args['group'], changed, diff) 331 | changed = FileUtil.set_mode_if_different(module, file_args['path'], file_args['mode'], changed, diff) 332 | return changed 333 | end 334 | 335 | function FileUtil.isabs(path) 336 | return 1 == string.find(path, "/") 337 | end 338 | 339 | function FileUtil.mkdir(path) 340 | local status, errstr, errno = stat.mkdir(path) 341 | return 0 == status, errstr, errno 342 | end 343 | 344 | function FileUtil.walk(path, follow) 345 | local entries = {} 346 | local stack = {path} 347 | local i = 1 348 | while i <= #stack do 349 | local cur = stack[i] 350 | 351 | local ok, dir = pcall(dirent.dir, cur) 352 | 353 | local entry = { root=cur } 354 | local dirs = {} 355 | local files = {} 356 | if ok and dir ~= nil then 357 | for _, entry in ipairs(dir) do 358 | if "." ~= entry and ".." ~= entry then 359 | local child = cur .. "/" .. entry 360 | if follow and FileUtil.islnk(child) then 361 | local dst = FileUtil.realpath(child) 362 | dirs[#dirs + 1] = entry 363 | stack[#stack + 1] = dst 364 | elseif FileUtil.isdir(child) then 365 | dirs[#dirs + 1] = entry 366 | stack[#stack + 1] = child 367 | else 368 | files[#files + 1] = entry 369 | end 370 | end 371 | end 372 | end 373 | entry['dirs'] = dirs 374 | entry['files'] = files 375 | entries[#entries + 1] = entry 376 | i = i + 1 377 | end 378 | 379 | return entries 380 | end 381 | 382 | function FileUtil.listdir(path) 383 | local ok, dir = pcall(dirent.dir, path) 384 | if not ok then 385 | return nil 386 | end 387 | 388 | local entries = {} 389 | 390 | for _, k in ipairs(dir) do 391 | if k ~= "." and k ~= ".." then 392 | entries[#entries + 1] = k 393 | end 394 | end 395 | 396 | return entries 397 | end 398 | 399 | function FileUtil.rmdir(path) 400 | local status, errstr, errno = unistd.rmdir(path) 401 | 402 | return 0 == status, errstr, errno 403 | end 404 | 405 | function FileUtil.link(target, link) 406 | local status, errstr, errno = unistd.link(target, link, false) 407 | 408 | return 0 == status, errstr, errno 409 | end 410 | 411 | function FileUtil.symlink(target, link) 412 | local status, errstr, errno = unistd.link(target, link, true) 413 | 414 | return 0 == status, errstr, errno 415 | end 416 | 417 | function FileUtil.unlink(path) 418 | local status, errstr, errno = unistd.unlink(path) 419 | 420 | return 0 == status, errstr, errno 421 | end 422 | 423 | function FileUtil.touch(path) 424 | local file, errmsg = io.open(path, "w") 425 | if file ~= nil then 426 | io.close(file) 427 | end 428 | return file ~= nil, errmsg 429 | end 430 | 431 | function FileUtil.utime(path) 432 | local status, errstr, errno = utime.utime(path) 433 | 434 | return 0 == status, errstr, errno 435 | end 436 | 437 | function FileUtil.join(path, paths) 438 | for _, segment in ipairs(paths) do 439 | if segment ~= nil then 440 | if FileUtil.isabs(segment) then 441 | path = segment 442 | else 443 | path = path .. "/" .. segment 444 | end 445 | end 446 | end 447 | 448 | return path 449 | end 450 | 451 | function FileUtil.rename(oldpath, newpath) 452 | local status, errstr, errno 453 | if nil ~= stdio['rename'] then 454 | status, errstr, errno = stdio.rename(oldpath, newpath) 455 | status = status == 0 456 | else 457 | status, errstr, errno = os.rename(oldpath, newpath) 458 | end 459 | 460 | return status, errstr, errno 461 | end 462 | 463 | function FileUtil.split(path) 464 | local tail = FileUtil.basename(path) 465 | local head = FileUtil.dirname(path) 466 | return head, tail 467 | end 468 | 469 | function FileUtil.split_pre_existing_dir(dirname) 470 | -- Return the first pre-existing directory and a list of the new directories that will be created 471 | local head, tail = FileUtil.split(dirname) 472 | 473 | local pre_existing_dir, new_directory_list 474 | if not FileUtil.exists(head) then 475 | pre_existing_dir, new_directory_list = FileUtil.split_pre_existing_dir(head) 476 | else 477 | return head, {tail} 478 | end 479 | new_directory_list[#new_directory_list + 1] = tail 480 | return pre_existing_dir, new_directory_list 481 | end 482 | 483 | function FileUtil.mkdirs(path) 484 | local exists, new = FileUtil.split_pre_existing_dir(path) 485 | 486 | for _, seg in ipairs(new) do 487 | exists = exists .. "/" .. seg 488 | local res, errstr, errno = FileUtil.mkdir(exists) 489 | if not res then 490 | return res, errstr, errno 491 | end 492 | end 493 | return true 494 | end 495 | 496 | function FileUtil.mkstemp(pattern) 497 | local fd, path = stdlib.mkstemp(pattern) 498 | if -1 ~= fd and type(fd) == "number" then 499 | unistd.close(fd) 500 | return path 501 | else 502 | return nil, path -- path is a errmsg in this case 503 | end 504 | end 505 | 506 | return FileUtil 507 | -------------------------------------------------------------------------------- /src/lineinfile.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local File = require("fileutils") 5 | 6 | local function join(list, sep) 7 | local cur = "" 8 | for i, v in ipairs(list) do 9 | if i ~= 1 then 10 | cur = string.format("%s%s%s", cur, sep, v) 11 | else 12 | cur = v 13 | end 14 | end 15 | return cur 16 | end 17 | 18 | function write_changes(module, lines, dest) 19 | -- FIXME: we do not support validate, sorry 20 | module:unslurp(dest, join(lines, "\n") .. "\n") 21 | end 22 | 23 | function check_file_attrs(module, changed, message, diff) 24 | file_args = module:get_params() 25 | if File.set_fs_attributes_if_different(module, file_args, changed, diff) then 26 | if changed then 27 | message = message .. " and " 28 | end 29 | changed = true 30 | message = message .. "ownership or perms changed" 31 | end 32 | 33 | return message, changed 34 | end 35 | 36 | local function splitlines(content) 37 | local lines = {} 38 | for line in string.gmatch(content, "[^\n]+") do 39 | lines[#lines + 1] = line 40 | end 41 | return lines 42 | end 43 | 44 | local function append(t1, t2) 45 | for k,v in ipairs(t2) do 46 | t1[#t1 + 1] = v 47 | end 48 | return t1 49 | end 50 | 51 | 52 | local function rstrip(str, chars) 53 | return string.gsub(str, string.format("[%s]+$", chars), "") 54 | end 55 | 56 | local function filter(matcher, list) 57 | local tmp = {} 58 | for i,v in ipairs(list) do 59 | if matcher(v) then 60 | tmp[#tmp + 1] = v 61 | end 62 | end 63 | return tmp 64 | end 65 | 66 | function present(module, dest, regexp, line, insertafter, insertbefore, create, backup, backrefs) 67 | diff = {before="", after="", before_header=dest .. " (content)", after_header=dest .. " (content)"} 68 | 69 | local lines 70 | if not File.exists(dest) then 71 | if not create then 72 | module:fail_json({rc=257, msg='Destination ' .. dest .. ' does not exist!'}) 73 | end 74 | local destpath = File.dirname(dest) 75 | if not File.exists(destpath) and not module:check_mode() then 76 | local status, errstr = File.mkdirs(destpath) 77 | if not status then 78 | module:fail_json({msg="Failed to create path components for " .. destpath .. ": " .. errstr}) 79 | end 80 | end 81 | lines = {} 82 | else 83 | lines = splitlines(module:slurp(dest)) 84 | end 85 | 86 | if module._diff then 87 | diff['before'] = join(lines, "\n") 88 | end 89 | 90 | local mre = regexp 91 | 92 | local insre = nil 93 | if insertafter ~= nil and insertafter ~= 'BOF' and insertafter ~= 'EOF' then 94 | insre = insertafter 95 | elseif insertbefore ~= nil and insertbefore ~= 'BOF' then 96 | insre = insertbefore 97 | end 98 | 99 | 100 | -- matchno is the line num where the regexp has been found 101 | -- borano is the line num where the insertafter/insertbefore has been found 102 | local matchno, borano = -1, -1 103 | local m = nil 104 | for lineno, cur_line in ipairs(lines) do 105 | if regexp ~= nil then 106 | -- FIXME: lua patterns are not regexes 107 | match_found = string.match(cur_line, mre) 108 | else 109 | match_found = line == rstrip(cur_line, '\r\n') 110 | end 111 | if match_found then 112 | matchno = lineno 113 | m = cur_line 114 | elseif insre ~= nil and string.match(cur_line, insre) then 115 | if insertafter then 116 | -- + 1 for the next line 117 | borano = lineno + 1 118 | end 119 | if insertbefore then 120 | -- + 1 for the previous line 121 | borano = lineno 122 | end 123 | end 124 | end 125 | 126 | local msg = '' 127 | local changed = false 128 | 129 | -- Regexp matched a line in the file 130 | if matchno ~= -1 then 131 | local new_line 132 | if backrefs then 133 | new_line = string.gsub(m, mre, line) 134 | else 135 | -- don't do backref expansion if not asked 136 | new_line = line 137 | end 138 | 139 | new_line = rstrip(new_line, '\r\n') 140 | 141 | if lines[matchno] ~= new_line then 142 | lines[matchno] = new_line 143 | msg = 'line replaced' 144 | changed = true 145 | end 146 | elseif backrefs then 147 | -- Do absolutely nothing since it's not safe generating the line 148 | -- without the regexp matching to populate the backrefs 149 | elseif insertbefore == 'BOF' or insertafter=='BOF' then 150 | local tmp = { line } 151 | lines = append(tmp, lines) 152 | msg = 'line added' 153 | changed = true 154 | -- Add it to the end of the file if requested or 155 | -- if insertafter/insertbefore didn't match anything 156 | -- (so default behaviour is to add at the end) 157 | elseif insertafter == 'EOF' or borano == -1 then 158 | lines[#lines + 1] = line 159 | msg = 'line added' 160 | changed = true 161 | -- insert* matched, but not the regexp 162 | else 163 | local tmp = {} 164 | for i,v in ipairs(lines) do 165 | if i == borano then 166 | tmp[#tmp + 1] = line 167 | end 168 | tmp[#tmp + 1] = v 169 | end 170 | end 171 | 172 | if module._diff then 173 | diff['after'] = join(lines, "\n") 174 | end 175 | 176 | local backupdest = "" 177 | if changed and not module:check_mode() then 178 | if backup and File.exists(dest) then 179 | backupdest = module:backup_local(dest) 180 | end 181 | write_changes(module, lines, dest) 182 | end 183 | 184 | if module:check_mode() and not File.exists(dest) then 185 | module:exit_json({changed=changed, msg=msg, backup=backupdest, diff=diff}) 186 | end 187 | 188 | local attr_diff = {} 189 | msg, changed = check_file_attrs(module, changed, msg, attr_diff) 190 | 191 | attr_diff['before_header'] = dest .. " (file attributes)" 192 | attr_diff['after_header'] = dest .. " (file attributes)" 193 | 194 | local difflist = {diff, attr_diff} 195 | module:exit_json({changed=changed, msg=msg, backup=backupdest, diff=difflist}) 196 | end 197 | 198 | function absent(module, dest, regexp, line, backup) 199 | if not File.exists(dest) then 200 | module:exit_json({changed=false, msg="file not present"}) 201 | end 202 | 203 | local msg = "" 204 | diff = {before='', after='', before_header=dest .. '(content)', after_header=dest .. '(content)'} 205 | 206 | local lines = splitlines(module:slurp(dest)) 207 | 208 | if module._diff then 209 | diff['before'] = join(lines, "\n") 210 | end 211 | 212 | local cre 213 | if regexp ~= nil then 214 | cre = regexp 215 | end 216 | found = {} 217 | 218 | local function matcher(cur_line) 219 | local match_found 220 | if regexp ~= nil then 221 | match_found = string.match(cur_line, cre) 222 | else 223 | match_found = line == rstrip(cur_line, "\r\n") 224 | end 225 | if match_found then 226 | found[#found + 1] = cur_line 227 | end 228 | 229 | return not match_found 230 | end 231 | 232 | lines = filter(matcher, lines) 233 | changed = #found > 0 234 | 235 | if module._diff then 236 | diff['after'] = join(lines, "\n") 237 | end 238 | 239 | backupdest = "" 240 | if changed and not module:check_mode() then 241 | if backup then 242 | backupdest = module:backup_local(dest) 243 | end 244 | write_changes(module, lines, dest) 245 | end 246 | 247 | if changed then 248 | msg = tostring(#found) .. " line(s) removed" 249 | end 250 | 251 | local attr_diff={} 252 | attr_diff['before_header'] = dest .. " (file attributes)" 253 | attr_diff['after_header'] = dest .. " (file attributes)" 254 | 255 | local difflist = {diff, attr_diff} 256 | module:exit_json({changed=changed, found=#found, msg=msg, backup=backupdest, diff=difflist}) 257 | end 258 | 259 | function main(arg) 260 | local module = Ansible.new({ 261 | line = { type='str' }, 262 | mode = { type='str' }, 263 | backup = { default=false, type='bool' }, 264 | insertbefore = { type='str' }, 265 | insertafter = { type='str' }, 266 | owner = { type='str' }, 267 | group = { type='str' }, 268 | backrefs = { default=false, type='bool' }, 269 | create = { default=false, type='bool' }, 270 | path = { aliases={'name', 'dest', 'destfile'}, type='path', required='true' }, 271 | regexp = { type='str' }, 272 | state = { default = "present", choices={"present", "absent"} }, 273 | }) 274 | 275 | module:parse(arg[1]) 276 | 277 | local p = module:get_params() 278 | 279 | -- Ensure that the dest parameter is valid 280 | local dest = File.expanduser(p['path']) 281 | local create = p['create'] 282 | local backup = p['backup'] 283 | local backrefs = p['backrefs'] 284 | 285 | if p['insertbefore'] and p['insertafter'] then 286 | module:fail_json({msg="The options insertbefore and insertafter are mutually exclusive"}) 287 | end 288 | 289 | if File.isdir(dest) then 290 | module:fail_json({msg="Destination " .. dest .. " is a directory!"}) 291 | end 292 | 293 | if p['state'] == "present" then 294 | if backrefs and p['regexp'] == nil then 295 | module:fail_json({msg='regexp= is required wioth backrefs=true'}) 296 | end 297 | 298 | if p['line'] == nil then 299 | module:fail_json({msg='line= is required with state=present'}) 300 | end 301 | 302 | -- Deal with the insertafter default value manually, to avoid errors 303 | -- because of the mutually_exclusive mechanism 304 | local ins_bef, ins_aft = p['insertbefore'], p['insertafter'] 305 | if ins_bef == nil and ins_aft == nil then 306 | ins_aft = 'EOF' 307 | end 308 | 309 | local line = p['line'] 310 | present(module, dest, p['regexp'], line, ins_aft, ins_bef, create, backup, backrefs) 311 | else 312 | if p['regexp'] == nil and p['line'] == nil then 313 | module:fail_json({msg='one of line= or regexp= is required with state=absent'}) 314 | end 315 | absent(module, dest, p['regexp'], p['line'], backup) 316 | end 317 | end 318 | 319 | main(arg) 320 | -------------------------------------------------------------------------------- /src/opkg.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | 5 | function update_package_db(module, opkg_path) 6 | local rc, out, err = module:run_command(string.format("%s update", opkg_path)) 7 | 8 | if rc ~= 0 then 9 | module:fail_json({msg = "could not update package db", opkg={rc=rc, out=out, err=err}}) 10 | end 11 | end 12 | 13 | function query_package(module, opkg_path, name) 14 | local rc, out, err = module:run_command(string.format("%s list-installed", opkg_path)) 15 | 16 | if rc ~= 0 then 17 | module:fail_json({msg = "failed to list installed packages", opkg={rc=rc, out=out, err=err}}) 18 | end 19 | 20 | for line in string.gmatch(out, "[^\n]+") do 21 | if name == string.match(line, "^(%S+)%s") then 22 | return true 23 | end 24 | end 25 | 26 | return false 27 | end 28 | 29 | function get_force(force) 30 | if force and string.len(force) > 0 then 31 | return "--force-" .. force 32 | else 33 | return "" 34 | end 35 | end 36 | 37 | function remove_packages(module, opkg_path, packages) 38 | local p = module:get_params() 39 | 40 | local force = get_force(p["force"]) 41 | 42 | local remove_c = 0 43 | 44 | for _,package in ipairs(packages) do 45 | -- Query the package first, to see if we even need to remove 46 | if query_package(module, opkg_path, package) then 47 | if not module:check_mode() then 48 | local rc, out, err = module:run_command(string.format("%s remove %s %q", opkg_path, force, package)) 49 | 50 | if rc ~= 0 or query_package(module, opkg_path, package) then 51 | module:fail_json({msg="failed to remove " .. package, opkg={rc=rc, out=out, err=err}}) 52 | end 53 | end 54 | 55 | remove_c = remove_c + 1; 56 | end 57 | end 58 | 59 | if remove_c > 0 then 60 | module:exit_json({changed=true, msg=string.format("removed %d package(s)", remove_c)}) 61 | else 62 | module:exit_json({changed=false, msg="package(s) already absent"}) 63 | end 64 | end 65 | 66 | function install_packages(module, opkg_path, packages) 67 | local p = module:get_params() 68 | 69 | local force = get_force(p["force"]) 70 | 71 | local install_c = 0 72 | 73 | for _,package in ipairs(packages) do 74 | -- Query the package first, to see if we even need to remove 75 | if not query_package(module, opkg_path, package) then 76 | if not module:check_mode() then 77 | local rc, out, err = module:run_command(string.format("%s install %s %s", opkg_path, force, package)) 78 | 79 | if rc ~= 0 or not query_package(module, opkg_path, package) then 80 | module:fail_json({msg=string.format("failed to install %s", package), opkg={rc=rc, out=out, err=err}}) 81 | end 82 | end 83 | 84 | install_c = install_c + 1; 85 | end 86 | end 87 | 88 | if install_c > 0 then 89 | module:exit_json({changed=true, msg=string.format("installed %s packages(s)", install_c)}) 90 | else 91 | module:exit_json({changed=false, msg="package(s) already present"}) 92 | end 93 | end 94 | 95 | function last_cache_update_timestamp(module) 96 | local rc, stdout, stderr = module:run_command("date +%s -r /tmp/opkg-lists") 97 | if rc ~= 0 then 98 | return nil 99 | else 100 | return tonumber(stdout) 101 | end 102 | end 103 | 104 | function current_timestamp(module) 105 | local rc, stdout, stderr = module:run_command("date +%s") 106 | return tonumber(stdout) 107 | end 108 | 109 | function cache_age(module) 110 | local last_update = last_cache_update_timestamp(module) 111 | if last_update == nil then 112 | return nil 113 | else 114 | return current_timestamp(module) - last_update 115 | end 116 | end 117 | 118 | function should_update_cache(module) 119 | if module.params.update_cache then 120 | return true 121 | end 122 | if module.params.cache_valid_time ~= nil then 123 | local age = cache_age(module) 124 | if age == nil then 125 | return true 126 | end 127 | if age > module.params.cache_valid_time then 128 | return true 129 | end 130 | end 131 | return false 132 | end 133 | 134 | function update_cache_if_needed(module, opkg_path) 135 | if should_update_cache(module) and not module:check_mode() then 136 | update_package_db(module, opkg_path) 137 | end 138 | end 139 | 140 | function main(arg) 141 | local module = Ansible.new({ 142 | name = { aliases = {"pkg"}, required=true , type='list'}, 143 | state = { default = "present", choices={"present", "installed", "absent", "removed"} }, 144 | force = { default = "", choices={"", "depends", "maintainer", "reinstall", "overwrite", "downgrade", "space", "postinstall", "remove", "checksum", "removal-of-dependent-packages"} } , 145 | update_cache = { default = "no", aliases={ "update-cache" }, type='bool' }, 146 | cache_valid_time = { type='int' } 147 | }) 148 | 149 | local opkg_path = module:get_bin_path('opkg', true, {'/bin'}) 150 | 151 | module:parse(arg[1]) 152 | 153 | local p = module:get_params() 154 | 155 | update_cache_if_needed(module, opkg_path) 156 | 157 | local state = p["state"] 158 | local packages = p["name"] 159 | if "present" == state or "installed" == state then 160 | install_packages(module, opkg_path, packages) 161 | elseif "absent" == state or "removed" == state then 162 | remove_packages(module, opkg_path, packages) 163 | end 164 | end 165 | 166 | main(arg) 167 | -------------------------------------------------------------------------------- /src/ping.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | 5 | function main(arg) 6 | local module = Ansible.new({ 7 | data = { default="pong" }, 8 | }) 9 | 10 | module:parse(arg[1]) 11 | 12 | local p = module:get_params() 13 | 14 | local data = p["data"] 15 | 16 | if "crash" == data then 17 | module:fail_json({ msg="boom" }) 18 | else 19 | module:exit_json({ changed=false, ping=data }) 20 | end 21 | end 22 | 23 | main(arg) 24 | -------------------------------------------------------------------------------- /src/slurp.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local base64 = require("base64") 5 | 6 | function main(arg) 7 | local module = Ansible.new({ 8 | src = { required=true, type="path", aliases={"path"} }, 9 | }) 10 | 11 | module:parse(arg[1]) 12 | 13 | local source = module:get_params()["src"] 14 | local content = module:slurp(source) 15 | local encoded = base64.encode(content) 16 | 17 | module:exit_json({content=encoded, source=source, encoding='base64'}) 18 | end 19 | 20 | main(arg) 21 | -------------------------------------------------------------------------------- /src/stat.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local File = require("fileutils") 5 | local stat = require("posix.sys.stat") 6 | local errno = require("posix.errno") 7 | local bm = require("BinDecHex") 8 | local stdlib = require("posix.stdlib") 9 | local unistd = require("posix.unistd") 10 | local pwd = require("posix.pwd") 11 | local grp = require("posix.grp") 12 | 13 | local function tohex(int) 14 | return bm.Dec2Hex(string.format("%d", int)) 15 | end 16 | 17 | local function S_IMODE(mode) 18 | -- man 2 stat 19 | -- "... and the least significant 9 bits (0777) as the file permission bits" 20 | return tonumber(bm.Hex2Dec(bm.BMAnd(tohex(mode), tohex(0x1ff)))) 21 | end 22 | 23 | local function boolmask(mode, mask) 24 | local masked = tonumber(bm.Hex2Dec(bm.BMAnd(tohex(mode), tohex(mask)))) 25 | 26 | if 0 == masked then 27 | return false 28 | else 29 | return true 30 | end 31 | end 32 | 33 | function main(arg) 34 | local module = Ansible.new( 35 | { path = { required=true, type='path' } 36 | , follow = { default=false, type='bool' } 37 | , get_md5 = { default=true, type='bool'} 38 | , get_checksum = { default=true, type='bool' } 39 | , checksum_algorithm = { default='sha1', type='str', choices={'sha1'}, aliases={'checksum_algo', 'checksum'}} 40 | } 41 | ) 42 | 43 | module:parse(arg[1]) 44 | 45 | local p = module:get_params() 46 | 47 | local path = p['path'] 48 | local follow = p['follow'] 49 | local get_md5 = p['get_md5'] 50 | local get_checksum = p['get_checksum'] 51 | local checksum_algorithm = p['checksum_algorithm'] 52 | 53 | local st, err, rc 54 | if follow then 55 | st, err, rc = stat.stat(path) 56 | else 57 | st, err, rc = stat.lstat(path) 58 | end 59 | 60 | if not st then 61 | if rc == errno.ENOENT then 62 | d = { exists=false } 63 | module:exit_json({msg="No such file exists", changed=false, stat=d}) 64 | end 65 | 66 | module:fail_json({msg=err}) 67 | end 68 | 69 | mode = st['st_mode'] 70 | 71 | -- back to ansible 72 | d = { 73 | exists = true 74 | , path = path 75 | , mode = string.format("%04o", S_IMODE(mode)) 76 | , isdir = stat.S_ISDIR(mode) 77 | , ischr = stat.S_ISCHR(mode) 78 | , isblk = stat.S_ISBLK(mode) 79 | , isreg = stat.S_ISREG(mode) 80 | , isfifo = stat.S_ISFIFO(mode) 81 | , islnk = stat.S_ISLNK(mode) 82 | , issock = stat.S_ISSOCK(mode) 83 | , uid = st['st_uid'] 84 | , gid = st['st_gid'] 85 | , size = st['st_size'] 86 | , inode = st['st_ino'] 87 | , dev = st['st_dev'] 88 | , nlink = st['st_nlink'] 89 | , atime = st['st_atime'] 90 | , mtime = st['st_mtime'] 91 | , ctime = st['ctime'] 92 | , wusr = boolmask(mode, stat.S_IWUSR) 93 | , rusr = boolmask(mode, stat.S_IRUSR) 94 | , xusr = boolmask(mode, stat.S_IXUSR) 95 | , wgrp = boolmask(mode, stat.S_IWGRP) 96 | , rgrp = boolmask(mode, stat.S_IRGRP) 97 | , xgrp = boolmask(mode, stat.S_IXGRP) 98 | , woth = boolmask(mode, stat.S_IWOTH) 99 | , roth = boolmask(mode, stat.S_IROTH) 100 | , xoth = boolmask(mode, stat.S_IXOTH) 101 | , isuid = boolmask(mode, stat.S_ISUID) 102 | , isgid = boolmask(mode, stat.S_ISGID) 103 | } 104 | 105 | if 0 ~= d['islnk'] then 106 | d['lnk_source'] = stdlib.realpath(path) 107 | end 108 | 109 | if 0 ~= d['isreg'] and get_md5 and 0 == unistd.access(path, "r") then 110 | d['md5'] = File.md5(module, path) 111 | end 112 | 113 | if 0 ~= d['isreg'] and get_checksum and 0 == unistd.access(path, "r") then 114 | local chksums = { sha1=File.sha1 } 115 | d['checksum'] = chksums[p['checksum_algorithm']](module, path) 116 | end 117 | 118 | local pw = pwd.getpwuid(st['st_uid']) 119 | d['pw_name'] = pw['pw_name'] 120 | 121 | local grp_info = grp.getgrgid(st['st_gid']) 122 | d['gr_name'] = grp_info['gr_name'] 123 | 124 | d['mime_type'] = 'unknown' 125 | d['charset'] = 'unknown' 126 | 127 | module:exit_json({msg="Stat successful", changed=false, stat=d}) 128 | end 129 | 130 | main(arg) 131 | -------------------------------------------------------------------------------- /src/ubus.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local ubus = require("ubus") 5 | local json = require("dkjson") 6 | 7 | function list(module) 8 | check_parameters(module, {"path"}) 9 | local path = module:get_params()['path'] 10 | 11 | local conn = module:ubus_connect() 12 | 13 | local list = {} 14 | 15 | local namespaces = conn:objects() 16 | if not namespaces then 17 | module:fail_json({msg="Failed to enumerate ubus"}) 18 | end 19 | 20 | for _, n in ipairs(namespaces) do 21 | if not path or Ansible.contains(n, path) then 22 | local signatures = conn:signatures(n) 23 | if not signatures then 24 | module:fail_json({msg="Failed to enumerate ubus"}) 25 | end 26 | list[n] = signatures 27 | end 28 | end 29 | 30 | conn:close() 31 | module:exit_json({msg="Gathered local signatures", signatures=list}) 32 | end 33 | 34 | function call(module) 35 | check_parameters(module, {"path", "method", "message"}) 36 | local p = module:get_params() 37 | local path = p["path"] 38 | if 1 ~= #path then 39 | module:fail_json({msg="Call only allows one path element, but zero or 2+ were given"}) 40 | else 41 | path = path[1] 42 | end 43 | 44 | local conn = module:ubus_connect() 45 | local res = module:ubus_call(conn, path, p['method'], p['message']) 46 | 47 | conn:close() 48 | module:exit_json({msg=string.format("Called %s.%s(%s)", path, p['method'], json.encode(p['message'])), result=res, changed=true}) 49 | end 50 | 51 | function send(module) 52 | -- - send [] Send an event 53 | check_parameters(module, {"type", "message"}) 54 | local p = module:get_params() 55 | 56 | local conn = module:ubus_connect() 57 | 58 | local res, status = conn:send(p["type"], p["message"]) 59 | if not res then 60 | module:fail_json({msg="Failed to send event", status=status}) 61 | end 62 | 63 | conn:close() 64 | module:exit_json({msg="Event sent successfully", result=res, changed=true}) 65 | end 66 | 67 | function facts(module) 68 | check_parameters(module, {}) 69 | 70 | local conn = module:ubus_connect() 71 | 72 | local facts = {} 73 | 74 | local namespaces = conn:objects() 75 | for _,n in ipairs(namespaces) do 76 | if "network.device" == n 77 | or 1 == string.find(n, "network.interface.") 78 | or "network.wireless" == n then 79 | facts[n] = module:ubus_call(conn, n, "status", {}) 80 | elseif "service" == n then 81 | -- list {} 82 | facts[n] = module:ubus_call(conn, n, "list", {}) 83 | elseif "system" == n then 84 | -- board {} 85 | -- info {} 86 | local f = {} 87 | f["board"] = module:ubus_call(conn, n, "board", {}) 88 | f["info"] = module:ubus_call(conn, n, "info", {}) 89 | facts[n] = f 90 | elseif "uci" == n then 91 | -- configs {} 92 | -- foreach configs... 93 | local f = {} 94 | local configs = module:ubus_call(conn, n, "configs", {})['configs'] 95 | f["configs"] = configs 96 | f["state"] = {} 97 | 98 | for _,conf in ipairs(configs) do 99 | -- TODO: transform unnamed sections to their anonymous names 100 | f["state"][conf] = module:ubus_call( conn, n, "state", {config=conf})['values'] 101 | end 102 | facts[n] = f 103 | end 104 | end 105 | 106 | conn:close() 107 | 108 | module:exit_json({msg="All available facts gathered", ansible_facts=facts}) 109 | end 110 | 111 | function check_parameters(module, valid) 112 | local p = module:get_params() 113 | local i = 0 114 | for k,_ in pairs(p) do 115 | -- not a buildin command and not a valid entry 116 | if 1 ~= string.find(k, "_ansible") 117 | and k ~= "socket" 118 | and k ~= "timeout" 119 | and k ~= "command" then 120 | 121 | i = i+1 122 | 123 | if((not Ansible.contains(k, valid))) then 124 | module:fail_json({msg=string.format("Parameter %q invalid for command %s", k, p['command'])}) 125 | end 126 | end 127 | end 128 | 129 | return i 130 | end 131 | 132 | function main(arg) 133 | -- module models the ubus cli tools structure 134 | -- Usage: ubus [] [arguments...] 135 | -- Options: 136 | -- -s : Set the unix domain socket to connect to 137 | -- -t : Set the timeout (in seconds) for a command to complete 138 | -- -S: Use simplified output (for scripts) 139 | -- -v: More verbose output 140 | -- 141 | -- Commands: 142 | -- - list [] List objects 143 | -- - call [] Call an object method 144 | -- - send [] Send an event 145 | 146 | local module = Ansible.new({ 147 | command = { aliases = {"cmd"}, required=true , choices={"list", "call", "send", "facts"}}, 148 | path = { type="list" }, 149 | method = { type="str" }, 150 | type = { type="str" }, 151 | message = { type="jsonarg" }, 152 | socket = { type="path" }, 153 | timeout = { type="int"} 154 | }) 155 | 156 | module:parse(arg[1]) 157 | 158 | local p = module:get_params() 159 | 160 | local dispatcher = { 161 | list = list, 162 | call = call, 163 | send = send, 164 | facts = facts 165 | } 166 | 167 | dispatcher[p['command']](module) 168 | end 169 | 170 | main(arg) 171 | -------------------------------------------------------------------------------- /src/uci.lua: -------------------------------------------------------------------------------- 1 | #!/usr/bin/lua 2 | 3 | local Ansible = require("ansible") 4 | local ubus = require("ubus") 5 | 6 | function reload_configs(module) 7 | local conn = module:ubus_connect() 8 | 9 | local res = module:ubus_call(conn, "uci", "reload_config", {}) 10 | 11 | conn:close() 12 | module:exit_json({msg="Configs reloaded", result=res}) 13 | end 14 | 15 | function get_configs(module) 16 | local conn = module:ubus_connect() 17 | 18 | local res = module:ubus_call(conn, "uci", "configs", {}) 19 | 20 | conn:close() 21 | module:exit_json({msg="Configs fetched", result=res}) 22 | end 23 | 24 | function docommit(module, conn, config) 25 | local conf, sec = check_config(module, conn, config, nil) 26 | 27 | local res = module:ubus_call(conn, "uci", "commit", {config=conf}) 28 | 29 | return res 30 | end 31 | 32 | function commit(module) 33 | local conn = module:ubus_connect() 34 | local path = module:get_params()["name"] 35 | 36 | local configs 37 | if path == nil then 38 | local conf = module:ubus_call(conn, "uci", "configs", {}) 39 | configs = conf['configs'] 40 | else 41 | if path["option"] or path["section"] then 42 | module:fail_json({msg="Only whole configs can be committed"}) 43 | end 44 | 45 | configs = { path["config"] } 46 | end 47 | 48 | local res = {} 49 | for _, conf in ipairs(configs) do 50 | res[#res + 1] = docommit(module, conn, conf) 51 | end 52 | 53 | module:exit_json({msg="Committed all changes for " .. #configs .. " configurations", changed=true, result=res}) 54 | end 55 | 56 | function get(module) 57 | local conn = module:ubus_connect() 58 | local p = module:get_params() 59 | local path = p["name"] 60 | 61 | local msg = {config=path["config"]} 62 | if p["match"] ~= nil then 63 | msg["match"] = p["match"] 64 | end 65 | if p["type"] ~= nil then 66 | msg["type"] = p["type"] 67 | end 68 | if path["section"] ~= nil then 69 | msg["section"] = path["section"] 70 | end 71 | 72 | local res = module:ubus_call(conn, "uci", "get", msg) 73 | 74 | module:exit_json({msg="Got config", changed=false, result=res}) 75 | end 76 | 77 | function revert(module) 78 | local conn = module:ubus_connect() 79 | local path = module:get_params()["name"] 80 | 81 | local configs 82 | if path == nil then 83 | local conf = module:ubus_call(conn, "uci", "configs", {}) 84 | configs = conf['configs'] 85 | else 86 | local conf, sec = check_config(module, conn, path["config"], nil) 87 | configs = { conf } 88 | end 89 | 90 | local res = {} 91 | for _, conf in ipairs(configs) do 92 | res[#res + 1] = module:ubus_call(conn, "uci", "revert", {config=conf}) 93 | end 94 | 95 | module:exit_json({msg="Successfully reverted all staged changes for " .. #configs .. " configurations", changed=true, result=res}) 96 | end 97 | 98 | function parse_path(module) 99 | local path = module:get_params()['name'] 100 | -- a path consists of config.section.option 101 | 102 | -- lua's pattern engine does not seem to be expressive enough to do this in one go 103 | local config, section, option 104 | if string.match(path, "([^.]+)%.([^.]+)%.([^.]+)") then 105 | config, section, option = string.match(path, "([^.]+)%.([^.]+)%.([^.]+)") 106 | elseif string.match(path, "([^.]+)%.([^.]+)") then 107 | config, section = string.match(path, "([^.]+)%.([^.]+)") 108 | else 109 | config = path 110 | end 111 | 112 | local pathobject = {config=config, section=section, option=option} 113 | return pathobject 114 | end 115 | 116 | function query_value(module, conn, path, unique) 117 | local res = conn:call("uci", "get", path) 118 | 119 | if nil == res then 120 | return nil 121 | end 122 | 123 | if unique and nil ~= res["values"] then 124 | module:fail_json({msg="Path specified is amiguos and matches multiple options", path=path, result=res}) 125 | end 126 | 127 | if res["values"] then 128 | return res["values"] 129 | else 130 | return res["value"] 131 | end 132 | end 133 | 134 | function check_config(module, conn, config, section) 135 | local res = module:ubus_call(conn, "uci", "configs", {}) 136 | 137 | if not module.contains(config, res["configs"]) then 138 | module:fail_json({msg="Invalid config " .. config}) 139 | end 140 | 141 | if nil ~= section then 142 | res = module:ubus_call(conn, "uci", "get", {config=config, section=section}) 143 | if res and res["values"] and res["values"][".type"] then 144 | return config, section 145 | end 146 | end 147 | 148 | return config, nil 149 | end 150 | 151 | function compare_tables(a, b) 152 | if a == nil or b == nil then 153 | return a == b 154 | end 155 | 156 | if type(a) ~= "table" then 157 | if type(b) ~= "table" then 158 | return a == b 159 | end 160 | return false 161 | end 162 | if #a ~= #b then 163 | return false 164 | end 165 | -- level 1 compare 166 | table.sort(a) 167 | table.sort(b) 168 | for i,v in ipairs(a) do 169 | if v ~= b[i] then 170 | return false 171 | end 172 | end 173 | 174 | return true 175 | end 176 | 177 | function set_value(module) 178 | local p = module:get_params() 179 | local path = p["name"] 180 | 181 | local conn = module:ubus_connect() 182 | 183 | local conf, sec = check_config(module, conn, path["config"], path["section"]) 184 | 185 | local target = p["value"] 186 | local forcelist = p["forcelist"] 187 | 188 | if type(target) == "table" and #target == 1 and not forcelist then 189 | target = target[1] 190 | end 191 | 192 | local values = {} 193 | if path["option"] then 194 | values[path["option"]] = target 195 | end 196 | 197 | local res 198 | if nil ~= p["match"] then 199 | local preres = module:ubus_call(conn, "uci", "changes", {config=conf}) 200 | local prechanges = preres["changes"] or {} 201 | 202 | local message = { 203 | config=conf, 204 | values=p["values"], 205 | match=p["match"] 206 | } 207 | res = module:ubus_call(conn, "uci", "set", message) or {} 208 | 209 | -- Since 'uci changes' returns changes in the order they were made, 210 | -- determine what the 'set' command changed by stripping off the 211 | -- first #prechanges entries from the postchanges. 212 | local postres = module:ubus_call(conn, "uci", "changes", {config=conf}) 213 | local postchanges = postres["changes"] or {} 214 | for i = #prechanges, 1, -1 do 215 | table.remove(postchanges, i) 216 | end 217 | res["changes"] = postchanges 218 | 219 | conn:close() 220 | if #postchanges > 0 then 221 | module:exit_json({msg="Changes made", changed=true, result=res}) 222 | end 223 | module:exit_json({msg="No changes made", changed=false, result=res}) 224 | elseif not sec then 225 | -- We have to create a section and use "uci add" 226 | if not p["type"] then 227 | module:fail_json({msg="when creating sections, a type is required", message=message}) 228 | end 229 | 230 | local message = { 231 | config=conf, 232 | name=path["section"], 233 | type=p["type"], 234 | } 235 | 236 | if path["option"] then 237 | message["values"]=values 238 | end 239 | 240 | res = module:ubus_call(conn, "uci", "add", message) 241 | 242 | elseif not compare_tables(target, query_value(module, conn, path, true)) then 243 | -- We have to take actions and use "uci set" 244 | local message = { 245 | config=conf, 246 | section=sec, 247 | values=values 248 | } 249 | res = module:ubus_call(conn, "uci", "set", message) 250 | else 251 | conn:close() 252 | module:exit_json({msg="Value already set", changed=false, result=res}) 253 | end 254 | 255 | 256 | local autocommit = false 257 | if p["autocommit"] then 258 | autocommit = true 259 | docommit(module, conn, conf) 260 | end 261 | 262 | conn:close() 263 | module:exit_json({msg="Value successfully set", changed=true, autocommit=autocommit, result=res}) 264 | end 265 | 266 | function unset_value(module) 267 | local p = module:get_params() 268 | local path = p["name"] 269 | 270 | local conn = module:ubus_connect() 271 | 272 | local conf, sec = check_config(module, conn, path["config"], path["section"]) 273 | 274 | -- the whole section is already gone 275 | if nil == sec then 276 | -- already absent 277 | conn:close() 278 | module:exit_json({msg="Section already absent", changed=false}) 279 | end 280 | 281 | -- and nil ~= sec... 282 | local message = { 283 | config=conf, 284 | section=sec 285 | } 286 | 287 | -- check if we have got a option 288 | if path["option"] then 289 | local is = query_value(module, conn, path, false) 290 | if not is then 291 | conn:close() 292 | module:exit_json({msg="Option already absent", changed=false}) 293 | end 294 | 295 | message["option"] = path["option"] 296 | end 297 | 298 | 299 | local res = module:ubus_call(conn, "uci", "delete", message) 300 | 301 | local autocommit = false 302 | if p["autocommit"] then 303 | local autocommit = true 304 | docommit(module, conn, conf) 305 | end 306 | 307 | conn:close() 308 | module:exit_json({msg="Section successfully deleted", changed=true, autocommit=autocommit, result=res}) 309 | end 310 | 311 | function check_parameters(module) 312 | local p = module:get_params() 313 | 314 | -- Validate the path 315 | if p["name"] then 316 | p["name"] = parse_path(module, p["name"]) 317 | end 318 | 319 | -- op requires that no state is given, configs does not take any parameter 320 | if p["op"] then 321 | -- all operands do not take a state or value parameter 322 | if p["value"] then 323 | module:fail_json({msg="op=* do not work with 'state','value' or 'autocommit' arguments"}) 324 | end 325 | 326 | -- config does not take a path parameter 327 | if "configs" == p["op"] and p["name"] then 328 | module:fail_json({msg="'op=config' does not take a 'path' argument"}) 329 | end 330 | else 331 | -- in the normal case name and state are required 332 | if (not p["name"]) 333 | or (not p["state"]) then 334 | module:fail_json({msg="Both name and state are required to set/unset values"}) 335 | end 336 | 337 | -- when performing an "uci set", a value is required 338 | if ("set" == p["state"] or "present" == p["state"]) then 339 | if p["name"]["option"] and not p["value"] then -- Setting a regular value 340 | module:fail_json({msg="When using 'uci set', a value is required"}) 341 | elseif not p["name"]["option"] and not p["type"] and not p["match"] then -- Creating a section 342 | module:fail_json({msg="When creating sections with 'uci set', a type is required"}) 343 | end 344 | end 345 | 346 | if nil ~= p["value"] and ("unset" == p["state"] or "absent" == p["state"]) then 347 | module:fail_json({msg="When deleting options, no value can be set"}) 348 | end 349 | 350 | if nil ~= p["forcelist"] and ("unset" == p["state"] or "absent" == p["state"]) then 351 | module:fail_json({msg="'forcelist' only applies to set operations"}) 352 | end 353 | end 354 | 355 | end 356 | 357 | function main(arg) 358 | local module = Ansible.new({ 359 | name = { aliases = {"path", "key"}, type="str"}, 360 | value = { type="list" }, 361 | state = { default="present", choices={"present", "absent", "set", "unset"} }, 362 | op = { choices={"configs", "commit", "revert", "get"} }, 363 | reload = { aliases = {"reload_configs", "reload-configs"}, type='bool'}, 364 | autocommit = { default=true, type="bool" }, 365 | forcelist = { default=false, type="bool" }, 366 | type = { aliases = {"section-type"}, type="str" }, 367 | socket = { type="path" }, 368 | timeout = { type="int"}, 369 | match = { type="dict"}, 370 | values = { type="dict"} 371 | }) 372 | 373 | module:parse(arg[1]) 374 | check_parameters(module) 375 | 376 | local p = module:get_params() 377 | 378 | if p["reload"] then 379 | reload_configs(module) 380 | end 381 | 382 | -- Execute operation 383 | if "configs" == p["op"] then 384 | get_configs(module) 385 | elseif "commit" == p["op"] then 386 | commit(module) 387 | elseif "revert" == p["op"] then 388 | revert(module) 389 | elseif "get" == p["op"] then 390 | get(module) 391 | else 392 | -- If no op was given, simply enforce the setting state 393 | local state = p["state"] 394 | local doset = true 395 | if "absent" == state or "unset" == state then 396 | doset = false 397 | elseif "present" ~= state and "set" ~= state then 398 | module:fail_json({msg="Set state must be one of set, present, unset, absent"}) 399 | end 400 | 401 | -- check if a full path was specified 402 | local path = p["name"] 403 | if not path["config"] then 404 | module:fail_json({msg="Set operation requires a path"}) 405 | end 406 | if not path["section"] then 407 | if doset and not p["type"] and not p["match"] then 408 | module:fail_json({msg="Set operation requires a type, a match, or a path of" 409 | .. " the form '.
[.