Compare commits
	
		
			889 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | b5603a5233 | ||
|  | 73890f553c | ||
|  | f6243b8968 | ||
|  | 3770dc74d4 | ||
|  | 45f4e947c5 | ||
|  | 9928d7c6e1 | ||
|  | ae7c0e9195 | ||
|  | e90b640602 | ||
|  | ba7529d3f5 | ||
|  | 34667f252e | ||
|  | d18bddcb7b | ||
|  | 96dff49d33 | ||
|  | b389728338 | ||
|  | cdc7da86f3 | ||
|  | 4745cc0378 | ||
|  | 434f132479 | ||
|  | fb0f31ffc7 | ||
|  | bb1d73c0ae | ||
|  | 0e823d1191 | ||
|  | 48f4199ff3 | ||
|  | eaf379587b | ||
|  | 672446b7d1 | ||
|  | dfe52c1b07 | ||
|  | d63df03ad8 | ||
|  | aba4f9f2ce | ||
|  | ac5c1e7803 | ||
|  | d521dbf50e | ||
|  | f210ed3e6a | ||
|  | df3cac4ea6 | ||
|  | f778c5175b | ||
|  | 6c66ff28dd | ||
|  | d5b6ec702b | ||
|  | c62a5fcef2 | ||
|  | 59c47e9200 | ||
|  | 4ba44d8932 | ||
|  | 27dae05e1b | ||
|  | a251ae9b90 | ||
|  | 7e960b2bde | ||
|  | 5df4825158 | ||
|  | 8984d06d93 | ||
|  | eed7aac047 | ||
|  | 54b068de4a | ||
|  | f0f33b00b6 | ||
|  | 1043405088 | ||
|  | 0131b10805 | ||
|  | a19b441f62 | ||
|  | 28edc31d43 | ||
|  | 0f9872a818 | ||
|  | 76ce4296f3 | ||
|  | 3dd2671380 | ||
|  | 298ca31332 | ||
|  | 8f911aa6b9 | ||
|  | 82a5c7d9b1 | ||
|  | 7f013dcdba | ||
|  | 68e2e16076 | ||
|  | ea23c763c9 | ||
|  | 5dcecb3206 | ||
|  | 5bd48e2d0e | ||
|  | afd0a02589 | ||
|  | 2379192d53 | ||
|  | a6489290c8 | ||
|  | 5f74c43415 | ||
|  | aa8b84a302 | ||
|  | b987d041b0 | ||
|  | b62e37307e | ||
|  | 61a59aa6ac | ||
|  | f79ec27f1d | ||
|  | b993fe380f | ||
|  | d974b5f55f | ||
|  | f21ae93197 | ||
|  | 342ff18be8 | ||
|  | a8236f69bf | ||
|  | ab15a2448d | ||
|  | 6ff4d8f558 | ||
|  | bb04ba528c | ||
|  | b94a795189 | ||
|  | 9968184733 | ||
|  | 1be6f8f87a | ||
|  | 426821cceb | ||
|  | 4fec0deaf7 | ||
|  | 144ac5b6ce | ||
|  | 97c73786fa | ||
|  | 82e59d7da0 | ||
|  | b2c10de6af | ||
|  | d72029c2c6 | ||
|  | 17b9987063 | ||
|  | fde07da2b7 | ||
|  | c23bc29511 | ||
|  | 714cad2a52 | ||
|  | 357d5d2fde | ||
|  | d477cce901 | ||
|  | eb6af52ad1 | ||
|  | aae75023a7 | ||
|  | 41dcd4f458 | ||
|  | 4651ae4495 | ||
|  | ed61e0b0fc | ||
|  | 1eefc6fbf4 | ||
|  | 09ebf2cea2 | ||
|  | b3b0c4cd65 | ||
|  | f4b7924e8f | ||
|  | ea68d38b82 | ||
|  | dfbaa71132 | ||
|  | 6c328deb08 | ||
|  | add564d5bf | ||
|  | fa94acb426 | ||
|  | 6827468f13 | ||
|  | 53fd43868f | ||
|  | 9ced7561c5 | ||
|  | 31d55d3425 | ||
|  | 171d2a5bb9 | ||
|  | c5d05c1205 | ||
|  | 2973e0559a | ||
|  | ec27288dcf | ||
|  | f92e5c7093 | ||
|  | 7c67155c49 | ||
|  | b102cd4652 | ||
|  | 67f9a48c37 | ||
|  | a0c8a1ee65 | ||
|  | 7e7d272b06 | ||
|  | 3c642240ae | ||
|  | b5157fcaf1 | ||
|  | d1cb42f1bc | ||
|  | 84cde1a16a | ||
|  | 877f5db1ce | ||
|  | 787164e245 | ||
|  | d77fc5e7c5 | ||
|  | cca39a67d6 | ||
|  | a6c9a0431a | ||
|  | 729a80a639 | ||
|  | 31cb3001f6 | ||
|  | 5d0f54a329 | ||
|  | c8c3f5b5b7 | ||
|  | ba473ed75a | ||
|  | 7236fd59f8 | ||
|  | 9471e8f1fd | ||
|  | a2d39b51bb | ||
|  | 2920934b55 | ||
|  | 3f709d448e | ||
|  | b79f66183f | ||
|  | 8672f57e55 | ||
|  | 1e99c82351 | ||
|  | 1a2ff851f3 | ||
|  | f1c27c3959 | ||
|  | b30dac0f15 | ||
|  | cc79e5cdaf | ||
|  | d9a3b2f2cb | ||
|  | 479b528d09 | ||
|  | 461fb84fb9 | ||
|  | bd7685e3fa | ||
|  | cd98cb64b3 | ||
|  | 0f32a3ec24 | ||
|  | ca446cac87 | ||
|  | 6ea907ffda | ||
|  | 5287baa70d | ||
|  | 25935fec84 | ||
|  | e855a063ff | ||
|  | c726b8c9f0 | ||
|  | 13cb99290e | ||
|  | cea9413fd1 | ||
|  | 1432853b39 | ||
|  | 6d6c2b86e8 | ||
|  | 77b1d964b5 | ||
|  | 549936fc09 | ||
|  | c9c32f09c5 | ||
|  | 77f7778d4a | ||
|  | 84b6be9364 | ||
|  | 1e43b55804 | ||
|  | ba9bdaae0a | ||
|  | 7dfd7bde8e | ||
|  | 5e6c4161d0 | ||
|  | d75d56dfc9 | ||
|  | 1d9d350091 | ||
|  | 5744053c6f | ||
|  | 65589b6ca2 | ||
|  | e03a9d1137 | ||
|  | 29f80f2276 | ||
|  | a9b74aa69b | ||
|  | 63ebfd3210 | ||
|  | 87fa5ff7a6 | ||
|  | b686b53a9c | ||
|  | 258261dc64 | ||
|  | 9af5c9ead9 | ||
|  | 382654188c | ||
|  | fa1df082b7 | ||
|  | 5c227d8f80 | ||
|  | 81dabdbfb7 | ||
|  | 91f89f5a33 | ||
|  | 9f92746aa0 | ||
|  | 5d6e6f9441 | ||
|  | 01395a2726 | ||
|  | 465d75c65d | ||
|  | 4634f8927e | ||
|  | 74a287f9fe | ||
|  | 7ff6c79835 | ||
|  | 3629982237 | ||
|  | ddb610f1bc | ||
|  | f899905d27 | ||
|  | 3e4531b5c5 | ||
|  | a9e189e51d | ||
|  | 58ba08a8f3 | ||
|  | 9078ff27d8 | ||
|  | 6f43e61c24 | ||
|  | 4be0d3f212 | ||
|  | 00e47e5a27 | ||
|  | 152e145b32 | ||
|  | 54e55e8f57 | ||
|  | 05b8707f9e | ||
|  | 543e952023 | ||
|  | 6e5f40ea06 | ||
|  | bbafb0be87 | ||
|  | 1c9c5232fe | ||
|  | 598d79a502 | ||
|  | 37d8360b77 | ||
|  | 82d9ca3317 | ||
|  | 4e4238d486 | ||
|  | c77dbe44dc | ||
|  | e03737f15f | ||
|  | a02629bcd7 | ||
|  | 6c3fc23d78 | ||
|  | 0fe40f9ccb | ||
|  | 9bd7c8edd1 | ||
|  | 83ba480863 | ||
|  | f158ea25e9 | ||
|  | 0227519eab | ||
|  | 616a9685fa | ||
|  | fe61b01320 | ||
|  | 7b25144311 | ||
|  | 9d42fbbdd7 | ||
|  | 39ac5b088b | ||
|  | c14ffd08a0 | ||
|  | 6e1239340b | ||
|  | a297dc8b3b | ||
|  | 8d4ecc0898 | ||
|  | eae9c04429 | ||
|  | a41c48a9c5 | ||
|  | ff2a94bd9b | ||
|  | 4a1f5558b8 | ||
|  | 608db9889f | ||
|  | 012b697337 | ||
|  | 0580506cf3 | ||
|  | ff4ab9b661 | ||
|  | b7ce5fdd3e | ||
|  | a11e617322 | ||
|  | d0beac7e2b | ||
|  | 9db497092f | ||
|  | 8eb91c08aa | ||
|  | ded5437522 | ||
|  | 9348657951 | ||
|  | bca85933f7 | ||
|  | c32bb35f1c | ||
|  | 4b84062d62 | ||
|  | d6d0f8fa17 | ||
|  | dd72c875d3 | ||
|  | 1a1df50300 | ||
|  | 53cbb527b4 | ||
|  | 8b87b2717e | ||
|  | 1007d6dac7 | ||
|  | 6799fac120 | ||
|  | 558e6288ca | ||
|  | d9cb73291b | ||
|  | d0f7be3ac3 | ||
|  | 331e16d3ca | ||
|  | 0db246c311 | ||
|  | 94dc62ff58 | ||
|  | e68ecf6844 | ||
|  | 5167b0a8c6 | ||
|  | 77e3d3786d | ||
|  | 708d4d39bc | ||
|  | 2a8cda2a1e | ||
|  | 8d783840ad | ||
|  | abe39d5790 | ||
|  | d7868e9e5a | ||
|  | 7b84e36e15 | ||
|  | 6cab6d69d8 | ||
|  | 87846d7aef | ||
|  | 2557769c6a | ||
|  | 48375f3878 | ||
|  | 176c85d8c1 | ||
|  | 17cad71ede | ||
|  | e8bf9d4e6f | ||
|  | 7bdd2038ef | ||
|  | e9f6e7943a | ||
|  | e74ba387ab | ||
|  | 27c79e5b99 | ||
|  | 8170d5ea73 | ||
|  | 196f73705d | ||
|  | ad0bbf5248 | ||
|  | 4cae9cd90d | ||
|  | be7bc55a76 | ||
|  | 684b545e8f | ||
|  | 7835cc3b10 | ||
|  | f8706b51e8 | ||
|  | d97f8fd5da | ||
|  | f8fa87441e | ||
|  | d42537814a | ||
|  | 792421b0e2 | ||
|  | 72d55a010b | ||
|  | 880d8258ce | ||
|  | b79bf82efb | ||
|  | b3118b6253 | ||
|  | ba172e2e25 | ||
|  | 892d53abeb | ||
|  | 5cbaa1ce98 | ||
|  | 7b35d9ad2e | ||
|  | 8462de7911 | ||
|  | 8721f44298 | ||
|  | c7a2d69afa | ||
|  | 0453d81e7a | ||
|  | 501c04ac2b | ||
|  | 0ef4e9a5c3 | ||
|  | 129c50e598 | ||
|  | 3e276fc2ac | ||
|  | 658d5e05ae | ||
|  | 4e7d5d476e | ||
|  | 6a55ca20f3 | ||
|  | c56c537f7f | ||
|  | fd7d776121 | ||
|  | 1af28190d8 | ||
|  | 6b305be567 | ||
|  | 3bf70513b7 | ||
|  | 7e64404654 | ||
|  | e1b5226f34 | ||
|  | 0d7128ad31 | ||
|  | 5778626087 | ||
|  | 3ff48756ed | ||
|  | 0ce9a6eeba | ||
|  | ad527b4aed | ||
|  | 6633bb452e | ||
|  | efeb0b4feb | ||
|  | 8cc11fc102 | ||
|  | ee6a167220 | ||
|  | 8d4ad3c405 | ||
|  | 072fbf4d60 | ||
|  | 727c41c283 | ||
|  | e2266838b6 | ||
|  | 775762d615 | ||
|  | 900c3008cb | ||
|  | 09379213a6 | ||
|  | ceb97048e3 | ||
|  | 4561515517 | ||
|  | a7b285759f | ||
|  | b4531b2a12 | ||
|  | 9e1d261c76 | ||
|  | e35fa15cd2 | ||
|  | dbd1f0d4f9 | ||
|  | 9ade78b703 | ||
|  | f20e244b5f | ||
|  | 0989308b7e | ||
|  | 12c7140536 | ||
|  | 2a0b605e92 | ||
|  | 6978890e6a | ||
|  | 561abd6cb9 | ||
|  | 4dd6227f0b | ||
|  | 1ec314c31c | ||
|  | a2be5a00be | ||
|  | 4e2241c115 | ||
|  | 8459bca64a | ||
|  | 24cb0565b9 | ||
|  | 9442acb028 | ||
|  | 4f7f181a42 | ||
|  | b7dd8737a7 | ||
|  | 2207eeb727 | ||
|  | 89dad7dfe7 | ||
|  | e5803d0cf3 | ||
|  | c1fffe9ae6 | ||
|  | 9e6cbd3d32 | ||
|  | 2ea8742510 | ||
|  | 5cfa0254f9 | ||
|  | 8cd2544f78 | ||
|  | c03b768364 | ||
|  | d60481ead4 | ||
|  | 126be3827d | ||
|  | 121274dca2 | ||
|  | 0ecf8da27e | ||
|  | 4a6bcb525d | ||
|  | 83f9ee50dd | ||
|  | 2bff297f79 | ||
|  | dee68f6933 | ||
|  | afa1e19c83 | ||
|  | 6052088eb4 | ||
|  | c7fa5167c4 | ||
|  | 1034b0b146 | ||
|  | 8bcc4e5945 | ||
|  | c3c24aa1db | ||
|  | 281c75d2d2 | ||
|  | 52307420f3 | ||
|  | 6185347cd8 | ||
|  | b6cd29f77e | ||
|  | b8ea8b1567 | ||
|  | 2f7dc98830 | ||
|  | e248a99f79 | ||
|  | 4fb6d9aa5d | ||
|  | f092ea8d67 | ||
|  | c32cbbdda6 | ||
|  | 2497675259 | ||
|  | 8d084ab90a | ||
|  | 2398773ef0 | ||
|  | a05998a30e | ||
|  | f863c29194 | ||
|  | d16a98c788 | ||
|  | 9421b02e96 | ||
|  | 10256864e4 | ||
|  | 85d010615d | ||
|  | cd1cb186be | ||
|  | 4458354d70 | ||
|  | 0f27da8808 | ||
|  | dd76bfa3c2 | ||
|  | 5780a66f7d | ||
|  | d4342c034c | ||
|  | 1ec43f2530 | ||
|  | 3c300d8fdf | ||
|  | 23119b55d1 | ||
|  | c8fb0e8f8a | ||
|  | 0ec32a77ef | ||
|  | 52921bfce8 | ||
|  | 960b929097 | ||
|  | d4ce23eced | ||
|  | 6925510f44 | ||
|  | 9827ad4c22 | ||
|  | ef8aaee028 | ||
|  | 3d7d39f248 | ||
|  | 3eac620560 | ||
|  | ab17006956 | ||
|  | bfc6889ee9 | ||
|  | 0ec0b4a044 | ||
|  | f1a523f327 | ||
|  | 4181449aea | ||
|  | e192f8db52 | ||
|  | 8097c681ac | ||
|  | f45938bdd5 | ||
|  | 6ea4e97eca | ||
|  | f274c8e837 | ||
|  | 335e571485 | ||
|  | a11616aace | ||
|  | 883acadbc4 | ||
|  | f51e6a3fcf | ||
|  | 371e081c0d | ||
|  | 6f41b3bf1c | ||
|  | c1d74a6c9e | ||
|  | 24eaa6796e | ||
|  | 1521e3b620 | ||
|  | b6ff38dd62 | ||
|  | 44ea9ac03c | ||
|  | 4c2701505b | ||
|  | 9022fe18da | ||
|  | 63be349f8b | ||
|  | c40256a290 | ||
|  | 33ecb8ec52 | ||
|  | 82d62a0015 | ||
|  | 6278240526 | ||
|  | 8c2dc5f57d | ||
|  | 2e5868778a | ||
|  | a10b8dab9b | ||
|  | 92f4f7ef59 | ||
|  | 31257bd5cb | ||
|  | bb6510862f | ||
|  | 797ecf0780 | ||
|  | f9536dc67f | ||
|  | e8b95362af | ||
|  | bdc39ad4ec | ||
|  | 4a202c5585 | ||
|  | 3c6b321f73 | ||
|  | cb29b52799 | ||
|  | 7e48015a54 | ||
|  | 9ed3abf932 | ||
|  | 61762828a3 | ||
|  | 59beabe5ac | ||
|  | 0b30faa28c | ||
|  | d12d49b93f | ||
|  | f1d64d275a | ||
|  | d094eeeb03 | ||
|  | be25af658e | ||
|  | 794f52c229 | ||
|  | 5d4dc4ed4c | ||
|  | e49d97b898 | ||
|  | b6b4f1ba62 | ||
|  | 653d476716 | ||
|  | 48b855258c | ||
|  | c7efdaf5f9 | ||
|  | 22523ed3d3 | ||
|  | 33c602dd61 | ||
|  | e2a5509b76 | ||
|  | 61a0fa1a89 | ||
|  | a35bd8292b | ||
|  | 06c8ae60e3 | ||
|  | deeab1f845 | ||
|  | da81c4c987 | ||
|  | d180f1b2d5 | ||
|  | 526135629c | ||
|  | 6b9493e057 | ||
|  | 9bb33d2afc | ||
|  | 7421138533 | ||
|  | d0800c52bb | ||
|  | 913fcd4df2 | ||
|  | 83322cc725 | ||
|  | 5944501feb | ||
|  | 17e3603d3d | ||
|  | 95be43ae47 | ||
|  | feb91cbbaa | ||
|  | 79409af168 | ||
|  | 5dbfb64822 | ||
|  | 5e7ebf5e69 | ||
|  | e73215ca74 | ||
|  | a5f123b9ce | ||
|  | ac058e9675 | ||
|  | 371b764d1d | ||
|  | 66d7172e09 | ||
|  | 99d3a8a749 | ||
|  | db5ff372a4 | ||
|  | 3fe83f81be | ||
|  | 669e638fd6 | ||
|  | f1f999f3b6 | ||
|  | 6f3b6fa9ce | ||
|  | 938f945301 | ||
|  | e3efb2aad6 | ||
|  | 1e678c0d78 | ||
|  | a59c111140 | ||
|  | a8b2a31bed | ||
|  | 37402f9ee8 | ||
|  | e7b5ecb40f | ||
|  | c817ef04b9 | ||
|  | f52b18439c | ||
|  | 1e03c628d5 | ||
|  | 71fb39db1f | ||
|  | bcfb3726b0 | ||
|  | c6e9e29671 | ||
|  | 1bfefcce39 | ||
|  | 22488e93e1 | ||
|  | 244b89f035 | ||
|  | 1f9a241b94 | ||
|  | 03641aae42 | ||
|  | a2bdd113cc | ||
|  | a92e2f3c7b | ||
|  | 97766b3a57 | ||
|  | 9ef4c3bb06 | ||
|  | d82f0cd757 | ||
|  | 5f529e2af4 | ||
|  | beadd9e02b | ||
|  | 72543789cb | ||
|  | 5789439fa9 | ||
|  | f549126bcf | ||
|  | 7197548bad | ||
|  | 241fde783c | ||
|  | 2b872cd1f4 | ||
|  | a606fb4d1d | ||
|  | 9f9c6be38e | ||
|  | 01ee524049 | ||
|  | af9cb65338 | ||
|  | 8aa11c580b | ||
|  | ada627f444 | ||
|  | a7b6d338c3 | ||
|  | 9f00538b97 | ||
|  | a085015282 | ||
|  | 0b9c220fbb | ||
|  | 0e3d04873d | ||
|  | b7578d939f | ||
|  | b5c28de03f | ||
|  | e17d25c156 | ||
|  | c25dc1b99c | ||
|  | a493a574bd | ||
|  | 4284493dce | ||
|  | 25059de8e1 | ||
|  | 1731b05ad0 | ||
|  | e80dc663ac | ||
|  | 39988a4c2f | ||
|  | 415bff303a | ||
|  | a65eb62a54 | ||
|  | 03b2982128 | ||
|  | bff0527857 | ||
|  | f3b7634254 | ||
|  | 6a9593c0b9 | ||
|  | edb785b8e5 | ||
|  | 26d757b50a | ||
|  | 535079ee87 | ||
|  | ac380c29c1 | ||
|  | 3fd212f26c | ||
|  | 04a3abc651 | ||
|  | 6caf85ddd1 | ||
|  | 16e4071508 | ||
|  | 69e7c4324b | ||
|  | a1c4a8cbe5 | ||
|  | e37f6cfda7 | ||
|  | 989c804409 | ||
|  | 7345bc3c82 | ||
|  | 69bee35700 | ||
|  | 598e24df7c | ||
|  | 0ae669201e | ||
|  | f52a8a4642 | ||
|  | 9c40b61ef2 | ||
|  | 72dabcda83 | ||
|  | 161a06dbcc | ||
|  | 8ed3d4e70c | ||
|  | a4223ccc8a | ||
|  | ca85923855 | ||
|  | 52bfe7c493 | ||
|  | 4786bd0cbe | ||
|  | cadab160ff | ||
|  | 6a7f17b2b0 | ||
|  | 4986a4d775 | ||
|  | 903af0c2cf | ||
|  | 3282fa803c | ||
|  | 67cc47608d | ||
|  | 0411704b8b | ||
|  | 1de85b2c69 | ||
|  | 33b012f29d | ||
|  | 1357584df3 | ||
|  | e15809e271 | ||
|  | 0da1950427 | ||
|  | e590b921be | ||
|  | 09462692f5 | ||
|  | c1d1b5f762 | ||
|  | 6b9c87b858 | ||
|  | 485b6eb904 | ||
|  | 057630bdb5 | ||
|  | 6b02873b30 | ||
|  | 0fa0fc6d6b | ||
|  | 339ec07465 | ||
|  | cd2e798fea | ||
|  | d5cadbeae2 | ||
|  | 8046a3ccae | ||
|  | bf91d60b31 | ||
|  | 539c047ec8 | ||
|  | 290c18fa87 | ||
|  | 98c46f5e57 | ||
|  | f8bd5b5b4e | ||
|  | 816d32edad | ||
|  | 8453835c05 | ||
|  | 9328c356c8 | ||
|  | 89e3c1fc94 | ||
|  | 67e54cd15d | ||
|  | 278ea24786 | ||
|  | aba1662631 | ||
|  | 61eeb60c19 | ||
|  | 5e9a8f4806 | ||
|  | 4cb274e9bc | ||
|  | 8b9b1a6a35 | ||
|  | 2655964113 | ||
|  | 188bad061b | ||
|  | 3af4c329aa | ||
|  | 6c13395f7d | ||
|  | 77b32ba360 | ||
|  | 91dba291ac | ||
|  | a6bc293640 | ||
|  | 53882d6e5f | ||
|  | d68adfbf10 | ||
|  | 498a392d7f | ||
|  | 740f6c05db | ||
|  | d810ce301f | ||
|  | 5ef6a14d24 | ||
|  | a13f6f1e68 | ||
|  | d2d0f1aaee | ||
|  | e64c72cc89 | ||
|  | 9ab915a08b | ||
|  | e26fbf0328 | ||
|  | d9a52c4a2a | ||
|  | 7b2ec90de9 | ||
|  | d310bf8bbf | ||
|  | 2abc6cc939 | ||
|  | 56d4e694a2 | ||
|  | 5f002c9cdc | ||
|  | 759daf4b4a | ||
|  | 3a8d9568e3 | ||
|  | ff22a9d94a | ||
|  | a6e42d5374 | ||
|  | a2f74e0488 | ||
|  | ee44240569 | ||
|  | d0828744a2 | ||
|  | 6e2e576b29 | ||
|  | bf61e27f8a | ||
|  | c441c30b46 | ||
|  | 0e741230ea | ||
|  | 1bfe9ac2db | ||
|  | 6812e72348 | ||
|  | b6449d2f5b | ||
|  | 7e3ea20dce | ||
|  | c9d6fe9dcd | ||
|  | 4a649a6b8b | ||
|  | 8fef184963 | ||
|  | 69583ca3c0 | ||
|  | 6038a68e91 | ||
|  | fa8bd8db87 | ||
|  | 18b4f0ed0f | ||
|  | 461f9d66c9 | ||
|  | 2155103c7a | ||
|  | c9a6839c45 | ||
|  | 9fbe331a80 | ||
|  | a56389c4ce | ||
|  | 64656784cb | ||
|  | 6eff2c181e | ||
|  | 1aa48c6d62 | ||
|  | c7ca1a346d | ||
|  | fa0ec7b502 | ||
|  | 768438c136 | ||
|  | 9badea0b3c | ||
|  | 43263a1650 | ||
|  | 821e02dc75 | ||
|  | ed011ecf28 | ||
|  | d861de4c2f | ||
|  | 3a3b2449dc | ||
|  | d2614406ca | ||
|  | 0798d098ae | ||
|  | dab7ddc2bb | ||
|  | 081a96e281 | ||
|  | a7dd881d79 | ||
|  | 8134d5e24d | ||
|  | ba6756cd45 | ||
|  | 5d8fce21ac | ||
|  | e7e4a5bcd4 | ||
|  | 55f33357ea | ||
|  | 90568bba31 | ||
|  | 5d6e2dc2e4 | ||
|  | 6bb33f2559 | ||
|  | ced92554ed | ||
|  | dff3383158 | ||
|  | bf03c89cb2 | ||
|  | 9f1484bbef | ||
|  | 3899680e26 | ||
|  | 6bb2eb25a1 | ||
|  | f8dfd8edb3 | ||
|  | 042be624a3 | ||
|  | 6bafa4c79a | ||
|  | 58b42fac5c | ||
|  | 3b47b9558a | ||
|  | ccf9636296 | ||
|  | 96942719f2 | ||
|  | 69cf1c1adc | ||
|  | d77cba40b8 | ||
|  | 968735b555 | ||
|  | ceed9d29eb | ||
|  | 41329039ee | ||
|  | f68b102ca8 | ||
|  | fa36e54298 | ||
|  | b689f57435 | ||
|  | 885fa0ff56 | ||
|  | 303acb72a3 | ||
|  | b2a46cd0cd | ||
|  | 5a5ecb3ee3 | ||
|  | 60b4ab6a63 | ||
|  | e4b096a08f | ||
|  | 343f55049b | ||
|  | 6b46025261 | ||
|  | 5ea503f23e | ||
|  | ce95f9ac23 | ||
|  | c3fb87501b | ||
|  | dc6a343612 | ||
|  | 3a61053957 | ||
|  | 570129e4d4 | ||
|  | 3315c7045f | ||
|  | 5ae50e242c | ||
|  | bbcf449719 | ||
|  | aab10f7184 | ||
|  | 8d43488cb8 | ||
|  | 0a9c647e19 | ||
|  | 40db5d4aa8 | ||
|  | 9254532baa | ||
|  | 7abed47cf0 | ||
|  | 5c6ac758f7 | ||
|  | 007677962c | ||
|  | 9c4aeab64a | ||
|  | 48e6fc0efe | ||
|  | c8be713d11 | ||
|  | ae887c8648 | ||
|  | 5daac2531b | ||
|  | 68def00327 | ||
|  | 67e7976710 | ||
|  | 35747e937e | ||
|  | fb439787a4 | ||
|  | 8fa368f473 | ||
|  | c84a9d07b1 | ||
|  | 7fb46cdfc4 | ||
|  | 52985e5ddc | ||
|  | e880935dc3 | ||
|  | cc22b1bca5 | ||
|  | 49a5128918 | ||
|  | fedc7dcb44 | ||
|  | cd32b20215 | ||
|  | 15cd9832c4 | ||
|  | f25d4e4553 | ||
|  | 12d1c82b63 | ||
|  | aebe855078 | ||
|  | 3416a71ebd | ||
|  | 94b3fea528 | ||
|  | ad1a9ecca1 | ||
|  | 715accfb8a | ||
|  | a8e03c6138 | ||
|  | f69446b648 | ||
|  | eedfbe5846 | ||
|  | 153351cc9f | ||
|  | 1b1eec40a7 | ||
|  | 763877541a | ||
|  | 1fad7d72a2 | ||
|  | 51ea2ea879 | ||
|  | d77a478bf0 | ||
|  | e413c0264a | ||
|  | f88e7f898c | ||
|  | d07bd4a6db | ||
|  | fb34c099d5 | ||
|  | 1d2ee56a15 | ||
|  | 86665f7f09 | ||
|  | 0d2b4af986 | ||
|  | dc2b2eeb9f | ||
|  | e5dbb66d53 | ||
|  | 3474b1c471 | ||
|  | 3886de5b7c | ||
|  | 2b3cec06b3 | ||
|  | 8536754d14 | ||
|  | 1f36235801 | ||
|  | a4194b14f9 | ||
|  | 2dcc629d9d | ||
|  | 98ddadc6bc | ||
|  | f6e47b7383 | ||
|  | f073ddc906 | ||
|  | 3e00631925 | ||
|  | 9b7ac58562 | ||
|  | f242ddd801 | ||
|  | c129886fe2 | ||
|  | f577e814cf | ||
|  | c860a0cedd | ||
|  | ae7e28e492 | ||
|  | 90a63234ad | ||
|  | 14bca52e8f | ||
|  | 2f3c3361cf | ||
|  | 4034134055 | ||
|  | c04f94cb7b | ||
|  | fd1bbc7925 | ||
|  | ff69bed394 | ||
|  | d6e8c5146f | ||
|  | 9a04cf99d7 | ||
|  | 86e7c11e71 | ||
|  | 361cc08faa | ||
|  | 70dc771052 | ||
|  | c14873a799 | ||
|  | bba5abd74b | ||
|  | a224e79c1f | ||
|  | c305d98186 | ||
|  | 7c5a473e71 | ||
|  | 5e0f5d1eed | ||
|  | 238b269bc4 | ||
|  | 0ad121b9d2 | ||
|  | 7088acd9fd | ||
|  | e0a900d4b6 | ||
|  | a0fe2f0c7d | ||
|  | d5b9bc2f26 | ||
|  | 584254e6ca | ||
|  | a2963ed7bb | ||
|  | 2a3c2e133d | ||
|  | 3e7dcb2755 | ||
|  | faeec00b39 | ||
|  | eeed81392f | ||
|  | 95dce9e992 | ||
|  | 502bd2a191 | ||
|  | 17ac92a9d0 | ||
|  | ba028cde0c | ||
|  | 6e751e7a9b | ||
|  | 948b56d0e6 | ||
|  | 4bf2dc9ece | ||
|  | 125823f8ab | ||
|  | 24d33397e9 | ||
|  | 2c553825f4 | ||
|  | 198c485e9a | ||
|  | 0138505507 | ||
|  | 5d50dcc600 | ||
|  | 7bdd8c4626 | ||
|  | fc82c35f0c | ||
|  | 426ebad300 | ||
|  | 1afe61c593 | ||
|  | c20751829b | ||
|  | a3b8ee8392 | ||
|  | 156c0fe7f6 | ||
|  | 216f7a38cf | ||
|  | fd04dc10d4 | ||
|  | d39bdce926 | ||
|  | c6e01245b0 | ||
|  | c168ee7ba4 | ||
|  | 7575253000 | ||
|  | c28c1efbb1 | ||
|  | e6aa2c3b78 | ||
|  | ab7c481f83 | ||
|  | 84ad1c352d | ||
|  | e9aad39ac9 | ||
|  | c3444a87bc | ||
|  | 67b224b340 | ||
|  | bded14d36b | ||
|  | 73fa0b6631 | ||
|  | 2f07337588 | ||
|  | da163d44e7 | ||
|  | 56fbf8ae0c | ||
|  | 327eb4b39b | ||
|  | ae7873a7e3 | 
							
								
								
									
										28
									
								
								.devcontainer/.env.example
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								.devcontainer/.env.example
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | ||||
| COMPOSE_PROJECT_NAME=trmm | ||||
|  | ||||
| IMAGE_REPO=tacticalrmm/ | ||||
| VERSION=latest | ||||
|  | ||||
| # tactical credentials (Used to login to dashboard) | ||||
| TRMM_USER=tactical | ||||
| TRMM_PASS=tactical | ||||
|  | ||||
| # dns settings | ||||
| APP_HOST=rmm.example.com | ||||
| API_HOST=api.example.com | ||||
| MESH_HOST=mesh.example.com | ||||
|  | ||||
| # mesh settings | ||||
| MESH_USER=tactical | ||||
| MESH_PASS=tactical | ||||
| MONGODB_USER=mongouser | ||||
| MONGODB_PASSWORD=mongopass | ||||
|  | ||||
| # database settings | ||||
| POSTGRES_USER=postgres | ||||
| POSTGRES_PASS=postgrespass | ||||
|  | ||||
| # DEV SETTINGS | ||||
| APP_PORT=80 | ||||
| API_PORT=80 | ||||
| HTTP_PROTOCOL=https | ||||
							
								
								
									
										24
									
								
								.devcontainer/api.dockerfile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								.devcontainer/api.dockerfile
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| FROM python:3.9.2-slim | ||||
|  | ||||
| ENV TACTICAL_DIR /opt/tactical | ||||
| ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready | ||||
| ENV WORKSPACE_DIR /workspace | ||||
| ENV TACTICAL_USER tactical | ||||
| ENV VIRTUAL_ENV ${WORKSPACE_DIR}/api/tacticalrmm/env | ||||
| ENV PYTHONDONTWRITEBYTECODE=1 | ||||
| ENV PYTHONUNBUFFERED=1 | ||||
|  | ||||
| EXPOSE 8000 8383 | ||||
|  | ||||
| RUN groupadd -g 1000 tactical && \ | ||||
|     useradd -u 1000 -g 1000 tactical | ||||
|  | ||||
| # Copy Dev python reqs | ||||
| COPY ./requirements.txt / | ||||
|  | ||||
| # Copy Docker Entrypoint | ||||
| COPY ./entrypoint.sh / | ||||
| RUN chmod +x /entrypoint.sh | ||||
| ENTRYPOINT ["/entrypoint.sh"] | ||||
|  | ||||
| WORKDIR ${WORKSPACE_DIR}/api/tacticalrmm | ||||
							
								
								
									
										19
									
								
								.devcontainer/docker-compose.debug.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								.devcontainer/docker-compose.debug.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| version: '3.4' | ||||
|  | ||||
| services: | ||||
|   api-dev: | ||||
|     image: api-dev | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: ./api.dockerfile | ||||
|     command: ["sh", "-c", "pip install debugpy -t /tmp && python /tmp/debugpy --wait-for-client --listen 0.0.0.0:5678 manage.py runserver 0.0.0.0:8000 --nothreading --noreload"] | ||||
|     ports: | ||||
|       - 8000:8000 | ||||
|       - 5678:5678 | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases:  | ||||
|           - tactical-backend | ||||
							
								
								
									
										242
									
								
								.devcontainer/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										242
									
								
								.devcontainer/docker-compose.yml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,242 @@ | ||||
| version: '3.4' | ||||
|  | ||||
| services: | ||||
|   api-dev: | ||||
|     container_name: trmm-api-dev | ||||
|     image: api-dev | ||||
|     restart: always | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: ./api.dockerfile | ||||
|     command: ["tactical-api"] | ||||
|     environment: | ||||
|       API_PORT: ${API_PORT} | ||||
|     ports: | ||||
|       - "8000:${API_PORT}" | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases:  | ||||
|           - tactical-backend | ||||
|  | ||||
|   app-dev: | ||||
|     container_name: trmm-app-dev | ||||
|     image: node:14-alpine | ||||
|     restart: always | ||||
|     command: /bin/sh -c "npm install npm@latest -g && npm install && npm run serve -- --host 0.0.0.0 --port ${APP_PORT}" | ||||
|     working_dir: /workspace/web | ||||
|     volumes: | ||||
|       - ..:/workspace:cached | ||||
|     ports: | ||||
|       - "8080:${APP_PORT}" | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases:  | ||||
|           - tactical-frontend | ||||
|  | ||||
|   # nats | ||||
|   nats-dev: | ||||
|     container_name: trmm-nats-dev | ||||
|     image: ${IMAGE_REPO}tactical-nats:${VERSION} | ||||
|     restart: always | ||||
|     environment: | ||||
|       API_HOST: ${API_HOST} | ||||
|       API_PORT: ${API_PORT} | ||||
|       DEV: 1 | ||||
|     ports: | ||||
|       - "4222:4222" | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases: | ||||
|           - ${API_HOST} | ||||
|           - tactical-nats | ||||
|  | ||||
|   # meshcentral container | ||||
|   meshcentral-dev: | ||||
|     container_name: trmm-meshcentral-dev | ||||
|     image: ${IMAGE_REPO}tactical-meshcentral:${VERSION} | ||||
|     restart: always | ||||
|     environment:  | ||||
|       MESH_HOST: ${MESH_HOST} | ||||
|       MESH_USER: ${MESH_USER} | ||||
|       MESH_PASS: ${MESH_PASS} | ||||
|       MONGODB_USER: ${MONGODB_USER} | ||||
|       MONGODB_PASSWORD: ${MONGODB_PASSWORD} | ||||
|       NGINX_HOST_IP: 172.21.0.20 | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases: | ||||
|           - tactical-meshcentral | ||||
|           - ${MESH_HOST} | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - mesh-data-dev:/home/node/app/meshcentral-data | ||||
|     depends_on: | ||||
|       - mongodb-dev | ||||
|  | ||||
|   # mongodb container for meshcentral | ||||
|   mongodb-dev: | ||||
|     container_name: trmm-mongodb-dev | ||||
|     image: mongo:4.4 | ||||
|     restart: always | ||||
|     environment: | ||||
|       MONGO_INITDB_ROOT_USERNAME: ${MONGODB_USER} | ||||
|       MONGO_INITDB_ROOT_PASSWORD: ${MONGODB_PASSWORD} | ||||
|       MONGO_INITDB_DATABASE: meshcentral | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases: | ||||
|           - tactical-mongodb | ||||
|     volumes: | ||||
|       - mongo-dev-data:/data/db | ||||
|  | ||||
|   # postgres database for api service | ||||
|   postgres-dev: | ||||
|     container_name: trmm-postgres-dev | ||||
|     image: postgres:13-alpine | ||||
|     restart: always | ||||
|     environment: | ||||
|       POSTGRES_DB: tacticalrmm | ||||
|       POSTGRES_USER: ${POSTGRES_USER} | ||||
|       POSTGRES_PASSWORD: ${POSTGRES_PASS} | ||||
|     volumes: | ||||
|       - postgres-data-dev:/var/lib/postgresql/data | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases: | ||||
|           - tactical-postgres | ||||
|  | ||||
|   # redis container for celery tasks | ||||
|   redis-dev: | ||||
|     container_name: trmm-redis-dev | ||||
|     restart: always | ||||
|     image: redis:6.0-alpine | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases: | ||||
|           - tactical-redis | ||||
|  | ||||
|   init-dev: | ||||
|     container_name: trmm-init-dev | ||||
|     image: api-dev | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: ./api.dockerfile | ||||
|     restart: on-failure | ||||
|     command: ["tactical-init-dev"] | ||||
|     environment: | ||||
|       POSTGRES_USER: ${POSTGRES_USER} | ||||
|       POSTGRES_PASS: ${POSTGRES_PASS} | ||||
|       APP_HOST: ${APP_HOST} | ||||
|       API_HOST: ${API_HOST} | ||||
|       MESH_HOST: ${MESH_HOST} | ||||
|       MESH_USER: ${MESH_USER} | ||||
|       TRMM_USER: ${TRMM_USER} | ||||
|       TRMM_PASS: ${TRMM_PASS} | ||||
|       HTTP_PROTOCOL: ${HTTP_PROTOCOL} | ||||
|       APP_PORT: ${APP_PORT} | ||||
|     depends_on: | ||||
|       - postgres-dev | ||||
|       - meshcentral-dev | ||||
|     networks: | ||||
|       - dev | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|  | ||||
|   # container for celery worker service | ||||
|   celery-dev: | ||||
|     container_name: trmm-celery-dev | ||||
|     image: api-dev | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: ./api.dockerfile | ||||
|     command: ["tactical-celery-dev"] | ||||
|     restart: always | ||||
|     networks: | ||||
|       - dev | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|     depends_on: | ||||
|       - postgres-dev | ||||
|       - redis-dev | ||||
|  | ||||
|   # container for celery beat service | ||||
|   celerybeat-dev: | ||||
|     container_name: trmm-celerybeat-dev | ||||
|     image: api-dev | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: ./api.dockerfile | ||||
|     command: ["tactical-celerybeat-dev"] | ||||
|     restart: always | ||||
|     networks: | ||||
|       - dev | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|     depends_on: | ||||
|       - postgres-dev | ||||
|       - redis-dev | ||||
|  | ||||
|   # container for websockets communication | ||||
|   websockets-dev: | ||||
|     container_name: trmm-websockets-dev | ||||
|     image: api-dev | ||||
|     build: | ||||
|       context: . | ||||
|       dockerfile: ./api.dockerfile | ||||
|     command: ["tactical-websockets-dev"] | ||||
|     restart: always | ||||
|     networks: | ||||
|       dev: | ||||
|         aliases: | ||||
|           - tactical-websockets | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|     depends_on: | ||||
|       - postgres-dev | ||||
|       - redis-dev | ||||
|  | ||||
|   # container for tactical reverse proxy | ||||
|   nginx-dev: | ||||
|     container_name: trmm-nginx-dev | ||||
|     image: ${IMAGE_REPO}tactical-nginx:${VERSION} | ||||
|     restart: always | ||||
|     environment: | ||||
|       APP_HOST: ${APP_HOST} | ||||
|       API_HOST: ${API_HOST} | ||||
|       MESH_HOST: ${MESH_HOST} | ||||
|       CERT_PUB_KEY: ${CERT_PUB_KEY} | ||||
|       CERT_PRIV_KEY: ${CERT_PRIV_KEY} | ||||
|       APP_PORT: ${APP_PORT} | ||||
|       API_PORT: ${API_PORT} | ||||
|     networks: | ||||
|       dev: | ||||
|         ipv4_address: 172.21.0.20 | ||||
|     ports: | ||||
|       - "80:80" | ||||
|       - "443:443" | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|  | ||||
| volumes: | ||||
|   tactical-data-dev: | ||||
|   postgres-data-dev: | ||||
|   mongo-dev-data: | ||||
|   mesh-data-dev: | ||||
|  | ||||
| networks: | ||||
|   dev: | ||||
|     driver: bridge | ||||
|     ipam: | ||||
|       driver: default | ||||
|       config: | ||||
|         - subnet: 172.21.0.0/24   | ||||
							
								
								
									
										172
									
								
								.devcontainer/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								.devcontainer/entrypoint.sh
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| #!/usr/bin/env bash | ||||
|  | ||||
| set -e | ||||
|  | ||||
| : "${TRMM_USER:=tactical}" | ||||
| : "${TRMM_PASS:=tactical}" | ||||
| : "${POSTGRES_HOST:=tactical-postgres}" | ||||
| : "${POSTGRES_PORT:=5432}" | ||||
| : "${POSTGRES_USER:=tactical}" | ||||
| : "${POSTGRES_PASS:=tactical}" | ||||
| : "${POSTGRES_DB:=tacticalrmm}" | ||||
| : "${MESH_CONTAINER:=tactical-meshcentral}" | ||||
| : "${MESH_USER:=meshcentral}" | ||||
| : "${MESH_PASS:=meshcentralpass}" | ||||
| : "${MESH_HOST:=tactical-meshcentral}" | ||||
| : "${API_HOST:=tactical-backend}" | ||||
| : "${APP_HOST:=tactical-frontend}" | ||||
| : "${REDIS_HOST:=tactical-redis}" | ||||
| : "${HTTP_PROTOCOL:=http}" | ||||
| : "${APP_PORT:=8080}" | ||||
| : "${API_PORT:=8000}" | ||||
|  | ||||
| # Add python venv to path | ||||
| export PATH="${VIRTUAL_ENV}/bin:$PATH" | ||||
|  | ||||
| function check_tactical_ready { | ||||
|   sleep 15 | ||||
|   until [ -f "${TACTICAL_READY_FILE}" ]; do | ||||
|     echo "waiting for init container to finish install or update..." | ||||
|     sleep 10 | ||||
|   done | ||||
| } | ||||
|  | ||||
| function django_setup { | ||||
|   until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do | ||||
|     echo "waiting for postgresql container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
|  | ||||
|   until (echo > /dev/tcp/"${MESH_CONTAINER}"/443) &> /dev/null; do | ||||
|     echo "waiting for meshcentral container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
|  | ||||
|   echo "setting up django environment" | ||||
|  | ||||
|   # configure django settings | ||||
|   MESH_TOKEN="$(cat ${TACTICAL_DIR}/tmp/mesh_token)" | ||||
|  | ||||
|   DJANGO_SEKRET=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 80 | head -n 1) | ||||
|    | ||||
|   localvars="$(cat << EOF | ||||
| SECRET_KEY = '${DJANGO_SEKRET}' | ||||
|  | ||||
| DEBUG = True | ||||
|  | ||||
| DOCKER_BUILD = True | ||||
|  | ||||
| CERT_FILE = '/opt/tactical/certs/fullchain.pem' | ||||
| KEY_FILE = '/opt/tactical/certs/privkey.pem' | ||||
|  | ||||
| SCRIPTS_DIR = '${WORKSPACE_DIR}/scripts' | ||||
|  | ||||
| ALLOWED_HOSTS = ['${API_HOST}', '*'] | ||||
|  | ||||
| ADMIN_URL = 'admin/' | ||||
|  | ||||
| CORS_ORIGIN_ALLOW_ALL = True | ||||
|  | ||||
| DATABASES = { | ||||
|     'default': { | ||||
|         'ENGINE': 'django.db.backends.postgresql', | ||||
|         'NAME': '${POSTGRES_DB}', | ||||
|         'USER': '${POSTGRES_USER}', | ||||
|         'PASSWORD': '${POSTGRES_PASS}', | ||||
|         'HOST': '${POSTGRES_HOST}', | ||||
|         'PORT': '${POSTGRES_PORT}', | ||||
|     } | ||||
| } | ||||
|  | ||||
| REST_FRAMEWORK = { | ||||
|     'DATETIME_FORMAT': '%b-%d-%Y - %H:%M', | ||||
|  | ||||
|     'DEFAULT_PERMISSION_CLASSES': ( | ||||
|         'rest_framework.permissions.IsAuthenticated', | ||||
|     ), | ||||
|     'DEFAULT_AUTHENTICATION_CLASSES': ( | ||||
|         'knox.auth.TokenAuthentication', | ||||
|     ), | ||||
| } | ||||
|  | ||||
| if not DEBUG: | ||||
|     REST_FRAMEWORK.update({ | ||||
|         'DEFAULT_RENDERER_CLASSES': ( | ||||
|             'rest_framework.renderers.JSONRenderer', | ||||
|         ) | ||||
|     }) | ||||
|  | ||||
| MESH_USERNAME = '${MESH_USER}' | ||||
| MESH_SITE = 'https://${MESH_HOST}' | ||||
| MESH_TOKEN_KEY = '${MESH_TOKEN}' | ||||
| REDIS_HOST    = '${REDIS_HOST}' | ||||
| ADMIN_ENABLED = True | ||||
| EOF | ||||
| )" | ||||
|  | ||||
|   echo "${localvars}" > ${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
|  | ||||
|   # run migrations and init scripts | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py migrate --no-input | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py collectstatic --no-input | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py initial_db_setup | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py initial_mesh_setup | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py load_chocos | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py load_community_scripts | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py reload_nats | ||||
|  | ||||
|   # create super user  | ||||
|   echo "from accounts.models import User; User.objects.create_superuser('${TRMM_USER}', 'admin@example.com', '${TRMM_PASS}') if not User.objects.filter(username='${TRMM_USER}').exists() else 0;" | python manage.py shell | ||||
| } | ||||
|  | ||||
| if [ "$1" = 'tactical-init-dev' ]; then | ||||
|  | ||||
|   # make directories if they don't exist | ||||
|   mkdir -p "${TACTICAL_DIR}/tmp" | ||||
|  | ||||
|   test -f "${TACTICAL_READY_FILE}" && rm "${TACTICAL_READY_FILE}" | ||||
|  | ||||
|   # setup Python virtual env and install dependencies | ||||
|   ! test -e "${VIRTUAL_ENV}" && python -m venv ${VIRTUAL_ENV} | ||||
|   "${VIRTUAL_ENV}"/bin/pip install --no-cache-dir -r /requirements.txt | ||||
|  | ||||
|   django_setup | ||||
|  | ||||
|   # create .env file for frontend | ||||
|   webenv="$(cat << EOF | ||||
| PROD_URL = "${HTTP_PROTOCOL}://${API_HOST}" | ||||
| DEV_URL = "${HTTP_PROTOCOL}://${API_HOST}" | ||||
| APP_URL = "https://${APP_HOST}" | ||||
| DOCKER_BUILD = 1 | ||||
| EOF | ||||
| )" | ||||
|   echo "${webenv}" | tee "${WORKSPACE_DIR}"/web/.env > /dev/null | ||||
|  | ||||
|   # chown everything to tactical user | ||||
|   chown -R "${TACTICAL_USER}":"${TACTICAL_USER}" "${WORKSPACE_DIR}" | ||||
|   chown -R "${TACTICAL_USER}":"${TACTICAL_USER}" "${TACTICAL_DIR}" | ||||
|  | ||||
|   # create install ready file | ||||
|   su -c "echo 'tactical-init' > ${TACTICAL_READY_FILE}" "${TACTICAL_USER}" | ||||
| fi | ||||
|  | ||||
| if [ "$1" = 'tactical-api' ]; then | ||||
|   check_tactical_ready | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py runserver 0.0.0.0:"${API_PORT}" | ||||
| fi | ||||
|  | ||||
| if [ "$1" = 'tactical-celery-dev' ]; then | ||||
|   check_tactical_ready | ||||
|   "${VIRTUAL_ENV}"/bin/celery -A tacticalrmm worker -l debug | ||||
| fi | ||||
|  | ||||
| if [ "$1" = 'tactical-celerybeat-dev' ]; then | ||||
|   check_tactical_ready | ||||
|   test -f "${WORKSPACE_DIR}/api/tacticalrmm/celerybeat.pid" && rm "${WORKSPACE_DIR}/api/tacticalrmm/celerybeat.pid" | ||||
|   "${VIRTUAL_ENV}"/bin/celery -A tacticalrmm beat -l debug | ||||
| fi | ||||
|  | ||||
| if [ "$1" = 'tactical-websockets-dev' ]; then | ||||
|   check_tactical_ready | ||||
|   "${VIRTUAL_ENV}"/bin/daphne tacticalrmm.asgi:application --port 8383 -b 0.0.0.0 | ||||
| fi | ||||
							
								
								
									
										35
									
								
								.devcontainer/requirements.txt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								.devcontainer/requirements.txt
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| # To ensure app dependencies are ported from your virtual environment/host machine into your container, run 'pip freeze > requirements.txt' in the terminal to overwrite this file | ||||
| asyncio-nats-client | ||||
| celery | ||||
| channels | ||||
| Django | ||||
| django-cors-headers | ||||
| django-rest-knox | ||||
| djangorestframework | ||||
| loguru | ||||
| msgpack | ||||
| psycopg2-binary | ||||
| pycparser | ||||
| pycryptodome | ||||
| pyotp | ||||
| pyparsing | ||||
| pytz | ||||
| qrcode | ||||
| redis | ||||
| twilio | ||||
| packaging | ||||
| validators | ||||
| websockets | ||||
| black | ||||
| Werkzeug | ||||
| django-extensions | ||||
| coverage | ||||
| coveralls | ||||
| model_bakery | ||||
| mkdocs | ||||
| mkdocs-material | ||||
| pymdown-extensions | ||||
| Pygments | ||||
| mypy | ||||
| pysnooper | ||||
| isort | ||||
							
								
								
									
										25
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| **/__pycache__ | ||||
| **/.classpath | ||||
| **/.dockerignore | ||||
| **/.env | ||||
| **/.git | ||||
| **/.gitignore | ||||
| **/.project | ||||
| **/.settings | ||||
| **/.toolstarget | ||||
| **/.vs | ||||
| **/.vscode | ||||
| **/*.*proj.user | ||||
| **/*.dbmdl | ||||
| **/*.jfm | ||||
| **/azds.yaml | ||||
| **/charts | ||||
| **/docker-compose* | ||||
| **/Dockerfile* | ||||
| **/node_modules | ||||
| **/npm-debug.log | ||||
| **/obj | ||||
| **/secrets.dev.yaml | ||||
| **/values.dev.yaml | ||||
| **/env | ||||
| README.md | ||||
							
								
								
									
										12
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								.github/FUNDING.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| # These are supported funding model platforms | ||||
|  | ||||
| github: wh1te909 | ||||
| patreon: # Replace with a single Patreon username | ||||
| open_collective: # Replace with a single Open Collective username | ||||
| ko_fi: tacticalrmm | ||||
| tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel | ||||
| community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry | ||||
| liberapay: # Replace with a single Liberapay username | ||||
| issuehunt: # Replace with a single IssueHunt username | ||||
| otechie: # Replace with a single Otechie username | ||||
| custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] | ||||
							
								
								
									
										40
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| --- | ||||
| name: Bug report | ||||
| about: Create a bug report | ||||
| title: '' | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **Server Info (please complete the following information):** | ||||
|  - OS: [e.g. Ubuntu 20.04, Debian 10] | ||||
|  - Browser: [e.g. chrome, safari] | ||||
|  - RMM Version (as shown in top left of web UI): | ||||
|  | ||||
| **Installation Method:** | ||||
|   - [ ] Standard | ||||
|   - [ ] Docker | ||||
|  | ||||
| **Agent Info (please complete the following information):** | ||||
| - Agent version (as shown in the 'Summary' tab of the agent from web UI): | ||||
| - Agent OS: [e.g. Win 10 v2004, Server 2012 R2] | ||||
|  | ||||
| **Describe the bug** | ||||
| A clear and concise description of what the bug is. | ||||
|  | ||||
| **To Reproduce** | ||||
| Steps to reproduce the behavior: | ||||
| 1. Go to '...' | ||||
| 2. Click on '....' | ||||
| 3. Scroll down to '....' | ||||
| 4. See error | ||||
|  | ||||
| **Expected behavior** | ||||
| A clear and concise description of what you expected to happen. | ||||
|  | ||||
| **Screenshots** | ||||
| If applicable, add screenshots to help explain your problem. | ||||
|  | ||||
| **Additional context** | ||||
| Add any other context about the problem here. | ||||
							
								
								
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.github/ISSUE_TEMPLATE/feature_request.md
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | ||||
| --- | ||||
| name: Feature request | ||||
| about: Suggest an idea for this project | ||||
| title: '' | ||||
| labels: '' | ||||
| assignees: '' | ||||
|  | ||||
| --- | ||||
|  | ||||
| **Is your feature request related to a problem? Please describe.** | ||||
| A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] | ||||
|  | ||||
| **Describe the solution you'd like** | ||||
| A clear and concise description of what you want to happen. | ||||
|  | ||||
| **Describe alternatives you've considered** | ||||
| A clear and concise description of any alternative solutions or features you've considered. | ||||
|  | ||||
| **Additional context** | ||||
| Add any other context or screenshots about the feature request here. | ||||
							
								
								
									
										22
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								.github/workflows/deploy-docs.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | ||||
| name: Deploy Docs | ||||
| on: | ||||
|   push: | ||||
|     branches: | ||||
|       - master | ||||
|  | ||||
| defaults: | ||||
|   run: | ||||
|     working-directory: docs | ||||
|  | ||||
| jobs: | ||||
|   deploy: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: actions/checkout@v2 | ||||
|       - uses: actions/setup-python@v2 | ||||
|         with: | ||||
|           python-version: 3.x | ||||
|       - run: pip install --upgrade pip | ||||
|       - run: pip install --upgrade setuptools wheel | ||||
|       - run: pip install mkdocs mkdocs-material pymdown-extensions | ||||
|       - run: mkdocs gh-deploy --force | ||||
							
								
								
									
										78
									
								
								.github/workflows/docker-build-push.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										78
									
								
								.github/workflows/docker-build-push.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,78 @@ | ||||
| name: Publish Tactical Docker Images | ||||
| on: | ||||
|   push: | ||||
|     tags: | ||||
|       - "v*.*.*" | ||||
| jobs: | ||||
|   docker: | ||||
|     name: Build and Push Docker Images | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out the repo | ||||
|         uses: actions/checkout@v2 | ||||
|          | ||||
|       - name: Get Github Tag | ||||
|         id: prep | ||||
|         run: | | ||||
|           echo ::set-output name=version::${GITHUB_REF#refs/tags/v} | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v1 | ||||
|          | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v1 | ||||
|          | ||||
|       - name: Login to DockerHub | ||||
|         uses: docker/login-action@v1  | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|          | ||||
|       - name: Build and Push Tactical Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           pull: true | ||||
|           file: ./docker/containers/tactical/dockerfile | ||||
|           platforms: linux/amd64 | ||||
|           tags: tacticalrmm/tactical:${{ steps.prep.outputs.version }},tacticalrmm/tactical:latest | ||||
|            | ||||
|       - name: Build and Push Tactical MeshCentral Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           pull: true | ||||
|           file: ./docker/containers/tactical-meshcentral/dockerfile | ||||
|           platforms: linux/amd64 | ||||
|           tags: tacticalrmm/tactical-meshcentral:${{ steps.prep.outputs.version }},tacticalrmm/tactical-meshcentral:latest | ||||
|            | ||||
|       - name: Build and Push Tactical NATS Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           pull: true | ||||
|           file: ./docker/containers/tactical-nats/dockerfile | ||||
|           platforms: linux/amd64 | ||||
|           tags: tacticalrmm/tactical-nats:${{ steps.prep.outputs.version }},tacticalrmm/tactical-nats:latest | ||||
|            | ||||
|       - name: Build and Push Tactical Frontend Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           pull: true | ||||
|           file: ./docker/containers/tactical-frontend/dockerfile | ||||
|           platforms: linux/amd64 | ||||
|           tags: tacticalrmm/tactical-frontend:${{ steps.prep.outputs.version }},tacticalrmm/tactical-frontend:latest | ||||
|            | ||||
|       - name: Build and Push Tactical Nginx Image | ||||
|         uses: docker/build-push-action@v2 | ||||
|         with: | ||||
|           context: . | ||||
|           push: true | ||||
|           pull: true | ||||
|           file: ./docker/containers/tactical-nginx/dockerfile | ||||
|           platforms: linux/amd64 | ||||
|           tags: tacticalrmm/tactical-nginx:${{ steps.prep.outputs.version }},tacticalrmm/tactical-nginx:latest | ||||
							
								
								
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -45,3 +45,5 @@ htmlcov/ | ||||
| docker-compose.dev.yml | ||||
| docs/.vuepress/dist | ||||
| nats-rmm.conf | ||||
| .mypy_cache | ||||
| docs/site/ | ||||
|   | ||||
							
								
								
									
										14
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.vscode/launch.json
									
									
									
									
										vendored
									
									
								
							| @@ -14,6 +14,20 @@ | ||||
|                 "0.0.0.0:8000" | ||||
|             ], | ||||
|             "django": true | ||||
|         }, | ||||
|         { | ||||
|             "name": "Django: Docker Remote Attach", | ||||
|             "type": "python", | ||||
|             "request": "attach", | ||||
|             "port": 5678, | ||||
|             "host": "localhost", | ||||
|             "preLaunchTask": "docker debug", | ||||
|             "pathMappings": [ | ||||
|                 { | ||||
|                     "localRoot": "${workspaceFolder}/api/tacticalrmm", | ||||
|                     "remoteRoot": "/workspace/api/tacticalrmm" | ||||
|                 } | ||||
|             ] | ||||
|         } | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										28
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										28
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -2,8 +2,15 @@ | ||||
|     "python.pythonPath": "api/tacticalrmm/env/bin/python", | ||||
|     "python.languageServer": "Pylance", | ||||
|     "python.analysis.extraPaths": [ | ||||
|         "api/tacticalrmm" | ||||
|         "api/tacticalrmm", | ||||
|         "api/env", | ||||
|     ], | ||||
|     "python.analysis.diagnosticSeverityOverrides": { | ||||
|         "reportUnusedImport": "error", | ||||
|         "reportDuplicateImport": "error", | ||||
|     }, | ||||
|     "python.analysis.memory.keepLibraryAst": true, | ||||
|     "python.linting.mypyEnabled": true, | ||||
|     "python.analysis.typeCheckingMode": "basic", | ||||
|     "python.formatting.provider": "black", | ||||
|     "editor.formatOnSave": true, | ||||
| @@ -41,4 +48,23 @@ | ||||
|             "**/*.zip": true | ||||
|         }, | ||||
|     }, | ||||
|     "go.useLanguageServer": true, | ||||
|     "[go]": { | ||||
|         "editor.formatOnSave": true, | ||||
|         "editor.codeActionsOnSave": { | ||||
|             "source.organizeImports": false, | ||||
|         }, | ||||
|         "editor.snippetSuggestions": "none", | ||||
|     }, | ||||
|     "[go.mod]": { | ||||
|         "editor.formatOnSave": true, | ||||
|         "editor.codeActionsOnSave": { | ||||
|             "source.organizeImports": true, | ||||
|         }, | ||||
|     }, | ||||
|     "gopls": { | ||||
|         "usePlaceholders": true, | ||||
|         "completeUnimported": true, | ||||
|         "staticcheck": true, | ||||
|     } | ||||
| } | ||||
							
								
								
									
										23
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								.vscode/tasks.json
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| { | ||||
|     // See https://go.microsoft.com/fwlink/?LinkId=733558 | ||||
|     // for the documentation about the tasks.json format | ||||
|     "version": "2.0.0", | ||||
|     "tasks": [ | ||||
|         { | ||||
|             "label": "docker debug", | ||||
|             "type": "shell", | ||||
|             "command": "docker-compose", | ||||
|             "args": [ | ||||
|                 "-p", | ||||
|                 "trmm", | ||||
|                 "-f", | ||||
|                 ".devcontainer/docker-compose.yml", | ||||
|                 "-f", | ||||
|                 ".devcontainer/docker-compose.debug.yml", | ||||
|                 "up", | ||||
|                 "-d", | ||||
|                 "--build" | ||||
|             ] | ||||
|         } | ||||
|     ] | ||||
| } | ||||
							
								
								
									
										103
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										103
									
								
								README.md
									
									
									
									
									
								
							| @@ -6,15 +6,17 @@ | ||||
| [](https://github.com/python/black) | ||||
|  | ||||
| Tactical RMM is a remote monitoring & management tool for Windows computers, built with Django and Vue.\ | ||||
| It uses an [agent](https://github.com/wh1te909/rmmagent) written in golang, as well as the [SaltStack](https://github.com/saltstack/salt) api and [MeshCentral](https://github.com/Ylianst/MeshCentral) | ||||
| It uses an [agent](https://github.com/wh1te909/rmmagent) written in golang and integrates with [MeshCentral](https://github.com/Ylianst/MeshCentral) | ||||
|  | ||||
| # [LIVE DEMO](https://rmm.xlawgaming.com/) | ||||
| # [LIVE DEMO](https://rmm.tacticalrmm.io/) | ||||
| Demo database resets every hour. Alot of features are disabled for obvious reasons due to the nature of this app. | ||||
|  | ||||
| *Tactical RMM is currently in alpha and subject to breaking changes. Use in production at your own risk.* | ||||
|  | ||||
| ### [Discord Chat](https://discord.gg/upGTkWp) | ||||
|  | ||||
| ### [Documentation](https://wh1te909.github.io/tacticalrmm/) | ||||
|  | ||||
| ## Features | ||||
|  | ||||
| - Teamviewer-like remote desktop control | ||||
| @@ -33,99 +35,6 @@ Demo database resets every hour. Alot of features are disabled for obvious reaso | ||||
|  | ||||
| - Windows 7, 8.1, 10, Server 2008R2, 2012R2, 2016, 2019 | ||||
|  | ||||
| ## Installation | ||||
| ## Installation / Backup / Restore / Usage | ||||
|  | ||||
| ### Requirements | ||||
| - VPS with 4GB ram (an install script is provided for Ubuntu Server 20.04) | ||||
| - A domain you own with at least 3 subdomains | ||||
| - Google Authenticator app (2 factor is NOT optional) | ||||
|  | ||||
| ### Docker | ||||
| Refer to the [docker setup](docker/readme.md) | ||||
|  | ||||
|  | ||||
| ### Installation example (Ubuntu server 20.04 LTS) | ||||
|  | ||||
| Fresh VPS with latest updates\ | ||||
| login as root and create a user and add to sudoers group (we will be creating a user called tactical) | ||||
| ``` | ||||
| apt update && apt -y upgrade | ||||
| adduser tactical | ||||
| usermod -a -G sudo tactical | ||||
| ``` | ||||
|  | ||||
| switch to the tactical user and setup the firewall | ||||
| ``` | ||||
| su - tactical | ||||
| sudo ufw default deny incoming | ||||
| sudo ufw default allow outgoing | ||||
| sudo ufw allow ssh | ||||
| sudo ufw allow http | ||||
| sudo ufw allow https | ||||
| sudo ufw allow proto tcp from any to any port 4505,4506 | ||||
| sudo ufw allow proto tcp from any to any port 4222 | ||||
| sudo ufw enable && sudo ufw reload | ||||
| ``` | ||||
|  | ||||
| Our domain for this example is tacticalrmm.com | ||||
|  | ||||
| In the DNS manager of wherever our domain is hosted, we will create three A records, all pointing to the public IP address of our VPS | ||||
|  | ||||
| Create A record ```api.tacticalrmm.com``` for the django rest backend\ | ||||
| Create A record ```rmm.tacticalrmm.com``` for the vue frontend\ | ||||
| Create A record ```mesh.tacticalrmm.com``` for meshcentral | ||||
|  | ||||
| Download the install script and run it | ||||
|  | ||||
| ``` | ||||
| wget https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/install.sh | ||||
| chmod +x install.sh | ||||
| ./install.sh | ||||
| ``` | ||||
|  | ||||
|  Links will be provided at the end of the install script.\ | ||||
|  Download the executable from the first link, then open ```rmm.tacticalrmm.com``` and login.\ | ||||
|  Upload the executable when prompted during the initial setup page. | ||||
|  | ||||
|  | ||||
| ### Install an agent | ||||
| From the app's dashboard, choose Agents > Install Agent to generate an installer. | ||||
|  | ||||
| ## Updating | ||||
| Download and run [update.sh](https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/update.sh) | ||||
| ``` | ||||
| wget https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/update.sh | ||||
| chmod +x update.sh | ||||
| ./update.sh | ||||
| ``` | ||||
|  | ||||
| ## Backup | ||||
| Download [backup.sh](https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/backup.sh) | ||||
| ``` | ||||
| wget https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/backup.sh | ||||
| ``` | ||||
| Change the postgres username and password at the top of the file (you can find them in `/rmm/api/tacticalrmm/tacticalrmm/local_settings.py` under the DATABASES section) | ||||
|  | ||||
| Run it | ||||
| ``` | ||||
| chmod +x backup.sh | ||||
| ./backup.sh | ||||
| ``` | ||||
|  | ||||
| ## Restore | ||||
| Change your 3 A records to point to new server's public IP | ||||
|  | ||||
| Create same linux user account as old server and add to sudoers group and setup firewall (see install instructions above) | ||||
|  | ||||
| Copy backup file to new server | ||||
|  | ||||
| Download the restore script, and edit the postgres username/password at the top of the file. Same instructions as above in the backup steps. | ||||
| ``` | ||||
| wget https://raw.githubusercontent.com/wh1te909/tacticalrmm/master/restore.sh | ||||
| ``` | ||||
|  | ||||
| Run the restore script, passing it the backup tar file as the first argument | ||||
| ``` | ||||
| chmod +x restore.sh | ||||
| ./restore.sh rmm-backup-xxxxxxx.tar | ||||
| ``` | ||||
| ### Refer to the [documentation](https://wh1te909.github.io/tacticalrmm/) | ||||
| @@ -1,457 +0,0 @@ | ||||
| from __future__ import absolute_import | ||||
| import psutil | ||||
| import os | ||||
| import datetime | ||||
| import zlib | ||||
| import json | ||||
| import base64 | ||||
| import wmi | ||||
| import win32evtlog | ||||
| import win32con | ||||
| import win32evtlogutil | ||||
| import winerror | ||||
| from time import sleep | ||||
| import requests | ||||
| import subprocess | ||||
| import random | ||||
| import platform | ||||
|  | ||||
| ARCH = "64" if platform.machine().endswith("64") else "32" | ||||
| PROGRAM_DIR = os.path.join(os.environ["ProgramFiles"], "TacticalAgent") | ||||
| TAC_RMM = os.path.join(PROGRAM_DIR, "tacticalrmm.exe") | ||||
| NSSM = os.path.join(PROGRAM_DIR, "nssm.exe" if ARCH == "64" else "nssm-x86.exe") | ||||
| TEMP_DIR = os.path.join(os.environ["WINDIR"], "Temp") | ||||
| SYS_DRIVE = os.environ["SystemDrive"] | ||||
| PY_BIN = os.path.join(SYS_DRIVE, "\\salt", "bin", "python.exe") | ||||
| SALT_CALL = os.path.join(SYS_DRIVE, "\\salt", "salt-call.bat") | ||||
|  | ||||
|  | ||||
| def get_services(): | ||||
|     # see https://github.com/wh1te909/tacticalrmm/issues/38 | ||||
|     # for why I am manually implementing the svc.as_dict() method of psutil | ||||
|     ret = [] | ||||
|     for svc in psutil.win_service_iter(): | ||||
|         i = {} | ||||
|         try: | ||||
|             i["display_name"] = svc.display_name() | ||||
|             i["binpath"] = svc.binpath() | ||||
|             i["username"] = svc.username() | ||||
|             i["start_type"] = svc.start_type() | ||||
|             i["status"] = svc.status() | ||||
|             i["pid"] = svc.pid() | ||||
|             i["name"] = svc.name() | ||||
|             i["description"] = svc.description() | ||||
|         except Exception: | ||||
|             continue | ||||
|         else: | ||||
|             ret.append(i) | ||||
|  | ||||
|     return ret | ||||
|  | ||||
|  | ||||
| def run_python_script(filename, timeout, script_type="userdefined"): | ||||
|     # no longer used in agent version 0.11.0 | ||||
|     file_path = os.path.join(TEMP_DIR, filename) | ||||
|  | ||||
|     if os.path.exists(file_path): | ||||
|         try: | ||||
|             os.remove(file_path) | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|     if script_type == "userdefined": | ||||
|         __salt__["cp.get_file"](f"salt://scripts/userdefined/{filename}", file_path) | ||||
|     else: | ||||
|         __salt__["cp.get_file"](f"salt://scripts/{filename}", file_path) | ||||
|  | ||||
|     return __salt__["cmd.run_all"](f"{PY_BIN} {file_path}", timeout=timeout) | ||||
|  | ||||
|  | ||||
| def run_script(filepath, filename, shell, timeout, args=[], bg=False): | ||||
|     if shell == "powershell" or shell == "cmd": | ||||
|         if args: | ||||
|             return __salt__["cmd.script"]( | ||||
|                 source=filepath, | ||||
|                 args=" ".join(map(lambda x: f'"{x}"', args)), | ||||
|                 shell=shell, | ||||
|                 timeout=timeout, | ||||
|                 bg=bg, | ||||
|             ) | ||||
|         else: | ||||
|             return __salt__["cmd.script"]( | ||||
|                 source=filepath, shell=shell, timeout=timeout, bg=bg | ||||
|             ) | ||||
|  | ||||
|     elif shell == "python": | ||||
|         file_path = os.path.join(TEMP_DIR, filename) | ||||
|  | ||||
|         if os.path.exists(file_path): | ||||
|             try: | ||||
|                 os.remove(file_path) | ||||
|             except: | ||||
|                 pass | ||||
|  | ||||
|         __salt__["cp.get_file"](filepath, file_path) | ||||
|  | ||||
|         salt_cmd = "cmd.run_bg" if bg else "cmd.run_all" | ||||
|  | ||||
|         if args: | ||||
|             a = " ".join(map(lambda x: f'"{x}"', args)) | ||||
|             cmd = f"{PY_BIN} {file_path} {a}" | ||||
|             return __salt__[salt_cmd](cmd, timeout=timeout) | ||||
|         else: | ||||
|             return __salt__[salt_cmd](f"{PY_BIN} {file_path}", timeout=timeout) | ||||
|  | ||||
|  | ||||
| def uninstall_agent(): | ||||
|     remove_exe = os.path.join(PROGRAM_DIR, "unins000.exe") | ||||
|     __salt__["cmd.run_bg"]([remove_exe, "/VERYSILENT", "/SUPPRESSMSGBOXES"]) | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| def update_salt(): | ||||
|     for p in psutil.process_iter(): | ||||
|         with p.oneshot(): | ||||
|             if p.name() == "tacticalrmm.exe" and "updatesalt" in p.cmdline(): | ||||
|                 return "running" | ||||
|  | ||||
|     from subprocess import Popen, PIPE | ||||
|  | ||||
|     CREATE_NEW_PROCESS_GROUP = 0x00000200 | ||||
|     DETACHED_PROCESS = 0x00000008 | ||||
|     cmd = [TAC_RMM, "-m", "updatesalt"] | ||||
|     p = Popen( | ||||
|         cmd, | ||||
|         stdin=PIPE, | ||||
|         stdout=PIPE, | ||||
|         stderr=PIPE, | ||||
|         close_fds=True, | ||||
|         creationflags=DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, | ||||
|     ) | ||||
|     return p.pid | ||||
|  | ||||
|  | ||||
| def run_manual_checks(): | ||||
|     __salt__["cmd.run_bg"]([TAC_RMM, "-m", "runchecks"]) | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| def install_updates(): | ||||
|     for p in psutil.process_iter(): | ||||
|         with p.oneshot(): | ||||
|             if p.name() == "tacticalrmm.exe" and "winupdater" in p.cmdline(): | ||||
|                 return "running" | ||||
|  | ||||
|     return __salt__["cmd.run_bg"]([TAC_RMM, "-m", "winupdater"]) | ||||
|  | ||||
|  | ||||
| def _wait_for_service(svc, status, retries=10): | ||||
|     attempts = 0 | ||||
|     while 1: | ||||
|         try: | ||||
|             service = psutil.win_service_get(svc) | ||||
|         except psutil.NoSuchProcess: | ||||
|             stat = "fail" | ||||
|             attempts += 1 | ||||
|             sleep(5) | ||||
|         else: | ||||
|             stat = service.status() | ||||
|             if stat != status: | ||||
|                 attempts += 1 | ||||
|                 sleep(5) | ||||
|             else: | ||||
|                 attempts = 0 | ||||
|  | ||||
|         if attempts == 0 or attempts > retries: | ||||
|             break | ||||
|  | ||||
|     return stat | ||||
|  | ||||
|  | ||||
| def agent_update_v2(inno, url): | ||||
|     # make sure another instance of the update is not running | ||||
|     # this function spawns 2 instances of itself (because we call it twice with salt run_bg) | ||||
|     # so if more than 2 running, don't continue as an update is already running | ||||
|     count = 0 | ||||
|     for p in psutil.process_iter(): | ||||
|         try: | ||||
|             with p.oneshot(): | ||||
|                 if "win_agent.agent_update_v2" in p.cmdline(): | ||||
|                     count += 1 | ||||
|         except Exception: | ||||
|             continue | ||||
|  | ||||
|     if count > 2: | ||||
|         return "already running" | ||||
|  | ||||
|     sleep(random.randint(1, 20))  # don't flood the rmm | ||||
|  | ||||
|     exe = os.path.join(TEMP_DIR, inno) | ||||
|  | ||||
|     if os.path.exists(exe): | ||||
|         try: | ||||
|             os.remove(exe) | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|     try: | ||||
|         r = requests.get(url, stream=True, timeout=600) | ||||
|     except Exception: | ||||
|         return "failed" | ||||
|  | ||||
|     if r.status_code != 200: | ||||
|         return "failed" | ||||
|  | ||||
|     with open(exe, "wb") as f: | ||||
|         for chunk in r.iter_content(chunk_size=1024): | ||||
|             if chunk: | ||||
|                 f.write(chunk) | ||||
|     del r | ||||
|  | ||||
|     ret = subprocess.run([exe, "/VERYSILENT", "/SUPPRESSMSGBOXES"], timeout=120) | ||||
|  | ||||
|     tac = _wait_for_service(svc="tacticalagent", status="running") | ||||
|     if tac != "running": | ||||
|         subprocess.run([NSSM, "start", "tacticalagent"], timeout=30) | ||||
|  | ||||
|     chk = _wait_for_service(svc="checkrunner", status="running") | ||||
|     if chk != "running": | ||||
|         subprocess.run([NSSM, "start", "checkrunner"], timeout=30) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| def do_agent_update_v2(inno, url): | ||||
|     return __salt__["cmd.run_bg"]( | ||||
|         [ | ||||
|             SALT_CALL, | ||||
|             "win_agent.agent_update_v2", | ||||
|             f"inno={inno}", | ||||
|             f"url={url}", | ||||
|             "--local", | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| def agent_update(version, url): | ||||
|     # make sure another instance of the update is not running | ||||
|     # this function spawns 2 instances of itself so if more than 2 running, | ||||
|     # don't continue as an update is already running | ||||
|     count = 0 | ||||
|     for p in psutil.process_iter(): | ||||
|         try: | ||||
|             with p.oneshot(): | ||||
|                 if "win_agent.agent_update" in p.cmdline(): | ||||
|                     count += 1 | ||||
|         except Exception: | ||||
|             continue | ||||
|  | ||||
|     if count > 2: | ||||
|         return "already running" | ||||
|  | ||||
|     sleep(random.randint(1, 60))  # don't flood the rmm | ||||
|     try: | ||||
|         r = requests.get(url, stream=True, timeout=600) | ||||
|     except Exception: | ||||
|         return "failed" | ||||
|  | ||||
|     if r.status_code != 200: | ||||
|         return "failed" | ||||
|  | ||||
|     exe = os.path.join(TEMP_DIR, f"winagent-v{version}.exe") | ||||
|  | ||||
|     with open(exe, "wb") as f: | ||||
|         for chunk in r.iter_content(chunk_size=1024): | ||||
|             if chunk: | ||||
|                 f.write(chunk) | ||||
|     del r | ||||
|  | ||||
|     services = ("tacticalagent", "checkrunner") | ||||
|  | ||||
|     for svc in services: | ||||
|         subprocess.run([NSSM, "stop", svc], timeout=120) | ||||
|  | ||||
|     sleep(10) | ||||
|     r = subprocess.run([exe, "/VERYSILENT", "/SUPPRESSMSGBOXES"], timeout=300) | ||||
|     sleep(30) | ||||
|  | ||||
|     for svc in services: | ||||
|         subprocess.run([NSSM, "start", svc], timeout=120) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| def do_agent_update(version, url): | ||||
|     return __salt__["cmd.run_bg"]( | ||||
|         [ | ||||
|             SALT_CALL, | ||||
|             "win_agent.agent_update", | ||||
|             f"version={version}", | ||||
|             f"url={url}", | ||||
|             "--local", | ||||
|         ] | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class SystemDetail: | ||||
|     def __init__(self): | ||||
|         self.c = wmi.WMI() | ||||
|         self.comp_sys_prod = self.c.Win32_ComputerSystemProduct() | ||||
|         self.comp_sys = self.c.Win32_ComputerSystem() | ||||
|         self.memory = self.c.Win32_PhysicalMemory() | ||||
|         self.os = self.c.Win32_OperatingSystem() | ||||
|         self.base_board = self.c.Win32_BaseBoard() | ||||
|         self.bios = self.c.Win32_BIOS() | ||||
|         self.disk = self.c.Win32_DiskDrive() | ||||
|         self.network_adapter = self.c.Win32_NetworkAdapter() | ||||
|         self.network_config = self.c.Win32_NetworkAdapterConfiguration() | ||||
|         self.desktop_monitor = self.c.Win32_DesktopMonitor() | ||||
|         self.cpu = self.c.Win32_Processor() | ||||
|         self.usb = self.c.Win32_USBController() | ||||
|  | ||||
|     def get_all(self, obj): | ||||
|         ret = [] | ||||
|         for i in obj: | ||||
|             tmp = [ | ||||
|                 {j: getattr(i, j)} | ||||
|                 for j in list(i.properties) | ||||
|                 if getattr(i, j) is not None | ||||
|             ] | ||||
|             ret.append(tmp) | ||||
|  | ||||
|         return ret | ||||
|  | ||||
|  | ||||
| def system_info(): | ||||
|     info = SystemDetail() | ||||
|     return { | ||||
|         "comp_sys_prod": info.get_all(info.comp_sys_prod), | ||||
|         "comp_sys": info.get_all(info.comp_sys), | ||||
|         "mem": info.get_all(info.memory), | ||||
|         "os": info.get_all(info.os), | ||||
|         "base_board": info.get_all(info.base_board), | ||||
|         "bios": info.get_all(info.bios), | ||||
|         "disk": info.get_all(info.disk), | ||||
|         "network_adapter": info.get_all(info.network_adapter), | ||||
|         "network_config": info.get_all(info.network_config), | ||||
|         "desktop_monitor": info.get_all(info.desktop_monitor), | ||||
|         "cpu": info.get_all(info.cpu), | ||||
|         "usb": info.get_all(info.usb), | ||||
|     } | ||||
|  | ||||
|  | ||||
| def local_sys_info(): | ||||
|     return __salt__["cmd.run_bg"]([TAC_RMM, "-m", "sysinfo"]) | ||||
|  | ||||
|  | ||||
| def get_procs(): | ||||
|     ret = [] | ||||
|  | ||||
|     # setup | ||||
|     for proc in psutil.process_iter(): | ||||
|         with proc.oneshot(): | ||||
|             proc.cpu_percent(interval=None) | ||||
|  | ||||
|     # need time for psutil to record cpu percent | ||||
|     sleep(1) | ||||
|  | ||||
|     for c, proc in enumerate(psutil.process_iter(), 1): | ||||
|         x = {} | ||||
|         with proc.oneshot(): | ||||
|             if proc.pid == 0 or not proc.name(): | ||||
|                 continue | ||||
|  | ||||
|             x["name"] = proc.name() | ||||
|             x["cpu_percent"] = proc.cpu_percent(interval=None) / psutil.cpu_count() | ||||
|             x["memory_percent"] = proc.memory_percent() | ||||
|             x["pid"] = proc.pid | ||||
|             x["ppid"] = proc.ppid() | ||||
|             x["status"] = proc.status() | ||||
|             x["username"] = proc.username() | ||||
|             x["id"] = c | ||||
|  | ||||
|         ret.append(x) | ||||
|  | ||||
|     return ret | ||||
|  | ||||
|  | ||||
| def _compress_json(j): | ||||
|     return { | ||||
|         "wineventlog": base64.b64encode( | ||||
|             zlib.compress(json.dumps(j).encode("utf-8", errors="ignore")) | ||||
|         ).decode("ascii", errors="ignore") | ||||
|     } | ||||
|  | ||||
|  | ||||
| def get_eventlog(logtype, last_n_days): | ||||
|  | ||||
|     start_time = datetime.datetime.now() - datetime.timedelta(days=last_n_days) | ||||
|     flags = win32evtlog.EVENTLOG_BACKWARDS_READ | win32evtlog.EVENTLOG_SEQUENTIAL_READ | ||||
|  | ||||
|     status_dict = { | ||||
|         win32con.EVENTLOG_AUDIT_FAILURE: "AUDIT_FAILURE", | ||||
|         win32con.EVENTLOG_AUDIT_SUCCESS: "AUDIT_SUCCESS", | ||||
|         win32con.EVENTLOG_INFORMATION_TYPE: "INFO", | ||||
|         win32con.EVENTLOG_WARNING_TYPE: "WARNING", | ||||
|         win32con.EVENTLOG_ERROR_TYPE: "ERROR", | ||||
|         0: "INFO", | ||||
|     } | ||||
|  | ||||
|     computer = "localhost" | ||||
|     hand = win32evtlog.OpenEventLog(computer, logtype) | ||||
|     total = win32evtlog.GetNumberOfEventLogRecords(hand) | ||||
|     log = [] | ||||
|     uid = 0 | ||||
|     done = False | ||||
|  | ||||
|     try: | ||||
|         while 1: | ||||
|             events = win32evtlog.ReadEventLog(hand, flags, 0) | ||||
|             for ev_obj in events: | ||||
|  | ||||
|                 uid += 1 | ||||
|                 # return once total number of events reach or we'll be stuck in an infinite loop | ||||
|                 if uid >= total: | ||||
|                     done = True | ||||
|                     break | ||||
|  | ||||
|                 the_time = ev_obj.TimeGenerated.Format() | ||||
|                 time_obj = datetime.datetime.strptime(the_time, "%c") | ||||
|                 if time_obj < start_time: | ||||
|                     done = True | ||||
|                     break | ||||
|  | ||||
|                 computer = str(ev_obj.ComputerName) | ||||
|                 src = str(ev_obj.SourceName) | ||||
|                 evt_type = str(status_dict[ev_obj.EventType]) | ||||
|                 evt_id = str(winerror.HRESULT_CODE(ev_obj.EventID)) | ||||
|                 evt_category = str(ev_obj.EventCategory) | ||||
|                 record = str(ev_obj.RecordNumber) | ||||
|                 msg = ( | ||||
|                     str(win32evtlogutil.SafeFormatMessage(ev_obj, logtype)) | ||||
|                     .replace("<", "") | ||||
|                     .replace(">", "") | ||||
|                 ) | ||||
|  | ||||
|                 event_dict = { | ||||
|                     "computer": computer, | ||||
|                     "source": src, | ||||
|                     "eventType": evt_type, | ||||
|                     "eventID": evt_id, | ||||
|                     "eventCategory": evt_category, | ||||
|                     "message": msg, | ||||
|                     "time": the_time, | ||||
|                     "record": record, | ||||
|                     "uid": uid, | ||||
|                 } | ||||
|  | ||||
|                 log.append(event_dict) | ||||
|  | ||||
|             if done: | ||||
|                 break | ||||
|  | ||||
|     except Exception: | ||||
|         pass | ||||
|  | ||||
|     win32evtlog.CloseEventLog(hand) | ||||
|     return _compress_json(log) | ||||
| @@ -20,6 +20,5 @@ omit = | ||||
|     */urls.py | ||||
|     */tests.py | ||||
|     */test.py | ||||
|     api/*.py | ||||
|     checks/utils.py | ||||
|      | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| from rest_framework.authtoken.admin import TokenAdmin | ||||
|  | ||||
| from .models import User | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils import timezone as djangotime | ||||
| from knox.models import AuthToken | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,11 +1,13 @@ | ||||
| import pyotp | ||||
| import subprocess | ||||
|  | ||||
| import pyotp | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from accounts.models import User | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Generates barcode for Google Authenticator and creates totp for user" | ||||
|     help = "Generates barcode for Authenticator and creates totp for user" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument("code", type=str) | ||||
| @@ -24,12 +26,10 @@ class Command(BaseCommand): | ||||
|         url = pyotp.totp.TOTP(code).provisioning_uri(username, issuer_name=domain) | ||||
|         subprocess.run(f'qr "{url}"', shell=True) | ||||
|         self.stdout.write( | ||||
|             self.style.SUCCESS( | ||||
|                 "Scan the barcode above with your google authenticator app" | ||||
|             ) | ||||
|             self.style.SUCCESS("Scan the barcode above with your authenticator app") | ||||
|         ) | ||||
|         self.stdout.write( | ||||
|             self.style.SUCCESS( | ||||
|                 f"If that doesn't work you may manually enter the key: {code}" | ||||
|                 f"If that doesn't work you may manually enter the setup key: {code}" | ||||
|             ) | ||||
|         ) | ||||
|   | ||||
							
								
								
									
										57
									
								
								api/tacticalrmm/accounts/management/commands/reset_2fa.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										57
									
								
								api/tacticalrmm/accounts/management/commands/reset_2fa.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,57 @@ | ||||
| import os | ||||
| import subprocess | ||||
|  | ||||
| import pyotp | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from accounts.models import User | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Reset 2fa" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument("username", type=str) | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         username = kwargs["username"] | ||||
|         try: | ||||
|             user = User.objects.get(username=username) | ||||
|         except User.DoesNotExist: | ||||
|             self.stdout.write(self.style.ERROR(f"User {username} doesn't exist")) | ||||
|             return | ||||
|  | ||||
|         domain = "Tactical RMM" | ||||
|         nginx = "/etc/nginx/sites-available/frontend.conf" | ||||
|         found = None | ||||
|         if os.path.exists(nginx): | ||||
|             try: | ||||
|                 with open(nginx, "r") as f: | ||||
|                     for line in f: | ||||
|                         if "server_name" in line: | ||||
|                             found = line | ||||
|                             break | ||||
|  | ||||
|                 if found: | ||||
|                     rep = found.replace("server_name", "").replace(";", "") | ||||
|                     domain = "".join(rep.split()) | ||||
|             except: | ||||
|                 pass | ||||
|  | ||||
|         code = pyotp.random_base32() | ||||
|         user.totp_key = code | ||||
|         user.save(update_fields=["totp_key"]) | ||||
|  | ||||
|         url = pyotp.totp.TOTP(code).provisioning_uri(username, issuer_name=domain) | ||||
|         subprocess.run(f'qr "{url}"', shell=True) | ||||
|         self.stdout.write( | ||||
|             self.style.WARNING("Scan the barcode above with your authenticator app") | ||||
|         ) | ||||
|         self.stdout.write( | ||||
|             self.style.WARNING( | ||||
|                 f"If that doesn't work you may manually enter the setup key: {code}" | ||||
|             ) | ||||
|         ) | ||||
|         self.stdout.write( | ||||
|             self.style.SUCCESS(f"2fa was successfully reset for user {username}") | ||||
|         ) | ||||
| @@ -0,0 +1,22 @@ | ||||
| from django.core.management.base import BaseCommand | ||||
| from accounts.models import User | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Reset password for user" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument("username", type=str) | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         username = kwargs["username"] | ||||
|         try: | ||||
|             user = User.objects.get(username=username) | ||||
|         except User.DoesNotExist: | ||||
|             self.stdout.write(self.style.ERROR(f"User {username} doesn't exist")) | ||||
|             return | ||||
|  | ||||
|         passwd = input("Enter new password: ") | ||||
|         user.set_password(passwd) | ||||
|         user.save() | ||||
|         self.stdout.write(self.style.SUCCESS(f"Password for {username} was reset!")) | ||||
| @@ -2,8 +2,8 @@ | ||||
|  | ||||
| import django.contrib.auth.models | ||||
| import django.contrib.auth.validators | ||||
| from django.db import migrations, models | ||||
| import django.utils.timezone | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -6,28 +6,28 @@ from django.db import migrations, models | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0002_auto_20200810_0544'), | ||||
|         ("accounts", "0002_auto_20200810_0544"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='created_by', | ||||
|             model_name="user", | ||||
|             name="created_by", | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='created_time', | ||||
|             model_name="user", | ||||
|             name="created_time", | ||||
|             field=models.DateTimeField(auto_now_add=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='modified_by', | ||||
|             model_name="user", | ||||
|             name="modified_by", | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='modified_time', | ||||
|             model_name="user", | ||||
|             name="modified_time", | ||||
|             field=models.DateTimeField(auto_now=True, null=True), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -6,24 +6,24 @@ from django.db import migrations | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0003_auto_20200922_1344'), | ||||
|         ("accounts", "0003_auto_20200922_1344"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='user', | ||||
|             name='created_by', | ||||
|             model_name="user", | ||||
|             name="created_by", | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='user', | ||||
|             name='created_time', | ||||
|             model_name="user", | ||||
|             name="created_time", | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='user', | ||||
|             name='modified_by', | ||||
|             model_name="user", | ||||
|             name="modified_by", | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='user', | ||||
|             name='modified_time', | ||||
|             model_name="user", | ||||
|             name="modified_time", | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -6,28 +6,28 @@ from django.db import migrations, models | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0004_auto_20201002_1257'), | ||||
|         ("accounts", "0004_auto_20201002_1257"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='created_by', | ||||
|             model_name="user", | ||||
|             name="created_by", | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='created_time', | ||||
|             model_name="user", | ||||
|             name="created_time", | ||||
|             field=models.DateTimeField(auto_now_add=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='modified_by', | ||||
|             model_name="user", | ||||
|             name="modified_by", | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='modified_time', | ||||
|             model_name="user", | ||||
|             name="modified_time", | ||||
|             field=models.DateTimeField(auto_now=True, null=True), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # Generated by Django 3.1.2 on 2020-11-10 20:24 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -6,13 +6,13 @@ from django.db import migrations, models | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0007_update_agent_primary_key'), | ||||
|         ("accounts", "0007_update_agent_primary_key"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='dark_mode', | ||||
|             model_name="user", | ||||
|             name="dark_mode", | ||||
|             field=models.BooleanField(default=True), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.1.4 on 2020-12-10 17:00 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("accounts", "0008_user_dark_mode"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="show_community_scripts", | ||||
|             field=models.BooleanField(default=True), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,26 @@ | ||||
| # Generated by Django 3.1.4 on 2021-01-14 01:23 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("accounts", "0009_user_show_community_scripts"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="agent_dblclick_action", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("editagent", "Edit Agent"), | ||||
|                     ("takecontrol", "Take Control"), | ||||
|                     ("remotebg", "Remote Background"), | ||||
|                 ], | ||||
|                 default="editagent", | ||||
|                 max_length=50, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,26 @@ | ||||
| # Generated by Django 3.1.5 on 2021-01-18 09:40 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("accounts", "0010_user_agent_dblclick_action"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="default_agent_tbl_tab", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("server", "Servers"), | ||||
|                     ("workstation", "Workstations"), | ||||
|                     ("mixed", "Mixed"), | ||||
|                 ], | ||||
|                 default="server", | ||||
|                 max_length=50, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.1.7 on 2021-02-28 06:38 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0011_user_default_agent_tbl_tab'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='agents_per_page', | ||||
|             field=models.PositiveIntegerField(default=50), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.1.7 on 2021-03-09 02:33 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0012_user_agents_per_page'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='client_tree_sort', | ||||
|             field=models.CharField(choices=[('alphafail', 'Move failing clients to the top'), ('alpha', 'Sort alphabetically')], default='alphafail', max_length=50), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.2 on 2021-04-11 01:43 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0013_user_client_tree_sort'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='client_tree_splitter', | ||||
|             field=models.PositiveIntegerField(default=11), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.2 on 2021-04-11 03:03 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('accounts', '0014_user_client_tree_splitter'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='user', | ||||
|             name='loading_bar_color', | ||||
|             field=models.CharField(default='red', max_length=255), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,13 +1,43 @@ | ||||
| from django.db import models | ||||
| from django.contrib.auth.models import AbstractUser | ||||
| from django.db import models | ||||
|  | ||||
| from logs.models import BaseAuditModel | ||||
|  | ||||
| AGENT_DBLCLICK_CHOICES = [ | ||||
|     ("editagent", "Edit Agent"), | ||||
|     ("takecontrol", "Take Control"), | ||||
|     ("remotebg", "Remote Background"), | ||||
| ] | ||||
|  | ||||
| AGENT_TBL_TAB_CHOICES = [ | ||||
|     ("server", "Servers"), | ||||
|     ("workstation", "Workstations"), | ||||
|     ("mixed", "Mixed"), | ||||
| ] | ||||
|  | ||||
| CLIENT_TREE_SORT_CHOICES = [ | ||||
|     ("alphafail", "Move failing clients to the top"), | ||||
|     ("alpha", "Sort alphabetically"), | ||||
| ] | ||||
|  | ||||
|  | ||||
| class User(AbstractUser, BaseAuditModel): | ||||
|     is_active = models.BooleanField(default=True) | ||||
|     totp_key = models.CharField(max_length=50, null=True, blank=True) | ||||
|     dark_mode = models.BooleanField(default=True) | ||||
|     show_community_scripts = models.BooleanField(default=True) | ||||
|     agent_dblclick_action = models.CharField( | ||||
|         max_length=50, choices=AGENT_DBLCLICK_CHOICES, default="editagent" | ||||
|     ) | ||||
|     default_agent_tbl_tab = models.CharField( | ||||
|         max_length=50, choices=AGENT_TBL_TAB_CHOICES, default="server" | ||||
|     ) | ||||
|     agents_per_page = models.PositiveIntegerField(default=50)  # not currently used | ||||
|     client_tree_sort = models.CharField( | ||||
|         max_length=50, choices=CLIENT_TREE_SORT_CHOICES, default="alphafail" | ||||
|     ) | ||||
|     client_tree_splitter = models.PositiveIntegerField(default=11) | ||||
|     loading_bar_color = models.CharField(max_length=255, default="red") | ||||
|  | ||||
|     agent = models.OneToOneField( | ||||
|         "agents.Agent", | ||||
|   | ||||
| @@ -1,13 +1,23 @@ | ||||
| import pyotp | ||||
|  | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
| from rest_framework.serializers import ModelSerializer, SerializerMethodField | ||||
|  | ||||
| from .models import User | ||||
|  | ||||
|  | ||||
| class UserUISerializer(ModelSerializer): | ||||
|     class Meta: | ||||
|         model = User | ||||
|         fields = [ | ||||
|             "dark_mode", | ||||
|             "show_community_scripts", | ||||
|             "agent_dblclick_action", | ||||
|             "default_agent_tbl_tab", | ||||
|             "client_tree_sort", | ||||
|             "client_tree_splitter", | ||||
|             "loading_bar_color", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class UserSerializer(ModelSerializer): | ||||
|     class Meta: | ||||
|         model = User | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from django.test import override_settings | ||||
|  | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
| from accounts.models import User | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
|  | ||||
| class TestAccounts(TacticalTestCase): | ||||
| @@ -155,6 +156,33 @@ class GetUpdateDeleteUser(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("put", url) | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_put_root_user(self): | ||||
|         url = f"/accounts/{self.john.pk}/users/" | ||||
|         data = { | ||||
|             "id": self.john.pk, | ||||
|             "username": "john", | ||||
|             "email": "johndoe@xlawgaming.com", | ||||
|             "first_name": "John", | ||||
|             "last_name": "Doe", | ||||
|         } | ||||
|         r = self.client.put(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_put_not_root_user(self): | ||||
|         url = f"/accounts/{self.john.pk}/users/" | ||||
|         data = { | ||||
|             "id": self.john.pk, | ||||
|             "username": "john", | ||||
|             "email": "johndoe@xlawgaming.com", | ||||
|             "first_name": "John", | ||||
|             "last_name": "Doe", | ||||
|         } | ||||
|         self.client.force_authenticate(user=self.alice) | ||||
|         r = self.client.put(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|     def test_delete(self): | ||||
|         url = f"/accounts/{self.john.pk}/users/" | ||||
|         r = self.client.delete(url) | ||||
| @@ -166,6 +194,19 @@ class GetUpdateDeleteUser(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("delete", url) | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_delete_root_user(self): | ||||
|         url = f"/accounts/{self.john.pk}/users/" | ||||
|         r = self.client.delete(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_delete_non_root_user(self): | ||||
|         url = f"/accounts/{self.john.pk}/users/" | ||||
|         self.client.force_authenticate(user=self.alice) | ||||
|         r = self.client.delete(url) | ||||
|         self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|  | ||||
| class TestUserAction(TacticalTestCase): | ||||
|     def setUp(self): | ||||
| @@ -184,6 +225,21 @@ class TestUserAction(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_post_root_user(self): | ||||
|         url = "/accounts/users/reset/" | ||||
|         data = {"id": self.john.pk, "password": "3ASDjh2345kJA!@#)#@__123"} | ||||
|         r = self.client.post(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_post_non_root_user(self): | ||||
|         url = "/accounts/users/reset/" | ||||
|         data = {"id": self.john.pk, "password": "3ASDjh2345kJA!@#)#@__123"} | ||||
|         self.client.force_authenticate(user=self.alice) | ||||
|         r = self.client.post(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|     def test_put(self): | ||||
|         url = "/accounts/users/reset/" | ||||
|         data = {"id": self.john.pk} | ||||
| @@ -195,9 +251,36 @@ class TestUserAction(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("put", url) | ||||
|  | ||||
|     def test_darkmode(self): | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_put_root_user(self): | ||||
|         url = "/accounts/users/reset/" | ||||
|         data = {"id": self.john.pk} | ||||
|         r = self.client.put(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|         user = User.objects.get(pk=self.john.pk) | ||||
|         self.assertEqual(user.totp_key, "") | ||||
|  | ||||
|     @override_settings(ROOT_USER="john") | ||||
|     def test_put_non_root_user(self): | ||||
|         url = "/accounts/users/reset/" | ||||
|         data = {"id": self.john.pk} | ||||
|         self.client.force_authenticate(user=self.alice) | ||||
|         r = self.client.put(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|     def test_user_ui(self): | ||||
|         url = "/accounts/users/ui/" | ||||
|         data = {"dark_mode": False} | ||||
|  | ||||
|         data = { | ||||
|             "dark_mode": True, | ||||
|             "show_community_scripts": True, | ||||
|             "agent_dblclick_action": "editagent", | ||||
|             "default_agent_tbl_tab": "mixed", | ||||
|             "client_tree_sort": "alpha", | ||||
|             "client_tree_splitter": 14, | ||||
|             "loading_bar_color": "green", | ||||
|         } | ||||
|         r = self.client.patch(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from . import views | ||||
|  | ||||
| urlpatterns = [ | ||||
|   | ||||
| @@ -1,23 +1,28 @@ | ||||
| import pyotp | ||||
|  | ||||
| from django.contrib.auth import login | ||||
| from django.conf import settings | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.contrib.auth import login | ||||
| from django.db import IntegrityError | ||||
|  | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.authtoken.serializers import AuthTokenSerializer | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from knox.views import LoginView as KnoxLoginView | ||||
| from rest_framework import status | ||||
| from rest_framework.authtoken.serializers import AuthTokenSerializer | ||||
| from rest_framework.permissions import AllowAny | ||||
| from rest_framework.response import Response | ||||
| from rest_framework import status | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from .models import User | ||||
| from agents.models import Agent | ||||
| from logs.models import AuditLog | ||||
| from tacticalrmm.utils import notify_error | ||||
|  | ||||
| from .serializers import UserSerializer, TOTPSetupSerializer | ||||
| from .models import User | ||||
| from .serializers import TOTPSetupSerializer, UserSerializer, UserUISerializer | ||||
|  | ||||
|  | ||||
| def _is_root_user(request, user) -> bool: | ||||
|     return ( | ||||
|         hasattr(settings, "ROOT_USER") | ||||
|         and request.user != user | ||||
|         and user.username == settings.ROOT_USER | ||||
|     ) | ||||
|  | ||||
|  | ||||
| class CheckCreds(KnoxLoginView): | ||||
| @@ -60,7 +65,7 @@ class LoginView(KnoxLoginView): | ||||
|  | ||||
|         if settings.DEBUG and token == "sekret": | ||||
|             valid = True | ||||
|         elif totp.verify(token, valid_window=1): | ||||
|         elif totp.verify(token, valid_window=10): | ||||
|             valid = True | ||||
|  | ||||
|         if valid: | ||||
| @@ -81,7 +86,7 @@ class GetAddUsers(APIView): | ||||
|     def post(self, request): | ||||
|         # add new user | ||||
|         try: | ||||
|             user = User.objects.create_user( | ||||
|             user = User.objects.create_user(  # type: ignore | ||||
|                 request.data["username"], | ||||
|                 request.data["email"], | ||||
|                 request.data["password"], | ||||
| @@ -108,6 +113,9 @@ class GetUpdateDeleteUser(APIView): | ||||
|     def put(self, request, pk): | ||||
|         user = get_object_or_404(User, pk=pk) | ||||
|  | ||||
|         if _is_root_user(request, user): | ||||
|             return notify_error("The root user cannot be modified from the UI") | ||||
|  | ||||
|         serializer = UserSerializer(instance=user, data=request.data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
| @@ -115,7 +123,11 @@ class GetUpdateDeleteUser(APIView): | ||||
|         return Response("ok") | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         get_object_or_404(User, pk=pk).delete() | ||||
|         user = get_object_or_404(User, pk=pk) | ||||
|         if _is_root_user(request, user): | ||||
|             return notify_error("The root user cannot be deleted from the UI") | ||||
|  | ||||
|         user.delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
| @@ -124,8 +136,10 @@ class UserActions(APIView): | ||||
|  | ||||
|     # reset password | ||||
|     def post(self, request): | ||||
|  | ||||
|         user = get_object_or_404(User, pk=request.data["id"]) | ||||
|         if _is_root_user(request, user): | ||||
|             return notify_error("The root user cannot be modified from the UI") | ||||
|  | ||||
|         user.set_password(request.data["password"]) | ||||
|         user.save() | ||||
|  | ||||
| @@ -133,8 +147,10 @@ class UserActions(APIView): | ||||
|  | ||||
|     # reset two factor token | ||||
|     def put(self, request): | ||||
|  | ||||
|         user = get_object_or_404(User, pk=request.data["id"]) | ||||
|         if _is_root_user(request, user): | ||||
|             return notify_error("The root user cannot be modified from the UI") | ||||
|  | ||||
|         user.totp_key = "" | ||||
|         user.save() | ||||
|  | ||||
| @@ -160,7 +176,9 @@ class TOTPSetup(APIView): | ||||
|  | ||||
| class UserUI(APIView): | ||||
|     def patch(self, request): | ||||
|         user = request.user | ||||
|         user.dark_mode = request.data["dark_mode"] | ||||
|         user.save(update_fields=["dark_mode"]) | ||||
|         return Response("ok") | ||||
|         serializer = UserUISerializer( | ||||
|             instance=request.user, data=request.data, partial=True | ||||
|         ) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|         return Response("ok") | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| from .models import Agent, AgentOutage, RecoveryAction, Note | ||||
| from .models import Agent, AgentCustomField, Note, RecoveryAction | ||||
|  | ||||
| admin.site.register(Agent) | ||||
| admin.site.register(AgentOutage) | ||||
| admin.site.register(RecoveryAction) | ||||
| admin.site.register(Note) | ||||
| admin.site.register(AgentCustomField) | ||||
|   | ||||
| @@ -1,14 +1,12 @@ | ||||
| import json | ||||
| import os | ||||
| import random | ||||
| import string | ||||
| import os | ||||
| import json | ||||
|  | ||||
| from model_bakery.recipe import Recipe, seq | ||||
| from itertools import cycle | ||||
| from django.utils import timezone as djangotime | ||||
| from django.conf import settings | ||||
|  | ||||
| from .models import Agent | ||||
| from django.conf import settings | ||||
| from django.utils import timezone as djangotime | ||||
| from model_bakery.recipe import Recipe, foreign_key, seq | ||||
|  | ||||
|  | ||||
| def generate_agent_id(hostname): | ||||
| @@ -16,6 +14,9 @@ def generate_agent_id(hostname): | ||||
|     return f"{rand}-{hostname}" | ||||
|  | ||||
|  | ||||
| site = Recipe("clients.Site") | ||||
|  | ||||
|  | ||||
| def get_wmi_data(): | ||||
|     with open( | ||||
|         os.path.join(settings.BASE_DIR, "tacticalrmm/test_data/wmi_python_agent.json") | ||||
| @@ -24,12 +25,12 @@ def get_wmi_data(): | ||||
|  | ||||
|  | ||||
| agent = Recipe( | ||||
|     Agent, | ||||
|     "agents.Agent", | ||||
|     site=foreign_key(site), | ||||
|     hostname="DESKTOP-TEST123", | ||||
|     version="1.1.0", | ||||
|     version="1.3.0", | ||||
|     monitoring_type=cycle(["workstation", "server"]), | ||||
|     salt_id=generate_agent_id("DESKTOP-TEST123"), | ||||
|     agent_id="71AHC-AA813-HH1BC-AAHH5-00013|DESKTOP-TEST123", | ||||
|     agent_id=seq("asdkj3h4234-1234hg3h4g34-234jjh34|DESKTOP-TEST123"), | ||||
| ) | ||||
|  | ||||
| server_agent = agent.extend( | ||||
| @@ -42,8 +43,12 @@ workstation_agent = agent.extend( | ||||
|  | ||||
| online_agent = agent.extend(last_seen=djangotime.now()) | ||||
|  | ||||
| offline_agent = agent.extend( | ||||
|     last_seen=djangotime.now() - djangotime.timedelta(minutes=7) | ||||
| ) | ||||
|  | ||||
| overdue_agent = agent.extend( | ||||
|     last_seen=djangotime.now() - djangotime.timedelta(minutes=6) | ||||
|     last_seen=djangotime.now() - djangotime.timedelta(minutes=35) | ||||
| ) | ||||
|  | ||||
| agent_with_services = agent.extend( | ||||
|   | ||||
| @@ -0,0 +1,93 @@ | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from agents.models import Agent | ||||
| from clients.models import Client, Site | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Bulk update agent offline/overdue time" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument("time", type=int, help="Time in minutes") | ||||
|         parser.add_argument( | ||||
|             "--client", | ||||
|             type=str, | ||||
|             help="Client Name", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--site", | ||||
|             type=str, | ||||
|             help="Site Name", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--offline", | ||||
|             action="store_true", | ||||
|             help="Offline", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--overdue", | ||||
|             action="store_true", | ||||
|             help="Overdue", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--all", | ||||
|             action="store_true", | ||||
|             help="All agents", | ||||
|         ) | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         time = kwargs["time"] | ||||
|         client_name = kwargs["client"] | ||||
|         site_name = kwargs["site"] | ||||
|         all_agents = kwargs["all"] | ||||
|         offline = kwargs["offline"] | ||||
|         overdue = kwargs["overdue"] | ||||
|         agents = None | ||||
|  | ||||
|         if offline and time < 2: | ||||
|             self.stdout.write(self.style.ERROR("Minimum offline time is 2 minutes")) | ||||
|             return | ||||
|  | ||||
|         if overdue and time < 3: | ||||
|             self.stdout.write(self.style.ERROR("Minimum overdue time is 3 minutes")) | ||||
|             return | ||||
|  | ||||
|         if client_name: | ||||
|             try: | ||||
|                 client = Client.objects.get(name=client_name) | ||||
|             except Client.DoesNotExist: | ||||
|                 self.stdout.write( | ||||
|                     self.style.ERROR(f"Client {client_name} doesn't exist") | ||||
|                 ) | ||||
|                 return | ||||
|  | ||||
|             agents = Agent.objects.filter(site__client=client) | ||||
|  | ||||
|         elif site_name: | ||||
|             try: | ||||
|                 site = Site.objects.get(name=site_name) | ||||
|             except Site.DoesNotExist: | ||||
|                 self.stdout.write(self.style.ERROR(f"Site {site_name} doesn't exist")) | ||||
|                 return | ||||
|  | ||||
|             agents = Agent.objects.filter(site=site) | ||||
|  | ||||
|         elif all_agents: | ||||
|             agents = Agent.objects.all() | ||||
|  | ||||
|         if agents: | ||||
|             if offline: | ||||
|                 agents.update(offline_time=time) | ||||
|                 self.stdout.write( | ||||
|                     self.style.SUCCESS( | ||||
|                         f"Changed offline time on {len(agents)} agents to {time} minutes" | ||||
|                     ) | ||||
|                 ) | ||||
|  | ||||
|             if overdue: | ||||
|                 agents.update(overdue_time=time) | ||||
|                 self.stdout.write( | ||||
|                     self.style.SUCCESS( | ||||
|                         f"Changed overdue time on {len(agents)} agents to {time} minutes" | ||||
|                     ) | ||||
|                 ) | ||||
| @@ -0,0 +1,18 @@ | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from agents.models import Agent | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Shows online agents that are not on the latest version" | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         q = Agent.objects.exclude(version=settings.LATEST_AGENT_VER).only( | ||||
|             "pk", "version", "last_seen", "overdue_time", "offline_time" | ||||
|         ) | ||||
|         agents = [i for i in q if i.status == "online"] | ||||
|         for agent in agents: | ||||
|             self.stdout.write( | ||||
|                 self.style.SUCCESS(f"{agent.hostname} - v{agent.version}") | ||||
|             ) | ||||
| @@ -1,8 +1,8 @@ | ||||
| # Generated by Django 3.0.6 on 2020-05-31 01:23 | ||||
|  | ||||
| import django.contrib.postgres.fields.jsonb | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # Generated by Django 3.0.7 on 2020-06-09 16:07 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # Generated by Django 3.0.8 on 2020-08-09 05:31 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| # Generated by Django 3.1.1 on 2020-09-22 20:57 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -1,20 +1,26 @@ | ||||
| # Generated by Django 3.1.2 on 2020-11-01 22:53 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('clients', '0006_deployment'), | ||||
|         ('agents', '0020_auto_20201025_2129'), | ||||
|         ("clients", "0006_deployment"), | ||||
|         ("agents", "0020_auto_20201025_2129"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agent', | ||||
|             name='site_link', | ||||
|             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='agents', to='clients.site'), | ||||
|             model_name="agent", | ||||
|             name="site_link", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="agents", | ||||
|                 to="clients.site", | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -6,16 +6,16 @@ from django.db import migrations | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0022_update_site_primary_key'), | ||||
|         ("agents", "0022_update_site_primary_key"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='agent', | ||||
|             name='client', | ||||
|             model_name="agent", | ||||
|             name="client", | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='agent', | ||||
|             name='site', | ||||
|             model_name="agent", | ||||
|             name="site", | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -6,13 +6,13 @@ from django.db import migrations | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0023_auto_20201101_2312'), | ||||
|         ("agents", "0023_auto_20201101_2312"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RenameField( | ||||
|             model_name='agent', | ||||
|             old_name='site_link', | ||||
|             new_name='site', | ||||
|             model_name="agent", | ||||
|             old_name="site_link", | ||||
|             new_name="site", | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -6,13 +6,22 @@ from django.db import migrations, models | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0024_auto_20201101_2319'), | ||||
|         ("agents", "0024_auto_20201101_2319"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='recoveryaction', | ||||
|             name='mode', | ||||
|             field=models.CharField(choices=[('salt', 'Salt'), ('mesh', 'Mesh'), ('command', 'Command'), ('rpc', 'Nats RPC')], default='mesh', max_length=50), | ||||
|             model_name="recoveryaction", | ||||
|             name="mode", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("salt", "Salt"), | ||||
|                     ("mesh", "Mesh"), | ||||
|                     ("command", "Command"), | ||||
|                     ("rpc", "Nats RPC"), | ||||
|                 ], | ||||
|                 default="mesh", | ||||
|                 max_length=50, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -6,13 +6,23 @@ from django.db import migrations, models | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0025_auto_20201122_0407'), | ||||
|         ("agents", "0025_auto_20201122_0407"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='recoveryaction', | ||||
|             name='mode', | ||||
|             field=models.CharField(choices=[('salt', 'Salt'), ('mesh', 'Mesh'), ('command', 'Command'), ('rpc', 'Nats RPC'), ('checkrunner', 'Checkrunner')], default='mesh', max_length=50), | ||||
|             model_name="recoveryaction", | ||||
|             name="mode", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("salt", "Salt"), | ||||
|                     ("mesh", "Mesh"), | ||||
|                     ("command", "Command"), | ||||
|                     ("rpc", "Nats RPC"), | ||||
|                     ("checkrunner", "Checkrunner"), | ||||
|                 ], | ||||
|                 default="mesh", | ||||
|                 max_length=50, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.1.4 on 2021-01-29 21:11 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0026_auto_20201125_2334'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agent', | ||||
|             name='overdue_dashboard_alert', | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										23
									
								
								api/tacticalrmm/agents/migrations/0028_auto_20210206_1534.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/tacticalrmm/agents/migrations/0028_auto_20210206_1534.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 3.1.4 on 2021-02-06 15:34 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0027_agent_overdue_dashboard_alert'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agentoutage', | ||||
|             name='outage_email_sent_time', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='agentoutage', | ||||
|             name='outage_sms_sent_time', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										16
									
								
								api/tacticalrmm/agents/migrations/0029_delete_agentoutage.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								api/tacticalrmm/agents/migrations/0029_delete_agentoutage.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| # Generated by Django 3.1.4 on 2021-02-10 21:56 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0028_auto_20210206_1534'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.DeleteModel( | ||||
|             name='AgentOutage', | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										18
									
								
								api/tacticalrmm/agents/migrations/0030_agent_offline_time.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								api/tacticalrmm/agents/migrations/0030_agent_offline_time.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.1.6 on 2021-02-16 08:50 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0029_delete_agentoutage'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agent', | ||||
|             name='offline_time', | ||||
|             field=models.PositiveIntegerField(default=4), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,20 @@ | ||||
| # Generated by Django 3.1.7 on 2021-03-04 03:57 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('alerts', '0006_auto_20210217_1736'), | ||||
|         ('agents', '0030_agent_offline_time'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agent', | ||||
|             name='alert_template', | ||||
|             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='agents', to='alerts.alerttemplate'), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										24
									
								
								api/tacticalrmm/agents/migrations/0032_agentcustomfield.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								api/tacticalrmm/agents/migrations/0032_agentcustomfield.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| # Generated by Django 3.1.7 on 2021-03-17 14:45 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('core', '0014_customfield'), | ||||
|         ('agents', '0031_agent_alert_template'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='AgentCustomField', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('value', models.TextField(blank=True, null=True)), | ||||
|                 ('agent', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='custom_fields', to='agents.agent')), | ||||
|                 ('field', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='agent_fields', to='core.customfield')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,19 @@ | ||||
| # Generated by Django 3.1.7 on 2021-03-29 02:51 | ||||
|  | ||||
| import django.contrib.postgres.fields | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0032_agentcustomfield'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agentcustomfield', | ||||
|             name='multiple_value', | ||||
|             field=django.contrib.postgres.fields.ArrayField(base_field=models.TextField(blank=True, null=True), blank=True, default=list, null=True, size=None), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 3.1.7 on 2021-03-29 03:01 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0033_agentcustomfield_multiple_value'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='agentcustomfield', | ||||
|             name='checkbox_value', | ||||
|             field=models.BooleanField(blank=True, default=False), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										23
									
								
								api/tacticalrmm/agents/migrations/0035_auto_20210329_1709.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								api/tacticalrmm/agents/migrations/0035_auto_20210329_1709.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 3.1.7 on 2021-03-29 17:09 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0034_agentcustomfield_checkbox_value'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RenameField( | ||||
|             model_name='agentcustomfield', | ||||
|             old_name='checkbox_value', | ||||
|             new_name='bool_value', | ||||
|         ), | ||||
|         migrations.RenameField( | ||||
|             model_name='agentcustomfield', | ||||
|             old_name='value', | ||||
|             new_name='string_value', | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,28 +1,26 @@ | ||||
| import requests | ||||
| import datetime as dt | ||||
| import time | ||||
| import asyncio | ||||
| import base64 | ||||
| from Crypto.Cipher import AES | ||||
| from Crypto.Random import get_random_bytes | ||||
| from Crypto.Hash import SHA3_384 | ||||
| from Crypto.Util.Padding import pad | ||||
| import validators | ||||
| import msgpack | ||||
| import random | ||||
| import re | ||||
| import string | ||||
| import time | ||||
| from collections import Counter | ||||
| from loguru import logger | ||||
| from packaging import version as pyver | ||||
| from distutils.version import LooseVersion | ||||
| from typing import Any | ||||
|  | ||||
| import msgpack | ||||
| import validators | ||||
| from Crypto.Cipher import AES | ||||
| from Crypto.Hash import SHA3_384 | ||||
| from Crypto.Random import get_random_bytes | ||||
| from Crypto.Util.Padding import pad | ||||
| from django.conf import settings | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| from django.db import models | ||||
| from django.utils import timezone as djangotime | ||||
| from loguru import logger | ||||
| from nats.aio.client import Client as NATS | ||||
| from nats.aio.errors import ErrTimeout | ||||
|  | ||||
| from django.db import models | ||||
| from django.conf import settings | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from core.models import CoreSettings, TZ_CHOICES | ||||
| from core.models import TZ_CHOICES, CoreSettings | ||||
| from logs.models import BaseAuditModel | ||||
|  | ||||
| logger.configure(**settings.LOG_CONFIG) | ||||
| @@ -53,6 +51,8 @@ class Agent(BaseAuditModel): | ||||
|     mesh_node_id = models.CharField(null=True, blank=True, max_length=255) | ||||
|     overdue_email_alert = models.BooleanField(default=False) | ||||
|     overdue_text_alert = models.BooleanField(default=False) | ||||
|     overdue_dashboard_alert = models.BooleanField(default=False) | ||||
|     offline_time = models.PositiveIntegerField(default=4) | ||||
|     overdue_time = models.PositiveIntegerField(default=30) | ||||
|     check_interval = models.PositiveIntegerField(default=120) | ||||
|     needs_reboot = models.BooleanField(default=False) | ||||
| @@ -63,6 +63,13 @@ class Agent(BaseAuditModel): | ||||
|         max_length=255, choices=TZ_CHOICES, null=True, blank=True | ||||
|     ) | ||||
|     maintenance_mode = models.BooleanField(default=False) | ||||
|     alert_template = models.ForeignKey( | ||||
|         "alerts.AlertTemplate", | ||||
|         related_name="agents", | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     site = models.ForeignKey( | ||||
|         "clients.Site", | ||||
|         related_name="agents", | ||||
| @@ -78,6 +85,24 @@ class Agent(BaseAuditModel): | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|  | ||||
|         # get old agent if exists | ||||
|         old_agent = type(self).objects.get(pk=self.pk) if self.pk else None | ||||
|         super(BaseAuditModel, self).save(*args, **kwargs) | ||||
|  | ||||
|         # check if new agent has been created | ||||
|         # or check if policy have changed on agent | ||||
|         # or if site has changed on agent and if so generate-policies | ||||
|         if ( | ||||
|             not old_agent | ||||
|             or old_agent | ||||
|             and old_agent.policy != self.policy | ||||
|             or old_agent.site != self.site | ||||
|         ): | ||||
|             self.generate_checks_from_policies() | ||||
|             self.generate_tasks_from_policies() | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.hostname | ||||
|  | ||||
| @@ -85,10 +110,6 @@ class Agent(BaseAuditModel): | ||||
|     def client(self): | ||||
|         return self.site.client | ||||
|  | ||||
|     @property | ||||
|     def has_nats(self): | ||||
|         return pyver.parse(self.version) >= pyver.parse("1.1.0") | ||||
|  | ||||
|     @property | ||||
|     def timezone(self): | ||||
|         # return the default timezone unless the timezone is explicity set per agent | ||||
| @@ -116,14 +137,6 @@ class Agent(BaseAuditModel): | ||||
|             return settings.DL_32 | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def winsalt_dl(self): | ||||
|         if self.arch == "64": | ||||
|             return settings.SALT_64 | ||||
|         elif self.arch == "32": | ||||
|             return settings.SALT_32 | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def win_inno_exe(self): | ||||
|         if self.arch == "64": | ||||
| @@ -134,7 +147,7 @@ class Agent(BaseAuditModel): | ||||
|  | ||||
|     @property | ||||
|     def status(self): | ||||
|         offline = djangotime.now() - djangotime.timedelta(minutes=6) | ||||
|         offline = djangotime.now() - djangotime.timedelta(minutes=self.offline_time) | ||||
|         overdue = djangotime.now() - djangotime.timedelta(minutes=self.overdue_time) | ||||
|  | ||||
|         if self.last_seen is not None: | ||||
| @@ -149,27 +162,25 @@ class Agent(BaseAuditModel): | ||||
|  | ||||
|     @property | ||||
|     def has_patches_pending(self): | ||||
|         return self.winupdates.filter(action="approve").filter(installed=False).exists() | ||||
|         return self.winupdates.filter(action="approve").filter(installed=False).exists()  # type: ignore | ||||
|  | ||||
|     @property | ||||
|     def checks(self): | ||||
|         total, passing, failing = 0, 0, 0 | ||||
|  | ||||
|         if self.agentchecks.exists(): | ||||
|             for i in self.agentchecks.all(): | ||||
|         if self.agentchecks.exists():  # type: ignore | ||||
|             for i in self.agentchecks.all():  # type: ignore | ||||
|                 total += 1 | ||||
|                 if i.status == "passing": | ||||
|                     passing += 1 | ||||
|                 elif i.status == "failing": | ||||
|                     failing += 1 | ||||
|  | ||||
|         has_failing_checks = True if failing > 0 else False | ||||
|  | ||||
|         ret = { | ||||
|             "total": total, | ||||
|             "passing": passing, | ||||
|             "failing": failing, | ||||
|             "has_failing_checks": has_failing_checks, | ||||
|             "has_failing_checks": failing > 0, | ||||
|         } | ||||
|         return ret | ||||
|  | ||||
| @@ -184,6 +195,27 @@ class Agent(BaseAuditModel): | ||||
|         except: | ||||
|             return ["unknown cpu model"] | ||||
|  | ||||
|     @property | ||||
|     def graphics(self): | ||||
|         ret, mrda = [], [] | ||||
|         try: | ||||
|             graphics = self.wmi_detail["graphics"] | ||||
|             for i in graphics: | ||||
|                 caption = [x["Caption"] for x in i if "Caption" in x][0] | ||||
|                 if "microsoft remote display adapter" in caption.lower(): | ||||
|                     mrda.append("yes") | ||||
|                     continue | ||||
|  | ||||
|                 ret.append([x["Caption"] for x in i if "Caption" in x][0]) | ||||
|  | ||||
|             # only return this if no other graphics cards | ||||
|             if not ret and mrda: | ||||
|                 return "Microsoft Remote Display Adapter" | ||||
|  | ||||
|             return ", ".join(ret) | ||||
|         except: | ||||
|             return "Graphics info requires agent v1.4.14" | ||||
|  | ||||
|     @property | ||||
|     def local_ips(self): | ||||
|         ret = [] | ||||
| @@ -228,6 +260,7 @@ class Agent(BaseAuditModel): | ||||
|             pass | ||||
|  | ||||
|         try: | ||||
|             comp_sys_prod = self.wmi_detail["comp_sys_prod"][0] | ||||
|             return [x["Version"] for x in comp_sys_prod if "Version" in x][0] | ||||
|         except: | ||||
|             pass | ||||
| @@ -257,33 +290,107 @@ class Agent(BaseAuditModel): | ||||
|         except: | ||||
|             return ["unknown disk"] | ||||
|  | ||||
|     def check_run_interval(self) -> int: | ||||
|         interval = self.check_interval | ||||
|         # determine if any agent checks have a custom interval and set the lowest interval | ||||
|         for check in self.agentchecks.filter(overriden_by_policy=False):  # type: ignore | ||||
|             if check.run_interval and check.run_interval < interval: | ||||
|  | ||||
|                 # don't allow check runs less than 15s | ||||
|                 if check.run_interval < 15: | ||||
|                     interval = 15 | ||||
|                 else: | ||||
|                     interval = check.run_interval | ||||
|  | ||||
|         return interval | ||||
|  | ||||
|     def run_script( | ||||
|         self, | ||||
|         scriptpk: int, | ||||
|         args: list[str] = [], | ||||
|         timeout: int = 120, | ||||
|         full: bool = False, | ||||
|         wait: bool = False, | ||||
|         run_on_any: bool = False, | ||||
|     ) -> Any: | ||||
|  | ||||
|         from scripts.models import Script | ||||
|  | ||||
|         script = Script.objects.get(pk=scriptpk) | ||||
|  | ||||
|         parsed_args = script.parse_script_args(self, script.shell, args) | ||||
|  | ||||
|         data = { | ||||
|             "func": "runscriptfull" if full else "runscript", | ||||
|             "timeout": timeout, | ||||
|             "script_args": parsed_args, | ||||
|             "payload": { | ||||
|                 "code": script.code, | ||||
|                 "shell": script.shell, | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|         running_agent = self | ||||
|         if run_on_any: | ||||
|             nats_ping = {"func": "ping"} | ||||
|  | ||||
|             # try on self first | ||||
|             r = asyncio.run(self.nats_cmd(nats_ping, timeout=1)) | ||||
|  | ||||
|             if r == "pong": | ||||
|                 running_agent = self | ||||
|             else: | ||||
|                 online = [ | ||||
|                     agent | ||||
|                     for agent in Agent.objects.only( | ||||
|                         "pk", "agent_id", "last_seen", "overdue_time", "offline_time" | ||||
|                     ) | ||||
|                     if agent.status == "online" | ||||
|                 ] | ||||
|  | ||||
|                 for agent in online: | ||||
|                     r = asyncio.run(agent.nats_cmd(nats_ping, timeout=1)) | ||||
|                     if r == "pong": | ||||
|                         running_agent = agent | ||||
|                         break | ||||
|  | ||||
|                 if running_agent.pk == self.pk: | ||||
|                     return "Unable to find an online agent" | ||||
|  | ||||
|         if wait: | ||||
|             return asyncio.run(running_agent.nats_cmd(data, timeout=timeout, wait=True)) | ||||
|         else: | ||||
|             asyncio.run(running_agent.nats_cmd(data, wait=False)) | ||||
|  | ||||
|         return "ok" | ||||
|  | ||||
|     # auto approves updates | ||||
|     def approve_updates(self): | ||||
|         patch_policy = self.get_patch_policy() | ||||
|  | ||||
|         updates = list() | ||||
|         if patch_policy.critical == "approve": | ||||
|             updates += self.winupdates.filter( | ||||
|             updates += self.winupdates.filter(  # type: ignore | ||||
|                 severity="Critical", installed=False | ||||
|             ).exclude(action="approve") | ||||
|  | ||||
|         if patch_policy.important == "approve": | ||||
|             updates += self.winupdates.filter( | ||||
|             updates += self.winupdates.filter(  # type: ignore | ||||
|                 severity="Important", installed=False | ||||
|             ).exclude(action="approve") | ||||
|  | ||||
|         if patch_policy.moderate == "approve": | ||||
|             updates += self.winupdates.filter( | ||||
|             updates += self.winupdates.filter(  # type: ignore | ||||
|                 severity="Moderate", installed=False | ||||
|             ).exclude(action="approve") | ||||
|  | ||||
|         if patch_policy.low == "approve": | ||||
|             updates += self.winupdates.filter(severity="Low", installed=False).exclude( | ||||
|             updates += self.winupdates.filter(severity="Low", installed=False).exclude(  # type: ignore | ||||
|                 action="approve" | ||||
|             ) | ||||
|  | ||||
|         if patch_policy.other == "approve": | ||||
|             updates += self.winupdates.filter(severity="", installed=False).exclude( | ||||
|             updates += self.winupdates.filter(severity="", installed=False).exclude(  # type: ignore | ||||
|                 action="approve" | ||||
|             ) | ||||
|  | ||||
| @@ -298,7 +405,7 @@ class Agent(BaseAuditModel): | ||||
|         site = self.site | ||||
|         core_settings = CoreSettings.objects.first() | ||||
|         patch_policy = None | ||||
|         agent_policy = self.winupdatepolicy.get() | ||||
|         agent_policy = self.winupdatepolicy.get()  # type: ignore | ||||
|  | ||||
|         if self.monitoring_type == "server": | ||||
|             # check agent policy first which should override client or site policy | ||||
| @@ -383,32 +490,139 @@ class Agent(BaseAuditModel): | ||||
|  | ||||
|         return patch_policy | ||||
|  | ||||
|     # clear is used to delete managed policy checks from agent | ||||
|     # parent_checks specifies a list of checks to delete from agent with matching parent_check field | ||||
|     def generate_checks_from_policies(self, clear=False): | ||||
|     def get_approved_update_guids(self) -> list[str]: | ||||
|         return list( | ||||
|             self.winupdates.filter(action="approve", installed=False).values_list(  # type: ignore | ||||
|                 "guid", flat=True | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     # sets alert template assigned in the following order: policy, site, client, global | ||||
|     # sets None if nothing is found | ||||
|     def set_alert_template(self): | ||||
|  | ||||
|         site = self.site | ||||
|         client = self.client | ||||
|         core = CoreSettings.objects.first() | ||||
|  | ||||
|         templates = list() | ||||
|         # check if alert template is on a policy assigned to agent | ||||
|         if ( | ||||
|             self.policy | ||||
|             and self.policy.alert_template | ||||
|             and self.policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(self.policy.alert_template) | ||||
|  | ||||
|         # check if policy with alert template is assigned to the site | ||||
|         if ( | ||||
|             self.monitoring_type == "server" | ||||
|             and site.server_policy | ||||
|             and site.server_policy.alert_template | ||||
|             and site.server_policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(site.server_policy.alert_template) | ||||
|         if ( | ||||
|             self.monitoring_type == "workstation" | ||||
|             and site.workstation_policy | ||||
|             and site.workstation_policy.alert_template | ||||
|             and site.workstation_policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(site.workstation_policy.alert_template) | ||||
|  | ||||
|         # check if alert template is assigned to site | ||||
|         if site.alert_template and site.alert_template.is_active: | ||||
|             templates.append(site.alert_template) | ||||
|  | ||||
|         # check if policy with alert template is assigned to the client | ||||
|         if ( | ||||
|             self.monitoring_type == "server" | ||||
|             and client.server_policy | ||||
|             and client.server_policy.alert_template | ||||
|             and client.server_policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(client.server_policy.alert_template) | ||||
|         if ( | ||||
|             self.monitoring_type == "workstation" | ||||
|             and client.workstation_policy | ||||
|             and client.workstation_policy.alert_template | ||||
|             and client.workstation_policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(client.workstation_policy.alert_template) | ||||
|  | ||||
|         # check if alert template is on client and return | ||||
|         if client.alert_template and client.alert_template.is_active: | ||||
|             templates.append(client.alert_template) | ||||
|  | ||||
|         # check if alert template is applied globally and return | ||||
|         if core.alert_template and core.alert_template.is_active: | ||||
|             templates.append(core.alert_template) | ||||
|  | ||||
|         # if agent is a workstation, check if policy with alert template is assigned to the site, client, or core | ||||
|         if ( | ||||
|             self.monitoring_type == "server" | ||||
|             and core.server_policy | ||||
|             and core.server_policy.alert_template | ||||
|             and core.server_policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(core.server_policy.alert_template) | ||||
|         if ( | ||||
|             self.monitoring_type == "workstation" | ||||
|             and core.workstation_policy | ||||
|             and core.workstation_policy.alert_template | ||||
|             and core.workstation_policy.alert_template.is_active | ||||
|         ): | ||||
|             templates.append(core.workstation_policy.alert_template) | ||||
|  | ||||
|         # go through the templates and return the first one that isn't excluded | ||||
|         for template in templates: | ||||
|             # check if client, site, or agent has been excluded from template | ||||
|             if ( | ||||
|                 client.pk | ||||
|                 in template.excluded_clients.all().values_list("pk", flat=True) | ||||
|                 or site.pk in template.excluded_sites.all().values_list("pk", flat=True) | ||||
|                 or self.pk | ||||
|                 in template.excluded_agents.all() | ||||
|                 .only("pk") | ||||
|                 .values_list("pk", flat=True) | ||||
|             ): | ||||
|                 continue | ||||
|  | ||||
|             # check if template is excluding desktops | ||||
|             elif ( | ||||
|                 self.monitoring_type == "workstation" and template.exclude_workstations | ||||
|             ): | ||||
|                 continue | ||||
|  | ||||
|             # check if template is excluding servers | ||||
|             elif self.monitoring_type == "server" and template.exclude_servers: | ||||
|                 continue | ||||
|  | ||||
|             else: | ||||
|                 # save alert_template to agent cache field | ||||
|                 self.alert_template = template | ||||
|                 self.save() | ||||
|  | ||||
|                 return template | ||||
|  | ||||
|         # no alert templates found or agent has been excluded | ||||
|         self.alert_template = None | ||||
|         self.save() | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def generate_checks_from_policies(self): | ||||
|         from automation.models import Policy | ||||
|  | ||||
|         # Clear agent checks managed by policy | ||||
|         if clear: | ||||
|             self.agentchecks.filter(managed_by_policy=True).delete() | ||||
|  | ||||
|         # Clear agent checks that have overriden_by_policy set | ||||
|         self.agentchecks.update(overriden_by_policy=False) | ||||
|         self.agentchecks.update(overriden_by_policy=False)  # type: ignore | ||||
|  | ||||
|         # Generate checks based on policies | ||||
|         Policy.generate_policy_checks(self) | ||||
|  | ||||
|     # clear is used to delete managed policy tasks from agent | ||||
|     # parent_tasks specifies a list of tasks to delete from agent with matching parent_task field | ||||
|     def generate_tasks_from_policies(self, clear=False): | ||||
|         from autotasks.tasks import delete_win_task_schedule | ||||
|     def generate_tasks_from_policies(self): | ||||
|         from automation.models import Policy | ||||
|  | ||||
|         # Clear agent tasks managed by policy | ||||
|         if clear: | ||||
|             for task in self.autotasks.filter(managed_by_policy=True): | ||||
|                 delete_win_task_schedule.delay(task.pk) | ||||
|  | ||||
|         # Generate tasks based on policies | ||||
|         Policy.generate_policy_tasks(self) | ||||
|  | ||||
| @@ -436,7 +650,7 @@ class Agent(BaseAuditModel): | ||||
|         except Exception: | ||||
|             return "err" | ||||
|  | ||||
|     async def nats_cmd(self, data, timeout=30, wait=True): | ||||
|     async def nats_cmd(self, data: dict, timeout: int = 30, wait: bool = True): | ||||
|         nc = NATS() | ||||
|         options = { | ||||
|             "servers": f"tls://{settings.ALLOWED_HOSTS[0]}:4222", | ||||
| @@ -458,7 +672,11 @@ class Agent(BaseAuditModel): | ||||
|             except ErrTimeout: | ||||
|                 ret = "timeout" | ||||
|             else: | ||||
|                 ret = msgpack.loads(msg.data) | ||||
|                 try: | ||||
|                     ret = msgpack.loads(msg.data)  # type: ignore | ||||
|                 except Exception as e: | ||||
|                     logger.error(e) | ||||
|                     ret = str(e) | ||||
|  | ||||
|             await nc.close() | ||||
|             return ret | ||||
| @@ -467,77 +685,6 @@ class Agent(BaseAuditModel): | ||||
|             await nc.flush() | ||||
|             await nc.close() | ||||
|  | ||||
|     def salt_api_cmd(self, **kwargs): | ||||
|  | ||||
|         # salt should always timeout first before the requests' timeout | ||||
|         try: | ||||
|             timeout = kwargs["timeout"] | ||||
|         except KeyError: | ||||
|             # default timeout | ||||
|             timeout = 15 | ||||
|             salt_timeout = 12 | ||||
|         else: | ||||
|             if timeout < 8: | ||||
|                 timeout = 8 | ||||
|                 salt_timeout = 5 | ||||
|             else: | ||||
|                 salt_timeout = timeout - 3 | ||||
|  | ||||
|         json = { | ||||
|             "client": "local", | ||||
|             "tgt": self.salt_id, | ||||
|             "fun": kwargs["func"], | ||||
|             "timeout": salt_timeout, | ||||
|             "username": settings.SALT_USERNAME, | ||||
|             "password": settings.SALT_PASSWORD, | ||||
|             "eauth": "pam", | ||||
|         } | ||||
|  | ||||
|         if "arg" in kwargs: | ||||
|             json.update({"arg": kwargs["arg"]}) | ||||
|         if "kwargs" in kwargs: | ||||
|             json.update({"kwarg": kwargs["kwargs"]}) | ||||
|  | ||||
|         try: | ||||
|             resp = requests.post( | ||||
|                 f"http://{settings.SALT_HOST}:8123/run", | ||||
|                 json=[json], | ||||
|                 timeout=timeout, | ||||
|             ) | ||||
|         except Exception: | ||||
|             return "timeout" | ||||
|  | ||||
|         try: | ||||
|             ret = resp.json()["return"][0][self.salt_id] | ||||
|         except Exception as e: | ||||
|             logger.error(f"{self.salt_id}: {e}") | ||||
|             return "error" | ||||
|         else: | ||||
|             return ret | ||||
|  | ||||
|     def salt_api_async(self, **kwargs): | ||||
|  | ||||
|         json = { | ||||
|             "client": "local_async", | ||||
|             "tgt": self.salt_id, | ||||
|             "fun": kwargs["func"], | ||||
|             "username": settings.SALT_USERNAME, | ||||
|             "password": settings.SALT_PASSWORD, | ||||
|             "eauth": "pam", | ||||
|         } | ||||
|  | ||||
|         if "arg" in kwargs: | ||||
|             json.update({"arg": kwargs["arg"]}) | ||||
|         if "kwargs" in kwargs: | ||||
|             json.update({"kwarg": kwargs["kwargs"]}) | ||||
|  | ||||
|         try: | ||||
|             resp = requests.post(f"http://{settings.SALT_HOST}:8123/run", json=[json]) | ||||
|         except Exception: | ||||
|             return "timeout" | ||||
|  | ||||
|         return resp | ||||
|  | ||||
|     @staticmethod | ||||
|     def serialize(agent): | ||||
|         # serializes the agent and returns json | ||||
| @@ -545,98 +692,18 @@ class Agent(BaseAuditModel): | ||||
|  | ||||
|         ret = AgentEditSerializer(agent).data | ||||
|         del ret["all_timezones"] | ||||
|         del ret["client"] | ||||
|         return ret | ||||
|  | ||||
|     @staticmethod | ||||
|     def salt_batch_async(**kwargs): | ||||
|         assert isinstance(kwargs["minions"], list) | ||||
|  | ||||
|         json = { | ||||
|             "client": "local_async", | ||||
|             "tgt_type": "list", | ||||
|             "tgt": kwargs["minions"], | ||||
|             "fun": kwargs["func"], | ||||
|             "username": settings.SALT_USERNAME, | ||||
|             "password": settings.SALT_PASSWORD, | ||||
|             "eauth": "pam", | ||||
|         } | ||||
|  | ||||
|         if "arg" in kwargs: | ||||
|             json.update({"arg": kwargs["arg"]}) | ||||
|         if "kwargs" in kwargs: | ||||
|             json.update({"kwarg": kwargs["kwargs"]}) | ||||
|  | ||||
|         try: | ||||
|             resp = requests.post(f"http://{settings.SALT_HOST}:8123/run", json=[json]) | ||||
|         except Exception: | ||||
|             return "timeout" | ||||
|  | ||||
|         return resp | ||||
|  | ||||
|     def schedule_reboot(self, obj): | ||||
|  | ||||
|         start_date = dt.datetime.strftime(obj, "%Y-%m-%d") | ||||
|         start_time = dt.datetime.strftime(obj, "%H:%M") | ||||
|  | ||||
|         # let windows task scheduler automatically delete the task after it runs | ||||
|         end_obj = obj + dt.timedelta(minutes=15) | ||||
|         end_date = dt.datetime.strftime(end_obj, "%Y-%m-%d") | ||||
|         end_time = dt.datetime.strftime(end_obj, "%H:%M") | ||||
|  | ||||
|         task_name = "TacticalRMM_SchedReboot_" + "".join( | ||||
|             random.choice(string.ascii_letters) for _ in range(10) | ||||
|         ) | ||||
|  | ||||
|         r = self.salt_api_cmd( | ||||
|             timeout=15, | ||||
|             func="task.create_task", | ||||
|             arg=[ | ||||
|                 f"name={task_name}", | ||||
|                 "force=True", | ||||
|                 "action_type=Execute", | ||||
|                 'cmd="C:\\Windows\\System32\\shutdown.exe"', | ||||
|                 'arguments="/r /t 5 /f"', | ||||
|                 "trigger_type=Once", | ||||
|                 f'start_date="{start_date}"', | ||||
|                 f'start_time="{start_time}"', | ||||
|                 f'end_date="{end_date}"', | ||||
|                 f'end_time="{end_time}"', | ||||
|                 "ac_only=False", | ||||
|                 "stop_if_on_batteries=False", | ||||
|                 "delete_after=Immediately", | ||||
|             ], | ||||
|         ) | ||||
|  | ||||
|         if r == "error" or (isinstance(r, bool) and not r): | ||||
|             return "failed" | ||||
|         elif r == "timeout": | ||||
|             return "timeout" | ||||
|         elif isinstance(r, bool) and r: | ||||
|             from logs.models import PendingAction | ||||
|  | ||||
|             details = { | ||||
|                 "taskname": task_name, | ||||
|                 "time": str(obj), | ||||
|             } | ||||
|             PendingAction(agent=self, action_type="schedreboot", details=details).save() | ||||
|  | ||||
|             nice_time = dt.datetime.strftime(obj, "%B %d, %Y at %I:%M %p") | ||||
|             return {"msg": {"time": nice_time, "agent": self.hostname}} | ||||
|         else: | ||||
|             return "failed" | ||||
|  | ||||
|     def not_supported(self, version_added): | ||||
|         return pyver.parse(self.version) < pyver.parse(version_added) | ||||
|  | ||||
|     def delete_superseded_updates(self): | ||||
|         try: | ||||
|             pks = []  # list of pks to delete | ||||
|             kbs = list(self.winupdates.values_list("kb", flat=True)) | ||||
|             kbs = list(self.winupdates.values_list("kb", flat=True))  # type: ignore | ||||
|             d = Counter(kbs) | ||||
|             dupes = [k for k, v in d.items() if v > 1] | ||||
|  | ||||
|             for dupe in dupes: | ||||
|                 titles = self.winupdates.filter(kb=dupe).values_list("title", flat=True) | ||||
|                 titles = self.winupdates.filter(kb=dupe).values_list("title", flat=True)  # type: ignore | ||||
|                 # extract the version from the title and sort from oldest to newest | ||||
|                 # skip if no version info is available therefore nothing to parse | ||||
|                 try: | ||||
| @@ -649,24 +716,24 @@ class Agent(BaseAuditModel): | ||||
|                     continue | ||||
|                 # append all but the latest version to our list of pks to delete | ||||
|                 for ver in sorted_vers[:-1]: | ||||
|                     q = self.winupdates.filter(kb=dupe).filter(title__contains=ver) | ||||
|                     q = self.winupdates.filter(kb=dupe).filter(title__contains=ver)  # type: ignore | ||||
|                     pks.append(q.first().pk) | ||||
|  | ||||
|             pks = list(set(pks)) | ||||
|             self.winupdates.filter(pk__in=pks).delete() | ||||
|             self.winupdates.filter(pk__in=pks).delete()  # type: ignore | ||||
|         except: | ||||
|             pass | ||||
|  | ||||
|     # define how the agent should handle pending actions | ||||
|     def handle_pending_actions(self): | ||||
|         pending_actions = self.pendingactions.filter(status="pending") | ||||
|         pending_actions = self.pendingactions.filter(status="pending")  # type: ignore | ||||
|  | ||||
|         for action in pending_actions: | ||||
|             if action.action_type == "taskaction": | ||||
|                 from autotasks.tasks import ( | ||||
|                     create_win_task_schedule, | ||||
|                     enable_or_disable_win_task, | ||||
|                     delete_win_task_schedule, | ||||
|                     enable_or_disable_win_task, | ||||
|                 ) | ||||
|  | ||||
|                 task_id = action.details["task_id"] | ||||
| @@ -680,38 +747,41 @@ class Agent(BaseAuditModel): | ||||
|                 elif action.details["action"] == "taskdelete": | ||||
|                     delete_win_task_schedule.delay(task_id, pending_action=action.id) | ||||
|  | ||||
|     # for clearing duplicate pending actions on agent | ||||
|     def remove_matching_pending_task_actions(self, task_id): | ||||
|         # remove any other pending actions on agent with same task_id | ||||
|         for action in self.pendingactions.filter(action_type="taskaction").exclude(status="completed"):  # type: ignore | ||||
|             if action.details["task_id"] == task_id: | ||||
|                 action.delete() | ||||
|  | ||||
| class AgentOutage(models.Model): | ||||
|     agent = models.ForeignKey( | ||||
|         Agent, | ||||
|         related_name="agentoutages", | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         on_delete=models.CASCADE, | ||||
|     ) | ||||
|     outage_time = models.DateTimeField(auto_now_add=True) | ||||
|     recovery_time = models.DateTimeField(null=True, blank=True) | ||||
|     outage_email_sent = models.BooleanField(default=False) | ||||
|     outage_sms_sent = models.BooleanField(default=False) | ||||
|     recovery_email_sent = models.BooleanField(default=False) | ||||
|     recovery_sms_sent = models.BooleanField(default=False) | ||||
|  | ||||
|     @property | ||||
|     def is_active(self): | ||||
|         return False if self.recovery_time else True | ||||
|     def should_create_alert(self, alert_template=None): | ||||
|         return ( | ||||
|             self.overdue_dashboard_alert | ||||
|             or self.overdue_email_alert | ||||
|             or self.overdue_text_alert | ||||
|             or ( | ||||
|                 alert_template | ||||
|                 and ( | ||||
|                     alert_template.agent_always_alert | ||||
|                     or alert_template.agent_always_email | ||||
|                     or alert_template.agent_always_text | ||||
|                 ) | ||||
|             ) | ||||
|         ) | ||||
|  | ||||
|     def send_outage_email(self): | ||||
|         from core.models import CoreSettings | ||||
|  | ||||
|         CORE = CoreSettings.objects.first() | ||||
|         CORE.send_mail( | ||||
|             f"{self.agent.client.name}, {self.agent.site.name}, {self.agent.hostname} - data overdue", | ||||
|             f"{self.client.name}, {self.site.name}, {self.hostname} - data overdue", | ||||
|             ( | ||||
|                 f"Data has not been received from client {self.agent.client.name}, " | ||||
|                 f"site {self.agent.site.name}, " | ||||
|                 f"agent {self.agent.hostname} " | ||||
|                 f"Data has not been received from client {self.client.name}, " | ||||
|                 f"site {self.site.name}, " | ||||
|                 f"agent {self.hostname} " | ||||
|                 "within the expected time." | ||||
|             ), | ||||
|             alert_template=self.alert_template, | ||||
|         ) | ||||
|  | ||||
|     def send_recovery_email(self): | ||||
| @@ -719,13 +789,14 @@ class AgentOutage(models.Model): | ||||
|  | ||||
|         CORE = CoreSettings.objects.first() | ||||
|         CORE.send_mail( | ||||
|             f"{self.agent.client.name}, {self.agent.site.name}, {self.agent.hostname} - data received", | ||||
|             f"{self.client.name}, {self.site.name}, {self.hostname} - data received", | ||||
|             ( | ||||
|                 f"Data has been received from client {self.agent.client.name}, " | ||||
|                 f"site {self.agent.site.name}, " | ||||
|                 f"agent {self.agent.hostname} " | ||||
|                 f"Data has been received from client {self.client.name}, " | ||||
|                 f"site {self.site.name}, " | ||||
|                 f"agent {self.hostname} " | ||||
|                 "after an interruption in data transmission." | ||||
|             ), | ||||
|             alert_template=self.alert_template, | ||||
|         ) | ||||
|  | ||||
|     def send_outage_sms(self): | ||||
| @@ -733,7 +804,8 @@ class AgentOutage(models.Model): | ||||
|  | ||||
|         CORE = CoreSettings.objects.first() | ||||
|         CORE.send_sms( | ||||
|             f"{self.agent.client.name}, {self.agent.site.name}, {self.agent.hostname} - data overdue" | ||||
|             f"{self.client.name}, {self.site.name}, {self.hostname} - data overdue", | ||||
|             alert_template=self.alert_template, | ||||
|         ) | ||||
|  | ||||
|     def send_recovery_sms(self): | ||||
| @@ -741,12 +813,10 @@ class AgentOutage(models.Model): | ||||
|  | ||||
|         CORE = CoreSettings.objects.first() | ||||
|         CORE.send_sms( | ||||
|             f"{self.agent.client.name}, {self.agent.site.name}, {self.agent.hostname} - data received" | ||||
|             f"{self.client.name}, {self.site.name}, {self.hostname} - data received", | ||||
|             alert_template=self.alert_template, | ||||
|         ) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.agent.hostname | ||||
|  | ||||
|  | ||||
| RECOVERY_CHOICES = [ | ||||
|     ("salt", "Salt"), | ||||
| @@ -770,12 +840,6 @@ class RecoveryAction(models.Model): | ||||
|     def __str__(self): | ||||
|         return f"{self.agent.hostname} - {self.mode}" | ||||
|  | ||||
|     def send(self): | ||||
|         ret = {"recovery": self.mode} | ||||
|         if self.mode == "command": | ||||
|             ret["cmd"] = self.command | ||||
|         return ret | ||||
|  | ||||
|  | ||||
| class Note(models.Model): | ||||
|     agent = models.ForeignKey( | ||||
| @@ -795,3 +859,38 @@ class Note(models.Model): | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.agent.hostname | ||||
|  | ||||
|  | ||||
| class AgentCustomField(models.Model): | ||||
|     agent = models.ForeignKey( | ||||
|         Agent, | ||||
|         related_name="custom_fields", | ||||
|         on_delete=models.CASCADE, | ||||
|     ) | ||||
|  | ||||
|     field = models.ForeignKey( | ||||
|         "core.CustomField", | ||||
|         related_name="agent_fields", | ||||
|         on_delete=models.CASCADE, | ||||
|     ) | ||||
|  | ||||
|     string_value = models.TextField(null=True, blank=True) | ||||
|     bool_value = models.BooleanField(blank=True, default=False) | ||||
|     multiple_value = ArrayField( | ||||
|         models.TextField(null=True, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.field | ||||
|  | ||||
|     @property | ||||
|     def value(self): | ||||
|         if self.field.type == "multiple": | ||||
|             return self.multiple_value | ||||
|         elif self.field.type == "checkbox": | ||||
|             return self.bool_value | ||||
|         else: | ||||
|             return self.string_value | ||||
|   | ||||
| @@ -1,12 +1,10 @@ | ||||
| import pytz | ||||
|  | ||||
| from rest_framework import serializers | ||||
| from rest_framework.fields import ReadOnlyField | ||||
|  | ||||
| from .models import Agent, Note | ||||
|  | ||||
| from winupdate.serializers import WinUpdatePolicySerializer | ||||
| from clients.serializers import ClientSerializer | ||||
| from winupdate.serializers import WinUpdatePolicySerializer | ||||
|  | ||||
| from .models import Agent, AgentCustomField, Note | ||||
|  | ||||
|  | ||||
| class AgentSerializer(serializers.ModelSerializer): | ||||
| @@ -18,6 +16,7 @@ class AgentSerializer(serializers.ModelSerializer): | ||||
|     local_ips = serializers.ReadOnlyField() | ||||
|     make_model = serializers.ReadOnlyField() | ||||
|     physical_disks = serializers.ReadOnlyField() | ||||
|     graphics = serializers.ReadOnlyField() | ||||
|     checks = serializers.ReadOnlyField() | ||||
|     timezone = serializers.ReadOnlyField() | ||||
|     all_timezones = serializers.SerializerMethodField() | ||||
| @@ -34,26 +33,69 @@ class AgentSerializer(serializers.ModelSerializer): | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class AgentOverdueActionSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = [ | ||||
|             "pk", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_dashboard_alert", | ||||
|         ] | ||||
|  | ||||
|  | ||||
| class AgentTableSerializer(serializers.ModelSerializer): | ||||
|     patches_pending = serializers.ReadOnlyField(source="has_patches_pending") | ||||
|     pending_actions = serializers.SerializerMethodField() | ||||
|     status = serializers.ReadOnlyField() | ||||
|     checks = serializers.ReadOnlyField() | ||||
|     last_seen = serializers.SerializerMethodField() | ||||
|     client_name = serializers.ReadOnlyField(source="client.name") | ||||
|     site_name = serializers.ReadOnlyField(source="site.name") | ||||
|     logged_username = serializers.SerializerMethodField() | ||||
|     italic = serializers.SerializerMethodField() | ||||
|     policy = serializers.ReadOnlyField(source="policy.id") | ||||
|     alert_template = serializers.SerializerMethodField() | ||||
|  | ||||
|     def get_last_seen(self, obj): | ||||
|     def get_alert_template(self, obj): | ||||
|  | ||||
|         if not obj.alert_template: | ||||
|             return None | ||||
|         else: | ||||
|             return { | ||||
|                 "name": obj.alert_template.name, | ||||
|                 "always_email": obj.alert_template.agent_always_email, | ||||
|                 "always_text": obj.alert_template.agent_always_text, | ||||
|                 "always_alert": obj.alert_template.agent_always_alert, | ||||
|             } | ||||
|  | ||||
|     def get_pending_actions(self, obj): | ||||
|         return obj.pendingactions.filter(status="pending").count() | ||||
|  | ||||
|     def get_last_seen(self, obj) -> str: | ||||
|         if obj.time_zone is not None: | ||||
|             agent_tz = pytz.timezone(obj.time_zone) | ||||
|         else: | ||||
|             agent_tz = self.context["default_tz"] | ||||
|  | ||||
|         return obj.last_seen.astimezone(agent_tz).strftime("%m %d %Y %H:%M:%S") | ||||
|         return obj.last_seen.astimezone(agent_tz).strftime("%m %d %Y %H:%M") | ||||
|  | ||||
|     def get_logged_username(self, obj) -> str: | ||||
|         if obj.logged_in_username == "None" and obj.status == "online": | ||||
|             return obj.last_logged_in_user | ||||
|         elif obj.logged_in_username != "None": | ||||
|             return obj.logged_in_username | ||||
|         else: | ||||
|             return "-" | ||||
|  | ||||
|     def get_italic(self, obj) -> bool: | ||||
|         return obj.logged_in_username == "None" and obj.status == "online" | ||||
|  | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "alert_template", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "site_name", | ||||
| @@ -62,23 +104,46 @@ class AgentTableSerializer(serializers.ModelSerializer): | ||||
|             "description", | ||||
|             "needs_reboot", | ||||
|             "patches_pending", | ||||
|             "pending_actions", | ||||
|             "status", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_dashboard_alert", | ||||
|             "last_seen", | ||||
|             "boot_time", | ||||
|             "checks", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "maintenance_mode", | ||||
|             "logged_username", | ||||
|             "italic", | ||||
|             "policy", | ||||
|         ] | ||||
|         depth = 2 | ||||
|  | ||||
|  | ||||
| class AgentCustomFieldSerializer(serializers.ModelSerializer): | ||||
|     class Meta: | ||||
|         model = AgentCustomField | ||||
|         fields = ( | ||||
|             "id", | ||||
|             "field", | ||||
|             "agent", | ||||
|             "value", | ||||
|             "string_value", | ||||
|             "bool_value", | ||||
|             "multiple_value", | ||||
|         ) | ||||
|         extra_kwargs = { | ||||
|             "string_value": {"write_only": True}, | ||||
|             "bool_value": {"write_only": True}, | ||||
|             "multiple_value": {"write_only": True}, | ||||
|         } | ||||
|  | ||||
|  | ||||
| class AgentEditSerializer(serializers.ModelSerializer): | ||||
|     winupdatepolicy = WinUpdatePolicySerializer(many=True, read_only=True) | ||||
|     all_timezones = serializers.SerializerMethodField() | ||||
|     client = ClientSerializer(read_only=True) | ||||
|     custom_fields = AgentCustomFieldSerializer(many=True, read_only=True) | ||||
|  | ||||
|     def get_all_timezones(self, obj): | ||||
|         return pytz.all_timezones | ||||
| @@ -96,10 +161,13 @@ class AgentEditSerializer(serializers.ModelSerializer): | ||||
|             "timezone", | ||||
|             "check_interval", | ||||
|             "overdue_time", | ||||
|             "offline_time", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_email_alert", | ||||
|             "all_timezones", | ||||
|             "winupdatepolicy", | ||||
|             "policy", | ||||
|             "custom_fields", | ||||
|         ] | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -1,349 +1,293 @@ | ||||
| import asyncio | ||||
| from loguru import logger | ||||
| from time import sleep | ||||
| import datetime as dt | ||||
| import random | ||||
| import requests | ||||
| from packaging import version as pyver | ||||
|  | ||||
| import urllib.parse | ||||
| from time import sleep | ||||
| from typing import Union | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.utils import timezone as djangotime | ||||
| from loguru import logger | ||||
| from packaging import version as pyver | ||||
|  | ||||
|  | ||||
| from tacticalrmm.celery import app | ||||
| from agents.models import Agent, AgentOutage | ||||
| from core.models import CoreSettings | ||||
| from agents.models import Agent | ||||
| from core.models import CoreSettings, CodeSignToken | ||||
| from logs.models import PendingAction | ||||
| from scripts.models import Script | ||||
| from tacticalrmm.celery import app | ||||
| from tacticalrmm.utils import run_nats_api_cmd | ||||
|  | ||||
| logger.configure(**settings.LOG_CONFIG) | ||||
|  | ||||
| OLD_64_PY_AGENT = "https://github.com/wh1te909/winagent/releases/download/v0.11.2/winagent-v0.11.2.exe" | ||||
| OLD_32_PY_AGENT = "https://github.com/wh1te909/winagent/releases/download/v0.11.2/winagent-v0.11.2-x86.exe" | ||||
|  | ||||
| def _get_exegen_url() -> str: | ||||
|     urls: list[str] = settings.EXE_GEN_URLS | ||||
|     for url in urls: | ||||
|         try: | ||||
|             r = requests.get(url, timeout=10) | ||||
|         except: | ||||
|             continue | ||||
|  | ||||
|         if r.status_code == 200: | ||||
|             return url | ||||
|  | ||||
|     return random.choice(urls) | ||||
|  | ||||
|  | ||||
| def agent_update(pk: int, codesigntoken: str = None) -> str: | ||||
|     agent = Agent.objects.get(pk=pk) | ||||
|  | ||||
|     if pyver.parse(agent.version) <= pyver.parse("1.3.0"): | ||||
|         return "not supported" | ||||
|  | ||||
|     # skip if we can't determine the arch | ||||
|     if agent.arch is None: | ||||
|         logger.warning( | ||||
|             f"Unable to determine arch on {agent.hostname}. Skipping agent update." | ||||
|         ) | ||||
|         return "noarch" | ||||
|  | ||||
|     version = settings.LATEST_AGENT_VER | ||||
|     inno = agent.win_inno_exe | ||||
|  | ||||
|     if codesigntoken is not None and pyver.parse(version) >= pyver.parse("1.5.0"): | ||||
|         base_url = _get_exegen_url() + "/api/v1/winagents/?" | ||||
|         params = {"version": version, "arch": agent.arch, "token": codesigntoken} | ||||
|         url = base_url + urllib.parse.urlencode(params) | ||||
|     else: | ||||
|         url = agent.winagent_dl | ||||
|  | ||||
|     if agent.pendingactions.filter( | ||||
|         action_type="agentupdate", status="pending" | ||||
|     ).exists(): | ||||
|         agent.pendingactions.filter( | ||||
|             action_type="agentupdate", status="pending" | ||||
|         ).delete() | ||||
|  | ||||
|     PendingAction.objects.create( | ||||
|         agent=agent, | ||||
|         action_type="agentupdate", | ||||
|         details={ | ||||
|             "url": url, | ||||
|             "version": version, | ||||
|             "inno": inno, | ||||
|         }, | ||||
|     ) | ||||
|  | ||||
|     nats_data = { | ||||
|         "func": "agentupdate", | ||||
|         "payload": { | ||||
|             "url": url, | ||||
|             "version": version, | ||||
|             "inno": inno, | ||||
|         }, | ||||
|     } | ||||
|     asyncio.run(agent.nats_cmd(nats_data, wait=False)) | ||||
|     return "created" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def send_agent_update_task(pks, version): | ||||
|     assert isinstance(pks, list) | ||||
|  | ||||
|     q = Agent.objects.filter(pk__in=pks) | ||||
|     agents = [i.pk for i in q if pyver.parse(i.version) < pyver.parse(version)] | ||||
|  | ||||
|     chunks = (agents[i : i + 30] for i in range(0, len(agents), 30)) | ||||
| def send_agent_update_task(pks: list[int]) -> None: | ||||
|     try: | ||||
|         codesigntoken = CodeSignToken.objects.first().token | ||||
|     except: | ||||
|         codesigntoken = None | ||||
|  | ||||
|     chunks = (pks[i : i + 30] for i in range(0, len(pks), 30)) | ||||
|     for chunk in chunks: | ||||
|         for pk in chunk: | ||||
|             agent = Agent.objects.get(pk=pk) | ||||
|  | ||||
|             # skip if we can't determine the arch | ||||
|             if agent.arch is None: | ||||
|                 logger.warning( | ||||
|                     f"Unable to determine arch on {agent.salt_id}. Skipping." | ||||
|                 ) | ||||
|                 continue | ||||
|  | ||||
|             # golang agent only backwards compatible with py agent 0.11.2 | ||||
|             # force an upgrade to the latest python agent if version < 0.11.2 | ||||
|             if pyver.parse(agent.version) < pyver.parse("0.11.2"): | ||||
|                 url = OLD_64_PY_AGENT if agent.arch == "64" else OLD_32_PY_AGENT | ||||
|                 inno = ( | ||||
|                     "winagent-v0.11.2.exe" | ||||
|                     if agent.arch == "64" | ||||
|                     else "winagent-v0.11.2-x86.exe" | ||||
|                 ) | ||||
|             else: | ||||
|                 url = agent.winagent_dl | ||||
|                 inno = agent.win_inno_exe | ||||
|             logger.info( | ||||
|                 f"Updating {agent.salt_id} current version {agent.version} using {inno}" | ||||
|             ) | ||||
|  | ||||
|             if agent.has_nats: | ||||
|                 if agent.pendingactions.filter( | ||||
|                     action_type="agentupdate", status="pending" | ||||
|                 ).exists(): | ||||
|                     continue | ||||
|  | ||||
|                 PendingAction.objects.create( | ||||
|                     agent=agent, | ||||
|                     action_type="agentupdate", | ||||
|                     details={ | ||||
|                         "url": agent.winagent_dl, | ||||
|                         "version": settings.LATEST_AGENT_VER, | ||||
|                         "inno": agent.win_inno_exe, | ||||
|                     }, | ||||
|                 ) | ||||
|             # TODO | ||||
|             # Salt is deprecated, remove this once salt is gone | ||||
|             else: | ||||
|                 r = agent.salt_api_async( | ||||
|                     func="win_agent.do_agent_update_v2", | ||||
|                     kwargs={ | ||||
|                         "inno": inno, | ||||
|                         "url": url, | ||||
|                     }, | ||||
|                 ) | ||||
|         sleep(10) | ||||
|             agent_update(pk, codesigntoken) | ||||
|             sleep(0.05) | ||||
|         sleep(4) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def auto_self_agent_update_task(): | ||||
| def auto_self_agent_update_task() -> None: | ||||
|     core = CoreSettings.objects.first() | ||||
|     if not core.agent_auto_update: | ||||
|         logger.info("Agent auto update is disabled. Skipping.") | ||||
|         return | ||||
|  | ||||
|     try: | ||||
|         codesigntoken = CodeSignToken.objects.first().token | ||||
|     except: | ||||
|         codesigntoken = None | ||||
|  | ||||
|     q = Agent.objects.only("pk", "version") | ||||
|     agents = [ | ||||
|     pks: list[int] = [ | ||||
|         i.pk | ||||
|         for i in q | ||||
|         if pyver.parse(i.version) < pyver.parse(settings.LATEST_AGENT_VER) | ||||
|     ] | ||||
|     logger.info(f"Updating {len(agents)}") | ||||
|  | ||||
|     chunks = (agents[i : i + 30] for i in range(0, len(agents), 30)) | ||||
|  | ||||
|     chunks = (pks[i : i + 30] for i in range(0, len(pks), 30)) | ||||
|     for chunk in chunks: | ||||
|         for pk in chunk: | ||||
|             agent = Agent.objects.get(pk=pk) | ||||
|  | ||||
|             # skip if we can't determine the arch | ||||
|             if agent.arch is None: | ||||
|                 logger.warning( | ||||
|                     f"Unable to determine arch on {agent.salt_id}. Skipping." | ||||
|                 ) | ||||
|                 continue | ||||
|  | ||||
|             # golang agent only backwards compatible with py agent 0.11.2 | ||||
|             # force an upgrade to the latest python agent if version < 0.11.2 | ||||
|             if pyver.parse(agent.version) < pyver.parse("0.11.2"): | ||||
|                 url = OLD_64_PY_AGENT if agent.arch == "64" else OLD_32_PY_AGENT | ||||
|                 inno = ( | ||||
|                     "winagent-v0.11.2.exe" | ||||
|                     if agent.arch == "64" | ||||
|                     else "winagent-v0.11.2-x86.exe" | ||||
|                 ) | ||||
|             else: | ||||
|                 url = agent.winagent_dl | ||||
|                 inno = agent.win_inno_exe | ||||
|             logger.info( | ||||
|                 f"Updating {agent.salt_id} current version {agent.version} using {inno}" | ||||
|             ) | ||||
|  | ||||
|             if agent.has_nats: | ||||
|                 if agent.pendingactions.filter( | ||||
|                     action_type="agentupdate", status="pending" | ||||
|                 ).exists(): | ||||
|                     continue | ||||
|  | ||||
|                 PendingAction.objects.create( | ||||
|                     agent=agent, | ||||
|                     action_type="agentupdate", | ||||
|                     details={ | ||||
|                         "url": agent.winagent_dl, | ||||
|                         "version": settings.LATEST_AGENT_VER, | ||||
|                         "inno": agent.win_inno_exe, | ||||
|                     }, | ||||
|                 ) | ||||
|             # TODO | ||||
|             # Salt is deprecated, remove this once salt is gone | ||||
|             else: | ||||
|                 r = agent.salt_api_async( | ||||
|                     func="win_agent.do_agent_update_v2", | ||||
|                     kwargs={ | ||||
|                         "inno": inno, | ||||
|                         "url": url, | ||||
|                     }, | ||||
|                 ) | ||||
|         sleep(10) | ||||
|             agent_update(pk, codesigntoken) | ||||
|             sleep(0.05) | ||||
|         sleep(4) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def update_salt_minion_task(): | ||||
|     q = Agent.objects.all() | ||||
|     agents = [ | ||||
|         i.pk | ||||
|         for i in q | ||||
|         if pyver.parse(i.version) >= pyver.parse("0.11.0") | ||||
|         and pyver.parse(i.salt_ver) < pyver.parse(settings.LATEST_SALT_VER) | ||||
|     ] | ||||
| def agent_outage_email_task(pk: int, alert_interval: Union[float, None] = None) -> str: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|     chunks = (agents[i : i + 50] for i in range(0, len(agents), 50)) | ||||
|     alert = Alert.objects.get(pk=pk) | ||||
|  | ||||
|     for chunk in chunks: | ||||
|         for pk in chunk: | ||||
|             agent = Agent.objects.get(pk=pk) | ||||
|             r = agent.salt_api_async(func="win_agent.update_salt") | ||||
|         sleep(20) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def get_wmi_detail_task(pk): | ||||
|     agent = Agent.objects.get(pk=pk) | ||||
|     if agent.has_nats: | ||||
|         asyncio.run(agent.nats_cmd({"func": "sysinfo"}, wait=False)) | ||||
|     if not alert.email_sent: | ||||
|         sleep(random.randint(1, 15)) | ||||
|         alert.agent.send_outage_email() | ||||
|         alert.email_sent = djangotime.now() | ||||
|         alert.save(update_fields=["email_sent"]) | ||||
|     else: | ||||
|         agent.salt_api_async(timeout=30, func="win_agent.local_sys_info") | ||||
|         if alert_interval: | ||||
|             # send an email only if the last email sent is older than alert interval | ||||
|             delta = djangotime.now() - dt.timedelta(days=alert_interval) | ||||
|             if alert.email_sent < delta: | ||||
|                 sleep(random.randint(1, 10)) | ||||
|                 alert.agent.send_outage_email() | ||||
|                 alert.email_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["email_sent"]) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def sync_salt_modules_task(pk): | ||||
|     agent = Agent.objects.get(pk=pk) | ||||
|     r = agent.salt_api_cmd(timeout=35, func="saltutil.sync_modules") | ||||
|     # successful sync if new/charnged files: {'return': [{'MINION-15': ['modules.get_eventlog', 'modules.win_agent', 'etc...']}]} | ||||
|     # successful sync with no new/changed files: {'return': [{'MINION-15': []}]} | ||||
|     if r == "timeout" or r == "error": | ||||
|         return f"Unable to sync modules {agent.salt_id}" | ||||
| def agent_recovery_email_task(pk: int) -> str: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|     return f"Successfully synced salt modules on {agent.hostname}" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def batch_sync_modules_task(): | ||||
|     # sync modules, split into chunks of 50 agents to not overload salt | ||||
|     agents = Agent.objects.all() | ||||
|     online = [i.salt_id for i in agents] | ||||
|     chunks = (online[i : i + 50] for i in range(0, len(online), 50)) | ||||
|     for chunk in chunks: | ||||
|         Agent.salt_batch_async(minions=chunk, func="saltutil.sync_modules") | ||||
|         sleep(10) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def batch_sysinfo_task(): | ||||
|     # update system info using WMI | ||||
|     agents = Agent.objects.all() | ||||
|  | ||||
|     agents_nats = [agent for agent in agents if agent.has_nats] | ||||
|     minions = [ | ||||
|         agent.salt_id | ||||
|         for agent in agents | ||||
|         if not agent.has_nats and pyver.parse(agent.version) >= pyver.parse("0.11.0") | ||||
|     ] | ||||
|  | ||||
|     if minions: | ||||
|         Agent.salt_batch_async(minions=minions, func="win_agent.local_sys_info") | ||||
|  | ||||
|     for agent in agents_nats: | ||||
|         asyncio.run(agent.nats_cmd({"func": "sysinfo"}, wait=False)) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def uninstall_agent_task(salt_id, has_nats): | ||||
|     attempts = 0 | ||||
|     error = False | ||||
|  | ||||
|     if not has_nats: | ||||
|         while 1: | ||||
|             try: | ||||
|  | ||||
|                 r = requests.post( | ||||
|                     f"http://{settings.SALT_HOST}:8123/run", | ||||
|                     json=[ | ||||
|                         { | ||||
|                             "client": "local", | ||||
|                             "tgt": salt_id, | ||||
|                             "fun": "win_agent.uninstall_agent", | ||||
|                             "timeout": 8, | ||||
|                             "username": settings.SALT_USERNAME, | ||||
|                             "password": settings.SALT_PASSWORD, | ||||
|                             "eauth": "pam", | ||||
|                         } | ||||
|                     ], | ||||
|                     timeout=10, | ||||
|                 ) | ||||
|                 ret = r.json()["return"][0][salt_id] | ||||
|             except Exception: | ||||
|                 attempts += 1 | ||||
|             else: | ||||
|                 if ret != "ok": | ||||
|                     attempts += 1 | ||||
|                 else: | ||||
|                     attempts = 0 | ||||
|  | ||||
|             if attempts >= 10: | ||||
|                 error = True | ||||
|                 break | ||||
|             elif attempts == 0: | ||||
|                 break | ||||
|  | ||||
|     if error: | ||||
|         logger.error(f"{salt_id} uninstall failed") | ||||
|     else: | ||||
|         logger.info(f"{salt_id} was successfully uninstalled") | ||||
|  | ||||
|     try: | ||||
|         r = requests.post( | ||||
|             f"http://{settings.SALT_HOST}:8123/run", | ||||
|             json=[ | ||||
|                 { | ||||
|                     "client": "wheel", | ||||
|                     "fun": "key.delete", | ||||
|                     "match": salt_id, | ||||
|                     "username": settings.SALT_USERNAME, | ||||
|                     "password": settings.SALT_PASSWORD, | ||||
|                     "eauth": "pam", | ||||
|                 } | ||||
|             ], | ||||
|             timeout=30, | ||||
|         ) | ||||
|     except Exception: | ||||
|         logger.error(f"{salt_id} unable to remove salt-key") | ||||
|     sleep(random.randint(1, 15)) | ||||
|     alert = Alert.objects.get(pk=pk) | ||||
|     alert.agent.send_recovery_email() | ||||
|     alert.resolved_email_sent = djangotime.now() | ||||
|     alert.save(update_fields=["resolved_email_sent"]) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def agent_outage_email_task(pk): | ||||
|     sleep(random.randint(1, 15)) | ||||
|     outage = AgentOutage.objects.get(pk=pk) | ||||
|     outage.send_outage_email() | ||||
|     outage.outage_email_sent = True | ||||
|     outage.save(update_fields=["outage_email_sent"]) | ||||
| def agent_outage_sms_task(pk: int, alert_interval: Union[float, None] = None) -> str: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|     alert = Alert.objects.get(pk=pk) | ||||
|  | ||||
|     if not alert.sms_sent: | ||||
|         sleep(random.randint(1, 15)) | ||||
|         alert.agent.send_outage_sms() | ||||
|         alert.sms_sent = djangotime.now() | ||||
|         alert.save(update_fields=["sms_sent"]) | ||||
|     else: | ||||
|         if alert_interval: | ||||
|             # send an sms only if the last sms sent is older than alert interval | ||||
|             delta = djangotime.now() - dt.timedelta(days=alert_interval) | ||||
|             if alert.sms_sent < delta: | ||||
|                 sleep(random.randint(1, 10)) | ||||
|                 alert.agent.send_outage_sms() | ||||
|                 alert.sms_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["sms_sent"]) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def agent_recovery_email_task(pk): | ||||
|     sleep(random.randint(1, 15)) | ||||
|     outage = AgentOutage.objects.get(pk=pk) | ||||
|     outage.send_recovery_email() | ||||
|     outage.recovery_email_sent = True | ||||
|     outage.save(update_fields=["recovery_email_sent"]) | ||||
| def agent_recovery_sms_task(pk: int) -> str: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def agent_outage_sms_task(pk): | ||||
|     sleep(random.randint(1, 3)) | ||||
|     outage = AgentOutage.objects.get(pk=pk) | ||||
|     outage.send_outage_sms() | ||||
|     outage.outage_sms_sent = True | ||||
|     outage.save(update_fields=["outage_sms_sent"]) | ||||
|     alert = Alert.objects.get(pk=pk) | ||||
|     alert.agent.send_recovery_sms() | ||||
|     alert.resolved_sms_sent = djangotime.now() | ||||
|     alert.save(update_fields=["resolved_sms_sent"]) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def agent_recovery_sms_task(pk): | ||||
|     sleep(random.randint(1, 3)) | ||||
|     outage = AgentOutage.objects.get(pk=pk) | ||||
|     outage.send_recovery_sms() | ||||
|     outage.recovery_sms_sent = True | ||||
|     outage.save(update_fields=["recovery_sms_sent"]) | ||||
| def agent_outages_task() -> None: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def agent_outages_task(): | ||||
|     agents = Agent.objects.only("pk") | ||||
|     agents = Agent.objects.only( | ||||
|         "pk", | ||||
|         "last_seen", | ||||
|         "offline_time", | ||||
|         "overdue_time", | ||||
|         "overdue_email_alert", | ||||
|         "overdue_text_alert", | ||||
|         "overdue_dashboard_alert", | ||||
|     ) | ||||
|  | ||||
|     for agent in agents: | ||||
|         if agent.status == "overdue": | ||||
|             outages = AgentOutage.objects.filter(agent=agent) | ||||
|             if outages and outages.last().is_active: | ||||
|                 continue | ||||
|             Alert.handle_alert_failure(agent) | ||||
|  | ||||
|             outage = AgentOutage(agent=agent) | ||||
|             outage.save() | ||||
|  | ||||
|             if agent.overdue_email_alert and not agent.maintenance_mode: | ||||
|                 agent_outage_email_task.delay(pk=outage.pk) | ||||
| @app.task | ||||
| def run_script_email_results_task( | ||||
|     agentpk: int, | ||||
|     scriptpk: int, | ||||
|     nats_timeout: int, | ||||
|     emails: list[str], | ||||
|     args: list[str] = [], | ||||
| ): | ||||
|     agent = Agent.objects.get(pk=agentpk) | ||||
|     script = Script.objects.get(pk=scriptpk) | ||||
|     r = agent.run_script( | ||||
|         scriptpk=script.pk, args=args, full=True, timeout=nats_timeout, wait=True | ||||
|     ) | ||||
|     if r == "timeout": | ||||
|         logger.error(f"{agent.hostname} timed out running script.") | ||||
|         return | ||||
|  | ||||
|             if agent.overdue_text_alert and not agent.maintenance_mode: | ||||
|                 agent_outage_sms_task.delay(pk=outage.pk) | ||||
|     CORE = CoreSettings.objects.first() | ||||
|     subject = f"{agent.hostname} {script.name} Results" | ||||
|     exec_time = "{:.4f}".format(r["execution_time"]) | ||||
|     body = ( | ||||
|         subject | ||||
|         + f"\nReturn code: {r['retcode']}\nExecution time: {exec_time} seconds\nStdout: {r['stdout']}\nStderr: {r['stderr']}" | ||||
|     ) | ||||
|  | ||||
|     import smtplib | ||||
|     from email.message import EmailMessage | ||||
|  | ||||
|     msg = EmailMessage() | ||||
|     msg["Subject"] = subject | ||||
|     msg["From"] = CORE.smtp_from_email | ||||
|  | ||||
|     if emails: | ||||
|         msg["To"] = ", ".join(emails) | ||||
|     else: | ||||
|         msg["To"] = ", ".join(CORE.email_alert_recipients) | ||||
|  | ||||
|     msg.set_content(body) | ||||
|  | ||||
|     try: | ||||
|         with smtplib.SMTP(CORE.smtp_host, CORE.smtp_port, timeout=20) as server: | ||||
|             if CORE.smtp_requires_auth: | ||||
|                 server.ehlo() | ||||
|                 server.starttls() | ||||
|                 server.login(CORE.smtp_host_user, CORE.smtp_host_password) | ||||
|                 server.send_message(msg) | ||||
|                 server.quit() | ||||
|             else: | ||||
|                 server.send_message(msg) | ||||
|                 server.quit() | ||||
|     except Exception as e: | ||||
|         logger.error(e) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def monitor_agents_task() -> None: | ||||
|     agents = Agent.objects.only( | ||||
|         "pk", "agent_id", "last_seen", "overdue_time", "offline_time" | ||||
|     ) | ||||
|     ids = [i.agent_id for i in agents if i.status != "online"] | ||||
|     run_nats_api_cmd("monitor", ids) | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def get_wmi_task() -> None: | ||||
|     agents = Agent.objects.only( | ||||
|         "pk", "agent_id", "last_seen", "overdue_time", "offline_time" | ||||
|     ) | ||||
|     ids = [i.agent_id for i in agents if i.status == "online"] | ||||
|     run_nats_api_cmd("wmi", ids) | ||||
|   | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,18 +1,16 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from . import views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("listagents/", views.AgentsTableList.as_view()), | ||||
|     path("listagentsnodetail/", views.list_agents_no_detail), | ||||
|     path("<int:pk>/agenteditdetails/", views.agent_edit_details), | ||||
|     path("byclient/<int:clientpk>/", views.by_client), | ||||
|     path("bysite/<int:sitepk>/", views.by_site), | ||||
|     path("overdueaction/", views.overdue_action), | ||||
|     path("sendrawcmd/", views.send_raw_cmd), | ||||
|     path("<pk>/agentdetail/", views.agent_detail), | ||||
|     path("<int:pk>/meshcentral/", views.meshcentral), | ||||
|     path("<str:arch>/getmeshexe/", views.get_mesh_exe), | ||||
|     path("poweraction/", views.power_action), | ||||
|     path("uninstall/", views.uninstall), | ||||
|     path("editagent/", views.edit_agent), | ||||
|     path("<pk>/geteventlog/<logtype>/<days>/", views.get_event_log), | ||||
| @@ -20,7 +18,7 @@ urlpatterns = [ | ||||
|     path("updateagents/", views.update_agents), | ||||
|     path("<pk>/getprocs/", views.get_processes), | ||||
|     path("<pk>/<pid>/killproc/", views.kill_proc), | ||||
|     path("rebootlater/", views.reboot_later), | ||||
|     path("reboot/", views.Reboot.as_view()), | ||||
|     path("installagent/", views.install_agent), | ||||
|     path("<int:pk>/ping/", views.ping), | ||||
|     path("recover/", views.recover), | ||||
| @@ -29,6 +27,6 @@ urlpatterns = [ | ||||
|     path("<int:pk>/notes/", views.GetAddNotes.as_view()), | ||||
|     path("<int:pk>/note/", views.GetEditDeleteNote.as_view()), | ||||
|     path("bulk/", views.bulk), | ||||
|     path("agent_counts/", views.agent_counts), | ||||
|     path("maintenance/", views.agent_maintenance), | ||||
|     path("<int:pk>/wmi/", views.WMI.as_view()), | ||||
| ] | ||||
|   | ||||
| @@ -1,47 +1,46 @@ | ||||
| import asyncio | ||||
| from loguru import logger | ||||
| import os | ||||
| import subprocess | ||||
| import pytz | ||||
| import datetime as dt | ||||
| from packaging import version as pyver | ||||
| import os | ||||
| import random | ||||
| import string | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.http import HttpResponse | ||||
|  | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from loguru import logger | ||||
| from packaging import version as pyver | ||||
| from rest_framework import status | ||||
| from rest_framework.decorators import api_view | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.response import Response | ||||
| from rest_framework import status, generics | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from .models import Agent, AgentOutage, RecoveryAction, Note | ||||
| from core.models import CoreSettings | ||||
| from logs.models import AuditLog, PendingAction | ||||
| from scripts.models import Script | ||||
| from logs.models import AuditLog | ||||
| from scripts.tasks import handle_bulk_command_task, handle_bulk_script_task | ||||
| from tacticalrmm.utils import get_default_timezone, notify_error, reload_nats | ||||
| from winupdate.serializers import WinUpdatePolicySerializer | ||||
| from winupdate.tasks import bulk_check_for_updates_task, bulk_install_updates_task | ||||
|  | ||||
| from .models import Agent, AgentCustomField, Note, RecoveryAction | ||||
| from .serializers import ( | ||||
|     AgentSerializer, | ||||
|     AgentHostnameSerializer, | ||||
|     AgentTableSerializer, | ||||
|     AgentCustomFieldSerializer, | ||||
|     AgentEditSerializer, | ||||
|     AgentHostnameSerializer, | ||||
|     AgentOverdueActionSerializer, | ||||
|     AgentSerializer, | ||||
|     AgentTableSerializer, | ||||
|     NoteSerializer, | ||||
|     NotesSerializer, | ||||
| ) | ||||
| from winupdate.serializers import WinUpdatePolicySerializer | ||||
|  | ||||
| from .tasks import uninstall_agent_task, send_agent_update_task | ||||
| from winupdate.tasks import bulk_check_for_updates_task | ||||
| from scripts.tasks import handle_bulk_command_task, handle_bulk_script_task | ||||
|  | ||||
| from tacticalrmm.utils import notify_error, reload_nats | ||||
| from .tasks import run_script_email_results_task, send_agent_update_task | ||||
|  | ||||
| logger.configure(**settings.LOG_CONFIG) | ||||
|  | ||||
|  | ||||
| @api_view() | ||||
| def get_agent_versions(request): | ||||
|     agents = Agent.objects.only("pk") | ||||
|     agents = Agent.objects.prefetch_related("site").only("pk", "hostname") | ||||
|     return Response( | ||||
|         { | ||||
|             "versions": [settings.LATEST_AGENT_VER], | ||||
| @@ -52,9 +51,13 @@ def get_agent_versions(request): | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def update_agents(request): | ||||
|     pks = request.data["pks"] | ||||
|     version = request.data["version"] | ||||
|     send_agent_update_task.delay(pks=pks, version=version) | ||||
|     q = Agent.objects.filter(pk__in=request.data["pks"]).only("pk", "version") | ||||
|     pks: list[int] = [ | ||||
|         i.pk | ||||
|         for i in q | ||||
|         if pyver.parse(i.version) < pyver.parse(settings.LATEST_AGENT_VER) | ||||
|     ] | ||||
|     send_agent_update_task.delay(pks=pks) | ||||
|     return Response("ok") | ||||
|  | ||||
|  | ||||
| @@ -62,14 +65,9 @@ def update_agents(request): | ||||
| def ping(request, pk): | ||||
|     agent = get_object_or_404(Agent, pk=pk) | ||||
|     status = "offline" | ||||
|     if agent.has_nats: | ||||
|         r = asyncio.run(agent.nats_cmd({"func": "ping"}, timeout=5)) | ||||
|         if r == "pong": | ||||
|             status = "online" | ||||
|     else: | ||||
|         r = agent.salt_api_cmd(timeout=5, func="test.ping") | ||||
|         if isinstance(r, bool) and r: | ||||
|             status = "online" | ||||
|     r = asyncio.run(agent.nats_cmd({"func": "ping"}, timeout=5)) | ||||
|     if r == "pong": | ||||
|         status = "online" | ||||
|  | ||||
|     return Response({"name": agent.hostname, "status": status}) | ||||
|  | ||||
| @@ -77,32 +75,52 @@ def ping(request, pk): | ||||
| @api_view(["DELETE"]) | ||||
| def uninstall(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     if agent.has_nats: | ||||
|         asyncio.run(agent.nats_cmd({"func": "uninstall"}, wait=False)) | ||||
|     asyncio.run(agent.nats_cmd({"func": "uninstall"}, wait=False)) | ||||
|  | ||||
|     salt_id = agent.salt_id | ||||
|     name = agent.hostname | ||||
|     has_nats = agent.has_nats | ||||
|     agent.delete() | ||||
|     reload_nats() | ||||
|  | ||||
|     uninstall_agent_task.delay(salt_id, has_nats) | ||||
|     return Response(f"{name} will now be uninstalled.") | ||||
|  | ||||
|  | ||||
| @api_view(["PATCH"]) | ||||
| @api_view(["PATCH", "PUT"]) | ||||
| def edit_agent(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["id"]) | ||||
|  | ||||
|     a_serializer = AgentSerializer(instance=agent, data=request.data, partial=True) | ||||
|     a_serializer.is_valid(raise_exception=True) | ||||
|     a_serializer.save() | ||||
|  | ||||
|     policy = agent.winupdatepolicy.get() | ||||
|     p_serializer = WinUpdatePolicySerializer( | ||||
|         instance=policy, data=request.data["winupdatepolicy"][0] | ||||
|     ) | ||||
|     p_serializer.is_valid(raise_exception=True) | ||||
|     p_serializer.save() | ||||
|     if "winupdatepolicy" in request.data.keys(): | ||||
|         policy = agent.winupdatepolicy.get()  # type: ignore | ||||
|         p_serializer = WinUpdatePolicySerializer( | ||||
|             instance=policy, data=request.data["winupdatepolicy"][0] | ||||
|         ) | ||||
|         p_serializer.is_valid(raise_exception=True) | ||||
|         p_serializer.save() | ||||
|  | ||||
|     if "custom_fields" in request.data.keys(): | ||||
|  | ||||
|         for field in request.data["custom_fields"]: | ||||
|  | ||||
|             custom_field = field | ||||
|             custom_field["agent"] = agent.id  # type: ignore | ||||
|  | ||||
|             if AgentCustomField.objects.filter( | ||||
|                 field=field["field"], agent=agent.id  # type: ignore | ||||
|             ): | ||||
|                 value = AgentCustomField.objects.get( | ||||
|                     field=field["field"], agent=agent.id  # type: ignore | ||||
|                 ) | ||||
|                 serializer = AgentCustomFieldSerializer( | ||||
|                     instance=value, data=custom_field | ||||
|                 ) | ||||
|                 serializer.is_valid(raise_exception=True) | ||||
|                 serializer.save() | ||||
|             else: | ||||
|                 serializer = AgentCustomFieldSerializer(data=custom_field) | ||||
|                 serializer.is_valid(raise_exception=True) | ||||
|                 serializer.save() | ||||
|  | ||||
|     return Response("ok") | ||||
|  | ||||
| @@ -119,16 +137,9 @@ def meshcentral(request, pk): | ||||
|     if token == "err": | ||||
|         return notify_error("Invalid mesh token") | ||||
|  | ||||
|     control = ( | ||||
|         f"{core.mesh_site}/?login={token}&node={agent.mesh_node_id}&viewmode=11&hide=31" | ||||
|     ) | ||||
|     terminal = ( | ||||
|         f"{core.mesh_site}/?login={token}&node={agent.mesh_node_id}&viewmode=12&hide=31" | ||||
|     ) | ||||
|     file = ( | ||||
|         f"{core.mesh_site}/?login={token}&node={agent.mesh_node_id}&viewmode=13&hide=31" | ||||
|     ) | ||||
|     webrdp = f"{core.mesh_site}/mstsc.html?login={token}&node={agent.mesh_node_id}" | ||||
|     control = f"{core.mesh_site}/?login={token}&gotonode={agent.mesh_node_id}&viewmode=11&hide=31" | ||||
|     terminal = f"{core.mesh_site}/?login={token}&gotonode={agent.mesh_node_id}&viewmode=12&hide=31" | ||||
|     file = f"{core.mesh_site}/?login={token}&gotonode={agent.mesh_node_id}&viewmode=13&hide=31" | ||||
|  | ||||
|     AuditLog.audit_mesh_session(username=request.user.username, hostname=agent.hostname) | ||||
|  | ||||
| @@ -137,7 +148,6 @@ def meshcentral(request, pk): | ||||
|         "control": control, | ||||
|         "terminal": terminal, | ||||
|         "file": file, | ||||
|         "webrdp": webrdp, | ||||
|         "status": agent.status, | ||||
|         "client": agent.client.name, | ||||
|         "site": agent.site.name, | ||||
| @@ -154,21 +164,15 @@ def agent_detail(request, pk): | ||||
| @api_view() | ||||
| def get_processes(request, pk): | ||||
|     agent = get_object_or_404(Agent, pk=pk) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|     r = asyncio.run(agent.nats_cmd(data={"func": "procs"}, timeout=5)) | ||||
|     if r == "timeout": | ||||
|         return notify_error("Unable to contact the agent") | ||||
|  | ||||
|     return Response(r) | ||||
|  | ||||
|  | ||||
| @api_view() | ||||
| def kill_proc(request, pk, pid): | ||||
|     agent = get_object_or_404(Agent, pk=pk) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|  | ||||
|     r = asyncio.run( | ||||
|         agent.nats_cmd({"func": "killproc", "procpid": int(pid)}, timeout=15) | ||||
|     ) | ||||
| @@ -184,41 +188,25 @@ def kill_proc(request, pk, pid): | ||||
| @api_view() | ||||
| def get_event_log(request, pk, logtype, days): | ||||
|     agent = get_object_or_404(Agent, pk=pk) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|     timeout = 180 if logtype == "Security" else 30 | ||||
|     data = { | ||||
|         "func": "eventlog", | ||||
|         "timeout": 30, | ||||
|         "timeout": timeout, | ||||
|         "payload": { | ||||
|             "logname": logtype, | ||||
|             "days": str(days), | ||||
|         }, | ||||
|     } | ||||
|     r = asyncio.run(agent.nats_cmd(data, timeout=32)) | ||||
|     r = asyncio.run(agent.nats_cmd(data, timeout=timeout + 2)) | ||||
|     if r == "timeout": | ||||
|         return notify_error("Unable to contact the agent") | ||||
|  | ||||
|     return Response(r) | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def power_action(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|     if request.data["action"] == "rebootnow": | ||||
|         r = asyncio.run(agent.nats_cmd({"func": "rebootnow"}, timeout=10)) | ||||
|         if r != "ok": | ||||
|             return notify_error("Unable to contact the agent") | ||||
|  | ||||
|     return Response("ok") | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def send_raw_cmd(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|     timeout = int(request.data["timeout"]) | ||||
|     data = { | ||||
|         "func": "rawcmd", | ||||
| @@ -243,21 +231,39 @@ def send_raw_cmd(request): | ||||
|     return Response(r) | ||||
|  | ||||
|  | ||||
| class AgentsTableList(generics.ListAPIView): | ||||
|     queryset = ( | ||||
|         Agent.objects.select_related("site") | ||||
|         .prefetch_related("agentchecks") | ||||
|         .only( | ||||
| class AgentsTableList(APIView): | ||||
|     def patch(self, request): | ||||
|         if "sitePK" in request.data.keys(): | ||||
|             queryset = ( | ||||
|                 Agent.objects.select_related("site", "policy", "alert_template") | ||||
|                 .prefetch_related("agentchecks") | ||||
|                 .filter(site_id=request.data["sitePK"]) | ||||
|             ) | ||||
|         elif "clientPK" in request.data.keys(): | ||||
|             queryset = ( | ||||
|                 Agent.objects.select_related("site", "policy", "alert_template") | ||||
|                 .prefetch_related("agentchecks") | ||||
|                 .filter(site__client_id=request.data["clientPK"]) | ||||
|             ) | ||||
|         else: | ||||
|             queryset = Agent.objects.select_related( | ||||
|                 "site", "policy", "alert_template" | ||||
|             ).prefetch_related("agentchecks") | ||||
|  | ||||
|         queryset = queryset.only( | ||||
|             "pk", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "site", | ||||
|             "policy", | ||||
|             "alert_template", | ||||
|             "monitoring_type", | ||||
|             "description", | ||||
|             "needs_reboot", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_time", | ||||
|             "offline_time", | ||||
|             "last_seen", | ||||
|             "boot_time", | ||||
|             "logged_in_username", | ||||
| @@ -265,14 +271,7 @@ class AgentsTableList(generics.ListAPIView): | ||||
|             "time_zone", | ||||
|             "maintenance_mode", | ||||
|         ) | ||||
|     ) | ||||
|     serializer_class = AgentTableSerializer | ||||
|  | ||||
|     def list(self, request): | ||||
|         queryset = self.get_queryset() | ||||
|         ctx = { | ||||
|             "default_tz": pytz.timezone(CoreSettings.objects.first().default_time_zone) | ||||
|         } | ||||
|         ctx = {"default_tz": get_default_timezone()} | ||||
|         serializer = AgentTableSerializer(queryset, many=True, context=ctx) | ||||
|         return Response(serializer.data) | ||||
|  | ||||
| @@ -289,107 +288,67 @@ def agent_edit_details(request, pk): | ||||
|     return Response(AgentEditSerializer(agent).data) | ||||
|  | ||||
|  | ||||
| @api_view() | ||||
| def by_client(request, clientpk): | ||||
|     agents = ( | ||||
|         Agent.objects.select_related("site") | ||||
|         .filter(site__client_id=clientpk) | ||||
|         .prefetch_related("agentchecks") | ||||
|         .only( | ||||
|             "pk", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "site", | ||||
|             "monitoring_type", | ||||
|             "description", | ||||
|             "needs_reboot", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_time", | ||||
|             "last_seen", | ||||
|             "boot_time", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "time_zone", | ||||
|             "maintenance_mode", | ||||
|         ) | ||||
|     ) | ||||
|     ctx = {"default_tz": pytz.timezone(CoreSettings.objects.first().default_time_zone)} | ||||
|     return Response(AgentTableSerializer(agents, many=True, context=ctx).data) | ||||
|  | ||||
|  | ||||
| @api_view() | ||||
| def by_site(request, sitepk): | ||||
|     agents = ( | ||||
|         Agent.objects.filter(site_id=sitepk) | ||||
|         .select_related("site") | ||||
|         .prefetch_related("agentchecks") | ||||
|         .only( | ||||
|             "pk", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "site", | ||||
|             "monitoring_type", | ||||
|             "description", | ||||
|             "needs_reboot", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_time", | ||||
|             "last_seen", | ||||
|             "boot_time", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "time_zone", | ||||
|             "maintenance_mode", | ||||
|         ) | ||||
|     ) | ||||
|     ctx = {"default_tz": pytz.timezone(CoreSettings.objects.first().default_time_zone)} | ||||
|     return Response(AgentTableSerializer(agents, many=True, context=ctx).data) | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def overdue_action(request): | ||||
|     pk = request.data["pk"] | ||||
|     alert_type = request.data["alertType"] | ||||
|     action = request.data["action"] | ||||
|     agent = get_object_or_404(Agent, pk=pk) | ||||
|     if alert_type == "email" and action == "enabled": | ||||
|         agent.overdue_email_alert = True | ||||
|         agent.save(update_fields=["overdue_email_alert"]) | ||||
|     elif alert_type == "email" and action == "disabled": | ||||
|         agent.overdue_email_alert = False | ||||
|         agent.save(update_fields=["overdue_email_alert"]) | ||||
|     elif alert_type == "text" and action == "enabled": | ||||
|         agent.overdue_text_alert = True | ||||
|         agent.save(update_fields=["overdue_text_alert"]) | ||||
|     elif alert_type == "text" and action == "disabled": | ||||
|         agent.overdue_text_alert = False | ||||
|         agent.save(update_fields=["overdue_text_alert"]) | ||||
|     else: | ||||
|         return Response( | ||||
|             {"error": "Something went wrong"}, status=status.HTTP_400_BAD_REQUEST | ||||
|         ) | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     serializer = AgentOverdueActionSerializer( | ||||
|         instance=agent, data=request.data, partial=True | ||||
|     ) | ||||
|     serializer.is_valid(raise_exception=True) | ||||
|     serializer.save() | ||||
|     return Response(agent.hostname) | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def reboot_later(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     date_time = request.data["datetime"] | ||||
| class Reboot(APIView): | ||||
|     # reboot now | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|         r = asyncio.run(agent.nats_cmd({"func": "rebootnow"}, timeout=10)) | ||||
|         if r != "ok": | ||||
|             return notify_error("Unable to contact the agent") | ||||
|  | ||||
|     try: | ||||
|         obj = dt.datetime.strptime(date_time, "%Y-%m-%d %H:%M") | ||||
|     except Exception: | ||||
|         return notify_error("Invalid date") | ||||
|         return Response("ok") | ||||
|  | ||||
|     r = agent.schedule_reboot(obj) | ||||
|     # reboot later | ||||
|     def patch(self, request): | ||||
|         agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|  | ||||
|     if r == "timeout": | ||||
|         return notify_error("Unable to contact the agent") | ||||
|     elif r == "failed": | ||||
|         return notify_error("Something went wrong") | ||||
|         try: | ||||
|             obj = dt.datetime.strptime(request.data["datetime"], "%Y-%m-%d %H:%M") | ||||
|         except Exception: | ||||
|             return notify_error("Invalid date") | ||||
|  | ||||
|     return Response(r["msg"]) | ||||
|         task_name = "TacticalRMM_SchedReboot_" + "".join( | ||||
|             random.choice(string.ascii_letters) for _ in range(10) | ||||
|         ) | ||||
|  | ||||
|         nats_data = { | ||||
|             "func": "schedtask", | ||||
|             "schedtaskpayload": { | ||||
|                 "type": "schedreboot", | ||||
|                 "deleteafter": True, | ||||
|                 "trigger": "once", | ||||
|                 "name": task_name, | ||||
|                 "year": int(dt.datetime.strftime(obj, "%Y")), | ||||
|                 "month": dt.datetime.strftime(obj, "%B"), | ||||
|                 "day": int(dt.datetime.strftime(obj, "%d")), | ||||
|                 "hour": int(dt.datetime.strftime(obj, "%H")), | ||||
|                 "min": int(dt.datetime.strftime(obj, "%M")), | ||||
|             }, | ||||
|         } | ||||
|  | ||||
|         r = asyncio.run(agent.nats_cmd(nats_data, timeout=10)) | ||||
|         if r != "ok": | ||||
|             return notify_error(r) | ||||
|  | ||||
|         details = {"taskname": task_name, "time": str(obj)} | ||||
|         PendingAction.objects.create( | ||||
|             agent=agent, action_type="schedreboot", details=details | ||||
|         ) | ||||
|         nice_time = dt.datetime.strftime(obj, "%B %d, %Y at %I:%M %p") | ||||
|         return Response( | ||||
|             {"time": nice_time, "agent": agent.hostname, "task_name": task_name} | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| @@ -423,124 +382,20 @@ def install_agent(request): | ||||
|     ) | ||||
|  | ||||
|     if request.data["installMethod"] == "exe": | ||||
|         go_bin = "/usr/local/rmmgo/go/bin/go" | ||||
|         from tacticalrmm.utils import generate_winagent_exe | ||||
|  | ||||
|         if not os.path.exists(go_bin): | ||||
|             return Response("nogolang", status=status.HTTP_409_CONFLICT) | ||||
|  | ||||
|         api = request.data["api"] | ||||
|         atype = request.data["agenttype"] | ||||
|         rdp = request.data["rdp"] | ||||
|         ping = request.data["ping"] | ||||
|         power = request.data["power"] | ||||
|  | ||||
|         file_name = "rmm-installer.exe" | ||||
|         exe = os.path.join(settings.EXE_DIR, file_name) | ||||
|  | ||||
|         if os.path.exists(exe): | ||||
|             try: | ||||
|                 os.remove(exe) | ||||
|             except Exception as e: | ||||
|                 logger.error(str(e)) | ||||
|  | ||||
|         goarch = "amd64" if arch == "64" else "386" | ||||
|         cmd = [ | ||||
|             "env", | ||||
|             "GOOS=windows", | ||||
|             f"GOARCH={goarch}", | ||||
|             go_bin, | ||||
|             "build", | ||||
|             f"-ldflags=\"-X 'main.Inno={inno}'", | ||||
|             f"-X 'main.Api={api}'", | ||||
|             f"-X 'main.Client={client_id}'", | ||||
|             f"-X 'main.Site={site_id}'", | ||||
|             f"-X 'main.Atype={atype}'", | ||||
|             f"-X 'main.Rdp={rdp}'", | ||||
|             f"-X 'main.Ping={ping}'", | ||||
|             f"-X 'main.Power={power}'", | ||||
|             f"-X 'main.DownloadUrl={download_url}'", | ||||
|             f"-X 'main.Token={token}'\"", | ||||
|             "-o", | ||||
|             exe, | ||||
|         ] | ||||
|  | ||||
|         build_error = False | ||||
|         gen_error = False | ||||
|  | ||||
|         gen = [ | ||||
|             "env", | ||||
|             "GOOS=windows", | ||||
|             f"GOARCH={goarch}", | ||||
|             go_bin, | ||||
|             "generate", | ||||
|         ] | ||||
|         try: | ||||
|             r1 = subprocess.run( | ||||
|                 " ".join(gen), | ||||
|                 capture_output=True, | ||||
|                 shell=True, | ||||
|                 cwd=os.path.join(settings.BASE_DIR, "core/goinstaller"), | ||||
|             ) | ||||
|         except Exception as e: | ||||
|             gen_error = True | ||||
|             logger.error(str(e)) | ||||
|             return Response( | ||||
|                 "genfailed", status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE | ||||
|             ) | ||||
|  | ||||
|         if r1.returncode != 0: | ||||
|             gen_error = True | ||||
|             if r1.stdout: | ||||
|                 logger.error(r1.stdout.decode("utf-8", errors="ignore")) | ||||
|  | ||||
|             if r1.stderr: | ||||
|                 logger.error(r1.stderr.decode("utf-8", errors="ignore")) | ||||
|  | ||||
|             logger.error(f"Go build failed with return code {r1.returncode}") | ||||
|  | ||||
|         if gen_error: | ||||
|             return Response( | ||||
|                 "genfailed", status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE | ||||
|             ) | ||||
|  | ||||
|         try: | ||||
|             r = subprocess.run( | ||||
|                 " ".join(cmd), | ||||
|                 capture_output=True, | ||||
|                 shell=True, | ||||
|                 cwd=os.path.join(settings.BASE_DIR, "core/goinstaller"), | ||||
|             ) | ||||
|         except Exception as e: | ||||
|             build_error = True | ||||
|             logger.error(str(e)) | ||||
|             return Response("buildfailed", status=status.HTTP_412_PRECONDITION_FAILED) | ||||
|  | ||||
|         if r.returncode != 0: | ||||
|             build_error = True | ||||
|             if r.stdout: | ||||
|                 logger.error(r.stdout.decode("utf-8", errors="ignore")) | ||||
|  | ||||
|             if r.stderr: | ||||
|                 logger.error(r.stderr.decode("utf-8", errors="ignore")) | ||||
|  | ||||
|             logger.error(f"Go build failed with return code {r.returncode}") | ||||
|  | ||||
|         if build_error: | ||||
|             return Response("buildfailed", status=status.HTTP_412_PRECONDITION_FAILED) | ||||
|  | ||||
|         if settings.DEBUG: | ||||
|             with open(exe, "rb") as f: | ||||
|                 response = HttpResponse( | ||||
|                     f.read(), | ||||
|                     content_type="application/vnd.microsoft.portable-executable", | ||||
|                 ) | ||||
|                 response["Content-Disposition"] = f"inline; filename={file_name}" | ||||
|                 return response | ||||
|         else: | ||||
|             response = HttpResponse() | ||||
|             response["Content-Disposition"] = f"attachment; filename={file_name}" | ||||
|             response["X-Accel-Redirect"] = f"/private/exe/{file_name}" | ||||
|             return response | ||||
|         return generate_winagent_exe( | ||||
|             client=client_id, | ||||
|             site=site_id, | ||||
|             agent_type=request.data["agenttype"], | ||||
|             rdp=request.data["rdp"], | ||||
|             ping=request.data["ping"], | ||||
|             power=request.data["power"], | ||||
|             arch=arch, | ||||
|             token=token, | ||||
|             api=request.data["api"], | ||||
|             file_name=request.data["fileName"], | ||||
|         ) | ||||
|  | ||||
|     elif request.data["installMethod"] == "manual": | ||||
|         cmd = [ | ||||
| @@ -548,12 +403,10 @@ def install_agent(request): | ||||
|             "/VERYSILENT", | ||||
|             "/SUPPRESSMSGBOXES", | ||||
|             "&&", | ||||
|             "timeout", | ||||
|             "/t", | ||||
|             "10", | ||||
|             "/nobreak", | ||||
|             ">", | ||||
|             "NUL", | ||||
|             "ping", | ||||
|             "127.0.0.1", | ||||
|             "-n", | ||||
|             "5", | ||||
|             "&&", | ||||
|             r'"C:\Program Files\TacticalAgent\tacticalrmm.exe"', | ||||
|             "-m", | ||||
| @@ -580,8 +433,6 @@ def install_agent(request): | ||||
|         resp = { | ||||
|             "cmd": " ".join(str(i) for i in cmd), | ||||
|             "url": download_url, | ||||
|             "salt64": settings.SALT_64, | ||||
|             "salt32": settings.SALT_32, | ||||
|         } | ||||
|  | ||||
|         return Response(resp) | ||||
| @@ -638,27 +489,14 @@ def recover(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     mode = request.data["mode"] | ||||
|  | ||||
|     if pyver.parse(agent.version) <= pyver.parse("0.9.5"): | ||||
|         return notify_error("Only available in agent version greater than 0.9.5") | ||||
|     # attempt a realtime recovery, otherwise fall back to old recovery method | ||||
|     if mode == "tacagent" or mode == "mesh": | ||||
|         data = {"func": "recover", "payload": {"mode": mode}} | ||||
|         r = asyncio.run(agent.nats_cmd(data, timeout=10)) | ||||
|         if r == "ok": | ||||
|             return Response("Successfully completed recovery") | ||||
|  | ||||
|     if not agent.has_nats: | ||||
|         if mode == "tacagent" or mode == "checkrunner" or mode == "rpc": | ||||
|             return notify_error("Requires agent version 1.1.0 or greater") | ||||
|  | ||||
|     # attempt a realtime recovery if supported, otherwise fall back to old recovery method | ||||
|     if agent.has_nats: | ||||
|         if ( | ||||
|             mode == "tacagent" | ||||
|             or mode == "checkrunner" | ||||
|             or mode == "salt" | ||||
|             or mode == "mesh" | ||||
|         ): | ||||
|             data = {"func": "recover", "payload": {"mode": mode}} | ||||
|             r = asyncio.run(agent.nats_cmd(data, timeout=10)) | ||||
|             if r == "ok": | ||||
|                 return Response("Successfully completed recovery") | ||||
|  | ||||
|     if agent.recoveryactions.filter(last_run=None).exists(): | ||||
|     if agent.recoveryactions.filter(last_run=None).exists():  # type: ignore | ||||
|         return notify_error( | ||||
|             "A recovery action is currently pending. Please wait for the next agent check-in." | ||||
|         ) | ||||
| @@ -686,10 +524,9 @@ def recover(request): | ||||
| @api_view(["POST"]) | ||||
| def run_script(request): | ||||
|     agent = get_object_or_404(Agent, pk=request.data["pk"]) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|     script = get_object_or_404(Script, pk=request.data["scriptPK"]) | ||||
|     output = request.data["output"] | ||||
|     args = request.data["args"] | ||||
|     req_timeout = int(request.data["timeout"]) + 3 | ||||
|  | ||||
|     AuditLog.audit_script_run( | ||||
| @@ -698,30 +535,32 @@ def run_script(request): | ||||
|         script=script.name, | ||||
|     ) | ||||
|  | ||||
|     data = { | ||||
|         "func": "runscript", | ||||
|         "timeout": request.data["timeout"], | ||||
|         "script_args": request.data["args"], | ||||
|         "payload": { | ||||
|             "code": script.code, | ||||
|             "shell": script.shell, | ||||
|         }, | ||||
|     } | ||||
|  | ||||
|     if output == "wait": | ||||
|         r = asyncio.run(agent.nats_cmd(data, timeout=req_timeout)) | ||||
|         r = agent.run_script( | ||||
|             scriptpk=script.pk, args=args, timeout=req_timeout, wait=True | ||||
|         ) | ||||
|         return Response(r) | ||||
|  | ||||
|     elif output == "email": | ||||
|         emails = ( | ||||
|             [] if request.data["emailmode"] == "default" else request.data["emails"] | ||||
|         ) | ||||
|         run_script_email_results_task.delay( | ||||
|             agentpk=agent.pk, | ||||
|             scriptpk=script.pk, | ||||
|             nats_timeout=req_timeout, | ||||
|             emails=emails, | ||||
|             args=args, | ||||
|         ) | ||||
|     else: | ||||
|         asyncio.run(agent.nats_cmd(data, wait=False)) | ||||
|         return Response(f"{script.name} will now be run on {agent.hostname}") | ||||
|         agent.run_script(scriptpk=script.pk, args=args, timeout=req_timeout) | ||||
|  | ||||
|     return Response(f"{script.name} will now be run on {agent.hostname}") | ||||
|  | ||||
|  | ||||
| @api_view() | ||||
| def recover_mesh(request, pk): | ||||
|     agent = get_object_or_404(Agent, pk=pk) | ||||
|     if not agent.has_nats: | ||||
|         return notify_error("Requires agent version 1.1.0 or greater") | ||||
|  | ||||
|     data = {"func": "recover", "payload": {"mode": "mesh"}} | ||||
|     r = asyncio.run(agent.nats_cmd(data, timeout=45)) | ||||
|     if r != "ok": | ||||
| @@ -794,12 +633,16 @@ def bulk(request): | ||||
|     elif request.data["target"] == "agents": | ||||
|         q = Agent.objects.filter(pk__in=request.data["agentPKs"]) | ||||
|     elif request.data["target"] == "all": | ||||
|         q = Agent.objects.all() | ||||
|         q = Agent.objects.only("pk", "monitoring_type") | ||||
|     else: | ||||
|         return notify_error("Something went wrong") | ||||
|  | ||||
|     minions = [agent.salt_id for agent in q] | ||||
|     agents = [agent.pk for agent in q] | ||||
|     if request.data["monType"] == "servers": | ||||
|         q = q.filter(monitoring_type="server") | ||||
|     elif request.data["monType"] == "workstations": | ||||
|         q = q.filter(monitoring_type="workstation") | ||||
|  | ||||
|     agents: list[int] = [agent.pk for agent in q] | ||||
|  | ||||
|     AuditLog.audit_bulk_action(request.user, request.data["mode"], request.data) | ||||
|  | ||||
| @@ -817,39 +660,17 @@ def bulk(request): | ||||
|         return Response(f"{script.name} will now be run on {len(agents)} agents") | ||||
|  | ||||
|     elif request.data["mode"] == "install": | ||||
|         r = Agent.salt_batch_async(minions=minions, func="win_agent.install_updates") | ||||
|         if r == "timeout": | ||||
|             return notify_error("Salt API not running") | ||||
|         bulk_install_updates_task.delay(agents) | ||||
|         return Response( | ||||
|             f"Pending updates will now be installed on {len(agents)} agents" | ||||
|         ) | ||||
|     elif request.data["mode"] == "scan": | ||||
|         bulk_check_for_updates_task.delay(minions=minions) | ||||
|         bulk_check_for_updates_task.delay(agents) | ||||
|         return Response(f"Patch status scan will now run on {len(agents)} agents") | ||||
|  | ||||
|     return notify_error("Something went wrong") | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def agent_counts(request): | ||||
|     return Response( | ||||
|         { | ||||
|             "total_server_count": Agent.objects.filter( | ||||
|                 monitoring_type="server" | ||||
|             ).count(), | ||||
|             "total_server_offline_count": AgentOutage.objects.filter( | ||||
|                 recovery_time=None, agent__monitoring_type="server" | ||||
|             ).count(), | ||||
|             "total_workstation_count": Agent.objects.filter( | ||||
|                 monitoring_type="workstation" | ||||
|             ).count(), | ||||
|             "total_workstation_offline_count": AgentOutage.objects.filter( | ||||
|                 recovery_time=None, agent__monitoring_type="workstation" | ||||
|             ).count(), | ||||
|         } | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| def agent_maintenance(request): | ||||
|     if request.data["type"] == "Client": | ||||
| @@ -871,3 +692,12 @@ def agent_maintenance(request): | ||||
|         return notify_error("Invalid data") | ||||
|  | ||||
|     return Response("ok") | ||||
|  | ||||
|  | ||||
| class WMI(APIView): | ||||
|     def get(self, request, pk): | ||||
|         agent = get_object_or_404(Agent, pk=pk) | ||||
|         r = asyncio.run(agent.nats_cmd({"func": "sysinfo"}, timeout=20)) | ||||
|         if r != "ok": | ||||
|             return notify_error("Unable to contact the agent") | ||||
|         return Response("ok") | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| from django.contrib import admin | ||||
|  | ||||
| from .models import Alert | ||||
|  | ||||
| from .models import Alert, AlertTemplate | ||||
|  | ||||
| admin.site.register(Alert) | ||||
| admin.site.register(AlertTemplate) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # Generated by Django 3.1 on 2020-08-15 15:31 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
| @@ -42,4 +42,4 @@ class Migration(migrations.Migration): | ||||
|                 ), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
|     ] | ||||
| @@ -27,4 +27,4 @@ class Migration(migrations.Migration): | ||||
|                 max_length=100, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
|     ] | ||||
| @@ -1,25 +1,31 @@ | ||||
| # Generated by Django 3.1.2 on 2020-10-21 18:15 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('checks', '0010_auto_20200922_1344'), | ||||
|         ('alerts', '0002_auto_20200815_1618'), | ||||
|         ("checks", "0010_auto_20200922_1344"), | ||||
|         ("alerts", "0002_auto_20200815_1618"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='assigned_check', | ||||
|             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='alert', to='checks.check'), | ||||
|             model_name="alert", | ||||
|             name="assigned_check", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.CASCADE, | ||||
|                 related_name="alert", | ||||
|                 to="checks.check", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alert', | ||||
|             name='alert_time', | ||||
|             model_name="alert", | ||||
|             name="alert_time", | ||||
|             field=models.DateTimeField(auto_now_add=True, null=True), | ||||
|         ), | ||||
|     ] | ||||
|     ] | ||||
							
								
								
									
										172
									
								
								api/tacticalrmm/alerts/migrations/0004_auto_20210212_1408.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										172
									
								
								api/tacticalrmm/alerts/migrations/0004_auto_20210212_1408.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,172 @@ | ||||
| # Generated by Django 3.1.4 on 2021-02-12 14:08 | ||||
|  | ||||
| import django.contrib.postgres.fields | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('agents', '0029_delete_agentoutage'), | ||||
|         ('clients', '0008_auto_20201103_1430'), | ||||
|         ('autotasks', '0017_auto_20210210_1512'), | ||||
|         ('scripts', '0005_auto_20201207_1606'), | ||||
|         ('alerts', '0003_auto_20201021_1815'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='action_execution_time', | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='action_retcode', | ||||
|             field=models.IntegerField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='action_run', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='action_stderr', | ||||
|             field=models.TextField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='action_stdout', | ||||
|             field=models.TextField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='action_timeout', | ||||
|             field=models.PositiveIntegerField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='alert_type', | ||||
|             field=models.CharField(choices=[('availability', 'Availability'), ('check', 'Check'), ('task', 'Task'), ('custom', 'Custom')], default='availability', max_length=20), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='assigned_task', | ||||
|             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='alert', to='autotasks.automatedtask'), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='email_sent', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='hidden', | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_execution_time', | ||||
|             field=models.CharField(blank=True, max_length=100, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_retcode', | ||||
|             field=models.IntegerField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_run', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_stderr', | ||||
|             field=models.TextField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_stdout', | ||||
|             field=models.TextField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_timeout', | ||||
|             field=models.PositiveIntegerField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_email_sent', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_on', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='resolved_sms_sent', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='sms_sent', | ||||
|             field=models.DateTimeField(blank=True, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alert', | ||||
|             name='snoozed', | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alert', | ||||
|             name='severity', | ||||
|             field=models.CharField(choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], default='info', max_length=30), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='AlertTemplate', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('name', models.CharField(max_length=100)), | ||||
|                 ('is_active', models.BooleanField(default=True)), | ||||
|                 ('action_args', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=255, null=True), blank=True, default=list, null=True, size=None)), | ||||
|                 ('resolved_action_args', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=255, null=True), blank=True, default=list, null=True, size=None)), | ||||
|                 ('email_recipients', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=100), blank=True, default=list, null=True, size=None)), | ||||
|                 ('text_recipients', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, max_length=100), blank=True, default=list, null=True, size=None)), | ||||
|                 ('email_from', models.EmailField(blank=True, max_length=254, null=True)), | ||||
|                 ('agent_email_on_resolved', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('agent_text_on_resolved', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('agent_include_desktops', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('agent_always_email', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('agent_always_text', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('agent_always_alert', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('agent_periodic_alert_days', models.PositiveIntegerField(blank=True, default=0, null=True)), | ||||
|                 ('check_email_alert_severity', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], max_length=25), blank=True, default=list, size=None)), | ||||
|                 ('check_text_alert_severity', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], max_length=25), blank=True, default=list, size=None)), | ||||
|                 ('check_dashboard_alert_severity', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], max_length=25), blank=True, default=list, size=None)), | ||||
|                 ('check_email_on_resolved', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('check_text_on_resolved', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('check_always_email', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('check_always_text', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('check_always_alert', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('check_periodic_alert_days', models.PositiveIntegerField(blank=True, default=0, null=True)), | ||||
|                 ('task_email_alert_severity', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], max_length=25), blank=True, default=list, size=None)), | ||||
|                 ('task_text_alert_severity', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], max_length=25), blank=True, default=list, size=None)), | ||||
|                 ('task_dashboard_alert_severity', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(blank=True, choices=[('info', 'Informational'), ('warning', 'Warning'), ('error', 'Error')], max_length=25), blank=True, default=list, size=None)), | ||||
|                 ('task_email_on_resolved', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('task_text_on_resolved', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('task_always_email', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('task_always_text', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('task_always_alert', models.BooleanField(blank=True, default=False, null=True)), | ||||
|                 ('task_periodic_alert_days', models.PositiveIntegerField(blank=True, default=0, null=True)), | ||||
|                 ('action', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='alert_template', to='scripts.script')), | ||||
|                 ('excluded_agents', models.ManyToManyField(blank=True, related_name='alert_exclusions', to='agents.Agent')), | ||||
|                 ('excluded_clients', models.ManyToManyField(blank=True, related_name='alert_exclusions', to='clients.Client')), | ||||
|                 ('excluded_sites', models.ManyToManyField(blank=True, related_name='alert_exclusions', to='clients.Site')), | ||||
|                 ('resolved_action', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='resolved_alert_template', to='scripts.script')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										31
									
								
								api/tacticalrmm/alerts/migrations/0005_auto_20210212_1745.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								api/tacticalrmm/alerts/migrations/0005_auto_20210212_1745.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | ||||
| # Generated by Django 3.1.4 on 2021-02-12 17:45 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('alerts', '0004_auto_20210212_1408'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='alert', | ||||
|             name='action_timeout', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='alert', | ||||
|             name='resolved_action_timeout', | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alerttemplate', | ||||
|             name='action_timeout', | ||||
|             field=models.PositiveIntegerField(default=15), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alerttemplate', | ||||
|             name='resolved_action_timeout', | ||||
|             field=models.PositiveIntegerField(default=15), | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										72
									
								
								api/tacticalrmm/alerts/migrations/0006_auto_20210217_1736.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										72
									
								
								api/tacticalrmm/alerts/migrations/0006_auto_20210217_1736.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,72 @@ | ||||
| # Generated by Django 3.1.6 on 2021-02-17 17:36 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('alerts', '0005_auto_20210212_1745'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='alerttemplate', | ||||
|             name='agent_include_desktops', | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alerttemplate', | ||||
|             name='exclude_servers', | ||||
|             field=models.BooleanField(blank=True, default=False, null=True), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='alerttemplate', | ||||
|             name='exclude_workstations', | ||||
|             field=models.BooleanField(blank=True, default=False, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='agent_always_alert', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='agent_always_email', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='agent_always_text', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='check_always_alert', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='check_always_email', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='check_always_text', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='task_always_alert', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='task_always_email', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name='alerttemplate', | ||||
|             name='task_always_text', | ||||
|             field=models.BooleanField(blank=True, default=None, null=True), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,5 +1,20 @@ | ||||
| from django.db import models | ||||
| from __future__ import annotations | ||||
|  | ||||
| from typing import TYPE_CHECKING, Union | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| from django.db import models | ||||
| from django.db.models.fields import BooleanField, PositiveIntegerField | ||||
| from django.utils import timezone as djangotime | ||||
| from loguru import logger | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from agents.models import Agent | ||||
|     from autotasks.models import AutomatedTask | ||||
|     from checks.models import Check | ||||
|  | ||||
| logger.configure(**settings.LOG_CONFIG) | ||||
|  | ||||
| SEVERITY_CHOICES = [ | ||||
|     ("info", "Informational"), | ||||
| @@ -7,6 +22,13 @@ SEVERITY_CHOICES = [ | ||||
|     ("error", "Error"), | ||||
| ] | ||||
|  | ||||
| ALERT_TYPE_CHOICES = [ | ||||
|     ("availability", "Availability"), | ||||
|     ("check", "Check"), | ||||
|     ("task", "Task"), | ||||
|     ("custom", "Custom"), | ||||
| ] | ||||
|  | ||||
|  | ||||
| class Alert(models.Model): | ||||
|     agent = models.ForeignKey( | ||||
| @@ -23,21 +45,554 @@ class Alert(models.Model): | ||||
|         null=True, | ||||
|         blank=True, | ||||
|     ) | ||||
|     assigned_task = models.ForeignKey( | ||||
|         "autotasks.AutomatedTask", | ||||
|         related_name="alert", | ||||
|         on_delete=models.CASCADE, | ||||
|         null=True, | ||||
|         blank=True, | ||||
|     ) | ||||
|     alert_type = models.CharField( | ||||
|         max_length=20, choices=ALERT_TYPE_CHOICES, default="availability" | ||||
|     ) | ||||
|     message = models.TextField(null=True, blank=True) | ||||
|     alert_time = models.DateTimeField(auto_now_add=True, null=True) | ||||
|     alert_time = models.DateTimeField(auto_now_add=True, null=True, blank=True) | ||||
|     snoozed = models.BooleanField(default=False) | ||||
|     snooze_until = models.DateTimeField(null=True, blank=True) | ||||
|     resolved = models.BooleanField(default=False) | ||||
|     severity = models.CharField( | ||||
|         max_length=100, choices=SEVERITY_CHOICES, default="info" | ||||
|     resolved_on = models.DateTimeField(null=True, blank=True) | ||||
|     severity = models.CharField(max_length=30, choices=SEVERITY_CHOICES, default="info") | ||||
|     email_sent = models.DateTimeField(null=True, blank=True) | ||||
|     resolved_email_sent = models.DateTimeField(null=True, blank=True) | ||||
|     sms_sent = models.DateTimeField(null=True, blank=True) | ||||
|     resolved_sms_sent = models.DateTimeField(null=True, blank=True) | ||||
|     hidden = models.BooleanField(default=False) | ||||
|     action_run = models.DateTimeField(null=True, blank=True) | ||||
|     action_stdout = models.TextField(null=True, blank=True) | ||||
|     action_stderr = models.TextField(null=True, blank=True) | ||||
|     action_retcode = models.IntegerField(null=True, blank=True) | ||||
|     action_execution_time = models.CharField(max_length=100, null=True, blank=True) | ||||
|     resolved_action_run = models.DateTimeField(null=True, blank=True) | ||||
|     resolved_action_stdout = models.TextField(null=True, blank=True) | ||||
|     resolved_action_stderr = models.TextField(null=True, blank=True) | ||||
|     resolved_action_retcode = models.IntegerField(null=True, blank=True) | ||||
|     resolved_action_execution_time = models.CharField( | ||||
|         max_length=100, null=True, blank=True | ||||
|     ) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.message | ||||
|  | ||||
|     def resolve(self): | ||||
|         self.resolved = True | ||||
|         self.resolved_on = djangotime.now() | ||||
|         self.snoozed = False | ||||
|         self.snooze_until = None | ||||
|         self.save() | ||||
|  | ||||
|     @classmethod | ||||
|     def create_availability_alert(cls, agent): | ||||
|         pass | ||||
|      | ||||
|     def create_or_return_availability_alert(cls, agent): | ||||
|         if not cls.objects.filter(agent=agent, resolved=False).exists(): | ||||
|             return cls.objects.create( | ||||
|                 agent=agent, | ||||
|                 alert_type="availability", | ||||
|                 severity="error", | ||||
|                 message=f"{agent.hostname} in {agent.client.name}\\{agent.site.name} is overdue.", | ||||
|                 hidden=True, | ||||
|             ) | ||||
|         else: | ||||
|             return cls.objects.get(agent=agent, resolved=False) | ||||
|  | ||||
|     @classmethod | ||||
|     def create_check_alert(cls, check): | ||||
|         pass | ||||
|     def create_or_return_check_alert(cls, check): | ||||
|  | ||||
|         if not cls.objects.filter(assigned_check=check, resolved=False).exists(): | ||||
|             return cls.objects.create( | ||||
|                 assigned_check=check, | ||||
|                 alert_type="check", | ||||
|                 severity=check.alert_severity, | ||||
|                 message=f"{check.agent.hostname} has a {check.check_type} check: {check.readable_desc} that failed.", | ||||
|                 hidden=True, | ||||
|             ) | ||||
|         else: | ||||
|             return cls.objects.get(assigned_check=check, resolved=False) | ||||
|  | ||||
|     @classmethod | ||||
|     def create_or_return_task_alert(cls, task): | ||||
|  | ||||
|         if not cls.objects.filter(assigned_task=task, resolved=False).exists(): | ||||
|             return cls.objects.create( | ||||
|                 assigned_task=task, | ||||
|                 alert_type="task", | ||||
|                 severity=task.alert_severity, | ||||
|                 message=f"{task.agent.hostname} has task: {task.name} that failed.", | ||||
|                 hidden=True, | ||||
|             ) | ||||
|         else: | ||||
|             return cls.objects.get(assigned_task=task, resolved=False) | ||||
|  | ||||
|     @classmethod | ||||
|     def handle_alert_failure(cls, instance: Union[Agent, AutomatedTask, Check]) -> None: | ||||
|         from agents.models import Agent | ||||
|         from autotasks.models import AutomatedTask | ||||
|         from checks.models import Check | ||||
|  | ||||
|         # set variables | ||||
|         dashboard_severities = None | ||||
|         email_severities = None | ||||
|         text_severities = None | ||||
|         always_dashboard = None | ||||
|         always_email = None | ||||
|         always_text = None | ||||
|         alert_interval = None | ||||
|         email_task = None | ||||
|         text_task = None | ||||
|  | ||||
|         # check what the instance passed is | ||||
|         if isinstance(instance, Agent): | ||||
|             from agents.tasks import agent_outage_email_task, agent_outage_sms_task | ||||
|  | ||||
|             email_task = agent_outage_email_task | ||||
|             text_task = agent_outage_sms_task | ||||
|  | ||||
|             email_alert = instance.overdue_email_alert | ||||
|             text_alert = instance.overdue_text_alert | ||||
|             dashboard_alert = instance.overdue_dashboard_alert | ||||
|             alert_template = instance.alert_template | ||||
|             maintenance_mode = instance.maintenance_mode | ||||
|             alert_severity = "error" | ||||
|             agent = instance | ||||
|  | ||||
|             # set alert_template settings | ||||
|             if alert_template: | ||||
|                 dashboard_severities = ["error"] | ||||
|                 email_severities = ["error"] | ||||
|                 text_severities = ["error"] | ||||
|                 always_dashboard = alert_template.agent_always_alert | ||||
|                 always_email = alert_template.agent_always_email | ||||
|                 always_text = alert_template.agent_always_text | ||||
|                 alert_interval = alert_template.agent_periodic_alert_days | ||||
|  | ||||
|             if instance.should_create_alert(alert_template): | ||||
|                 alert = cls.create_or_return_availability_alert(instance) | ||||
|             else: | ||||
|                 # check if there is an alert that exists | ||||
|                 if cls.objects.filter(agent=instance, resolved=False).exists(): | ||||
|                     alert = cls.objects.get(agent=instance, resolved=False) | ||||
|                 else: | ||||
|                     alert = None | ||||
|  | ||||
|         elif isinstance(instance, Check): | ||||
|             from checks.tasks import ( | ||||
|                 handle_check_email_alert_task, | ||||
|                 handle_check_sms_alert_task, | ||||
|             ) | ||||
|  | ||||
|             email_task = handle_check_email_alert_task | ||||
|             text_task = handle_check_sms_alert_task | ||||
|  | ||||
|             email_alert = instance.email_alert | ||||
|             text_alert = instance.text_alert | ||||
|             dashboard_alert = instance.dashboard_alert | ||||
|             alert_template = instance.agent.alert_template | ||||
|             maintenance_mode = instance.agent.maintenance_mode | ||||
|             alert_severity = instance.alert_severity | ||||
|             agent = instance.agent | ||||
|  | ||||
|             # set alert_template settings | ||||
|             if alert_template: | ||||
|                 dashboard_severities = alert_template.check_dashboard_alert_severity | ||||
|                 email_severities = alert_template.check_email_alert_severity | ||||
|                 text_severities = alert_template.check_text_alert_severity | ||||
|                 always_dashboard = alert_template.check_always_alert | ||||
|                 always_email = alert_template.check_always_email | ||||
|                 always_text = alert_template.check_always_text | ||||
|                 alert_interval = alert_template.check_periodic_alert_days | ||||
|  | ||||
|             if instance.should_create_alert(alert_template): | ||||
|                 alert = cls.create_or_return_check_alert(instance) | ||||
|             else: | ||||
|                 # check if there is an alert that exists | ||||
|                 if cls.objects.filter(assigned_check=instance, resolved=False).exists(): | ||||
|                     alert = cls.objects.get(assigned_check=instance, resolved=False) | ||||
|                 else: | ||||
|                     alert = None | ||||
|  | ||||
|         elif isinstance(instance, AutomatedTask): | ||||
|             from autotasks.tasks import handle_task_email_alert, handle_task_sms_alert | ||||
|  | ||||
|             email_task = handle_task_email_alert | ||||
|             text_task = handle_task_sms_alert | ||||
|  | ||||
|             email_alert = instance.email_alert | ||||
|             text_alert = instance.text_alert | ||||
|             dashboard_alert = instance.dashboard_alert | ||||
|             alert_template = instance.agent.alert_template | ||||
|             maintenance_mode = instance.agent.maintenance_mode | ||||
|             alert_severity = instance.alert_severity | ||||
|             agent = instance.agent | ||||
|  | ||||
|             # set alert_template settings | ||||
|             if alert_template: | ||||
|                 dashboard_severities = alert_template.task_dashboard_alert_severity | ||||
|                 email_severities = alert_template.task_email_alert_severity | ||||
|                 text_severities = alert_template.task_text_alert_severity | ||||
|                 always_dashboard = alert_template.task_always_alert | ||||
|                 always_email = alert_template.task_always_email | ||||
|                 always_text = alert_template.task_always_text | ||||
|                 alert_interval = alert_template.task_periodic_alert_days | ||||
|  | ||||
|             if instance.should_create_alert(alert_template): | ||||
|                 alert = cls.create_or_return_task_alert(instance) | ||||
|             else: | ||||
|                 # check if there is an alert that exists | ||||
|                 if cls.objects.filter(assigned_task=instance, resolved=False).exists(): | ||||
|                     alert = cls.objects.get(assigned_task=instance, resolved=False) | ||||
|                 else: | ||||
|                     alert = None | ||||
|         else: | ||||
|             return | ||||
|  | ||||
|         # return if agent is in maintenance mode | ||||
|         if maintenance_mode or not alert: | ||||
|             return | ||||
|  | ||||
|         # check if alert severity changed on check and update the alert | ||||
|         if alert_severity != alert.severity: | ||||
|             alert.severity = alert_severity | ||||
|             alert.save(update_fields=["severity"]) | ||||
|  | ||||
|         # create alert in dashboard if enabled | ||||
|         if dashboard_alert or always_dashboard: | ||||
|  | ||||
|             # check if alert template is set and specific severities are configured | ||||
|             if alert_template and alert.severity not in dashboard_severities:  # type: ignore | ||||
|                 pass | ||||
|             else: | ||||
|                 alert.hidden = False | ||||
|                 alert.save() | ||||
|  | ||||
|         # send email if enabled | ||||
|         if email_alert or always_email: | ||||
|  | ||||
|             # check if alert template is set and specific severities are configured | ||||
|             if alert_template and alert.severity not in email_severities:  # type: ignore | ||||
|                 pass | ||||
|             else: | ||||
|                 email_task.delay( | ||||
|                     pk=alert.pk, | ||||
|                     alert_interval=alert_interval, | ||||
|                 ) | ||||
|  | ||||
|         # send text if enabled | ||||
|         if text_alert or always_text: | ||||
|  | ||||
|             # check if alert template is set and specific severities are configured | ||||
|             if alert_template and alert.severity not in text_severities:  # type: ignore | ||||
|                 pass | ||||
|             else: | ||||
|                 text_task.delay(pk=alert.pk, alert_interval=alert_interval) | ||||
|  | ||||
|         # check if any scripts should be run | ||||
|         if alert_template and alert_template.action and not alert.action_run: | ||||
|             r = agent.run_script( | ||||
|                 scriptpk=alert_template.action.pk, | ||||
|                 args=alert_template.action_args, | ||||
|                 timeout=alert_template.action_timeout, | ||||
|                 wait=True, | ||||
|                 full=True, | ||||
|                 run_on_any=True, | ||||
|             ) | ||||
|  | ||||
|             # command was successful | ||||
|             if type(r) == dict: | ||||
|                 alert.action_retcode = r["retcode"] | ||||
|                 alert.action_stdout = r["stdout"] | ||||
|                 alert.action_stderr = r["stderr"] | ||||
|                 alert.action_execution_time = "{:.4f}".format(r["execution_time"]) | ||||
|                 alert.action_run = djangotime.now() | ||||
|                 alert.save() | ||||
|             else: | ||||
|                 logger.error( | ||||
|                     f"Failure action: {alert_template.action.name} failed to run on any agent for {agent.hostname} failure alert" | ||||
|                 ) | ||||
|  | ||||
|     @classmethod | ||||
|     def handle_alert_resolve(cls, instance: Union[Agent, AutomatedTask, Check]) -> None: | ||||
|         from agents.models import Agent | ||||
|         from autotasks.models import AutomatedTask | ||||
|         from checks.models import Check | ||||
|  | ||||
|         # set variables | ||||
|         email_on_resolved = False | ||||
|         text_on_resolved = False | ||||
|         resolved_email_task = None | ||||
|         resolved_text_task = None | ||||
|  | ||||
|         # check what the instance passed is | ||||
|         if isinstance(instance, Agent): | ||||
|             from agents.tasks import agent_recovery_email_task, agent_recovery_sms_task | ||||
|  | ||||
|             resolved_email_task = agent_recovery_email_task | ||||
|             resolved_text_task = agent_recovery_sms_task | ||||
|  | ||||
|             alert_template = instance.alert_template | ||||
|             alert = cls.objects.get(agent=instance, resolved=False) | ||||
|             maintenance_mode = instance.maintenance_mode | ||||
|             agent = instance | ||||
|  | ||||
|             if alert_template: | ||||
|                 email_on_resolved = alert_template.agent_email_on_resolved | ||||
|                 text_on_resolved = alert_template.agent_text_on_resolved | ||||
|  | ||||
|         elif isinstance(instance, Check): | ||||
|             from checks.tasks import ( | ||||
|                 handle_resolved_check_email_alert_task, | ||||
|                 handle_resolved_check_sms_alert_task, | ||||
|             ) | ||||
|  | ||||
|             resolved_email_task = handle_resolved_check_email_alert_task | ||||
|             resolved_text_task = handle_resolved_check_sms_alert_task | ||||
|  | ||||
|             alert_template = instance.agent.alert_template | ||||
|             alert = cls.objects.get(assigned_check=instance, resolved=False) | ||||
|             maintenance_mode = instance.agent.maintenance_mode | ||||
|             agent = instance.agent | ||||
|  | ||||
|             if alert_template: | ||||
|                 email_on_resolved = alert_template.check_email_on_resolved | ||||
|                 text_on_resolved = alert_template.check_text_on_resolved | ||||
|  | ||||
|         elif isinstance(instance, AutomatedTask): | ||||
|             from autotasks.tasks import ( | ||||
|                 handle_resolved_task_email_alert, | ||||
|                 handle_resolved_task_sms_alert, | ||||
|             ) | ||||
|  | ||||
|             resolved_email_task = handle_resolved_task_email_alert | ||||
|             resolved_text_task = handle_resolved_task_sms_alert | ||||
|  | ||||
|             alert_template = instance.agent.alert_template | ||||
|             alert = cls.objects.get(assigned_task=instance, resolved=False) | ||||
|             maintenance_mode = instance.agent.maintenance_mode | ||||
|             agent = instance.agent | ||||
|  | ||||
|             if alert_template: | ||||
|                 email_on_resolved = alert_template.task_email_on_resolved | ||||
|                 text_on_resolved = alert_template.task_text_on_resolved | ||||
|  | ||||
|         else: | ||||
|             return | ||||
|  | ||||
|         # return if agent is in maintenance mode | ||||
|         if maintenance_mode: | ||||
|             return | ||||
|  | ||||
|         alert.resolve() | ||||
|  | ||||
|         # check if a resolved email notification should be send | ||||
|         if email_on_resolved and not alert.resolved_email_sent: | ||||
|             resolved_email_task.delay(pk=alert.pk) | ||||
|  | ||||
|         # check if resolved text should be sent | ||||
|         if text_on_resolved and not alert.resolved_sms_sent: | ||||
|             resolved_text_task.delay(pk=alert.pk) | ||||
|  | ||||
|         # check if resolved script should be run | ||||
|         if ( | ||||
|             alert_template | ||||
|             and alert_template.resolved_action | ||||
|             and not alert.resolved_action_run | ||||
|         ): | ||||
|             r = agent.run_script( | ||||
|                 scriptpk=alert_template.resolved_action.pk, | ||||
|                 args=alert_template.resolved_action_args, | ||||
|                 timeout=alert_template.resolved_action_timeout, | ||||
|                 wait=True, | ||||
|                 full=True, | ||||
|                 run_on_any=True, | ||||
|             ) | ||||
|  | ||||
|             # command was successful | ||||
|             if type(r) == dict: | ||||
|                 alert.resolved_action_retcode = r["retcode"] | ||||
|                 alert.resolved_action_stdout = r["stdout"] | ||||
|                 alert.resolved_action_stderr = r["stderr"] | ||||
|                 alert.resolved_action_execution_time = "{:.4f}".format( | ||||
|                     r["execution_time"] | ||||
|                 ) | ||||
|                 alert.resolved_action_run = djangotime.now() | ||||
|                 alert.save() | ||||
|             else: | ||||
|                 logger.error( | ||||
|                     f"Resolved action: {alert_template.action.name} failed to run on any agent for {agent.hostname} resolved alert" | ||||
|                 ) | ||||
|  | ||||
|  | ||||
| class AlertTemplate(models.Model): | ||||
|     name = models.CharField(max_length=100) | ||||
|     is_active = models.BooleanField(default=True) | ||||
|  | ||||
|     action = models.ForeignKey( | ||||
|         "scripts.Script", | ||||
|         related_name="alert_template", | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     action_args = ArrayField( | ||||
|         models.CharField(max_length=255, null=True, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     action_timeout = models.PositiveIntegerField(default=15) | ||||
|     resolved_action = models.ForeignKey( | ||||
|         "scripts.Script", | ||||
|         related_name="resolved_alert_template", | ||||
|         blank=True, | ||||
|         null=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     resolved_action_args = ArrayField( | ||||
|         models.CharField(max_length=255, null=True, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     resolved_action_timeout = models.PositiveIntegerField(default=15) | ||||
|  | ||||
|     # overrides the global recipients | ||||
|     email_recipients = ArrayField( | ||||
|         models.CharField(max_length=100, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     text_recipients = ArrayField( | ||||
|         models.CharField(max_length=100, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|  | ||||
|     # overrides the from address | ||||
|     email_from = models.EmailField(blank=True, null=True) | ||||
|  | ||||
|     # agent alert settings | ||||
|     agent_email_on_resolved = BooleanField(null=True, blank=True, default=False) | ||||
|     agent_text_on_resolved = BooleanField(null=True, blank=True, default=False) | ||||
|     agent_always_email = BooleanField(null=True, blank=True, default=None) | ||||
|     agent_always_text = BooleanField(null=True, blank=True, default=None) | ||||
|     agent_always_alert = BooleanField(null=True, blank=True, default=None) | ||||
|     agent_periodic_alert_days = PositiveIntegerField(blank=True, null=True, default=0) | ||||
|  | ||||
|     # check alert settings | ||||
|     check_email_alert_severity = ArrayField( | ||||
|         models.CharField(max_length=25, blank=True, choices=SEVERITY_CHOICES), | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     check_text_alert_severity = ArrayField( | ||||
|         models.CharField(max_length=25, blank=True, choices=SEVERITY_CHOICES), | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     check_dashboard_alert_severity = ArrayField( | ||||
|         models.CharField(max_length=25, blank=True, choices=SEVERITY_CHOICES), | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     check_email_on_resolved = BooleanField(null=True, blank=True, default=False) | ||||
|     check_text_on_resolved = BooleanField(null=True, blank=True, default=False) | ||||
|     check_always_email = BooleanField(null=True, blank=True, default=None) | ||||
|     check_always_text = BooleanField(null=True, blank=True, default=None) | ||||
|     check_always_alert = BooleanField(null=True, blank=True, default=None) | ||||
|     check_periodic_alert_days = PositiveIntegerField(blank=True, null=True, default=0) | ||||
|  | ||||
|     # task alert settings | ||||
|     task_email_alert_severity = ArrayField( | ||||
|         models.CharField(max_length=25, blank=True, choices=SEVERITY_CHOICES), | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     task_text_alert_severity = ArrayField( | ||||
|         models.CharField(max_length=25, blank=True, choices=SEVERITY_CHOICES), | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     task_dashboard_alert_severity = ArrayField( | ||||
|         models.CharField(max_length=25, blank=True, choices=SEVERITY_CHOICES), | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     task_email_on_resolved = BooleanField(null=True, blank=True, default=False) | ||||
|     task_text_on_resolved = BooleanField(null=True, blank=True, default=False) | ||||
|     task_always_email = BooleanField(null=True, blank=True, default=None) | ||||
|     task_always_text = BooleanField(null=True, blank=True, default=None) | ||||
|     task_always_alert = BooleanField(null=True, blank=True, default=None) | ||||
|     task_periodic_alert_days = PositiveIntegerField(blank=True, null=True, default=0) | ||||
|  | ||||
|     # exclusion settings | ||||
|     exclude_workstations = BooleanField(null=True, blank=True, default=False) | ||||
|     exclude_servers = BooleanField(null=True, blank=True, default=False) | ||||
|  | ||||
|     excluded_sites = models.ManyToManyField( | ||||
|         "clients.Site", related_name="alert_exclusions", blank=True | ||||
|     ) | ||||
|     excluded_clients = models.ManyToManyField( | ||||
|         "clients.Client", related_name="alert_exclusions", blank=True | ||||
|     ) | ||||
|     excluded_agents = models.ManyToManyField( | ||||
|         "agents.Agent", related_name="alert_exclusions", blank=True | ||||
|     ) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|     @property | ||||
|     def has_agent_settings(self) -> bool: | ||||
|         return ( | ||||
|             self.agent_email_on_resolved | ||||
|             or self.agent_text_on_resolved | ||||
|             or self.agent_always_email | ||||
|             or self.agent_always_text | ||||
|             or self.agent_always_alert | ||||
|             or bool(self.agent_periodic_alert_days) | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def has_check_settings(self) -> bool: | ||||
|         return ( | ||||
|             bool(self.check_email_alert_severity) | ||||
|             or bool(self.check_text_alert_severity) | ||||
|             or bool(self.check_dashboard_alert_severity) | ||||
|             or self.check_email_on_resolved | ||||
|             or self.check_text_on_resolved | ||||
|             or self.check_always_email | ||||
|             or self.check_always_text | ||||
|             or self.check_always_alert | ||||
|             or bool(self.check_periodic_alert_days) | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def has_task_settings(self) -> bool: | ||||
|         return ( | ||||
|             bool(self.task_email_alert_severity) | ||||
|             or bool(self.task_text_alert_severity) | ||||
|             or bool(self.task_dashboard_alert_severity) | ||||
|             or self.task_email_on_resolved | ||||
|             or self.task_text_on_resolved | ||||
|             or self.task_always_email | ||||
|             or self.task_always_text | ||||
|             or self.task_always_alert | ||||
|             or bool(self.task_periodic_alert_days) | ||||
|         ) | ||||
|  | ||||
|     @property | ||||
|     def has_core_settings(self) -> bool: | ||||
|         return bool(self.email_from) or self.email_recipients or self.text_recipients | ||||
|  | ||||
|     @property | ||||
|     def is_default_template(self) -> bool: | ||||
|         return self.default_alert_template.exists()  # type: ignore | ||||
|   | ||||
| @@ -1,19 +1,121 @@ | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     ReadOnlyField, | ||||
|     DateTimeField, | ||||
| ) | ||||
| from rest_framework.fields import SerializerMethodField | ||||
| from rest_framework.serializers import ModelSerializer, ReadOnlyField | ||||
|  | ||||
| from .models import Alert | ||||
| from automation.serializers import PolicySerializer | ||||
| from clients.serializers import ClientSerializer, SiteSerializer | ||||
| from tacticalrmm.utils import get_default_timezone | ||||
|  | ||||
| from .models import Alert, AlertTemplate | ||||
|  | ||||
|  | ||||
| class AlertSerializer(ModelSerializer): | ||||
|  | ||||
|     hostname = ReadOnlyField(source="agent.hostname") | ||||
|     client = ReadOnlyField(source="agent.client") | ||||
|     site = ReadOnlyField(source="agent.site") | ||||
|     alert_time = DateTimeField(format="iso-8601") | ||||
|     hostname = SerializerMethodField(read_only=True) | ||||
|     client = SerializerMethodField(read_only=True) | ||||
|     site = SerializerMethodField(read_only=True) | ||||
|     alert_time = SerializerMethodField(read_only=True) | ||||
|     resolve_on = SerializerMethodField(read_only=True) | ||||
|     snoozed_until = SerializerMethodField(read_only=True) | ||||
|  | ||||
|     def get_hostname(self, instance): | ||||
|         if instance.alert_type == "availability": | ||||
|             return instance.agent.hostname if instance.agent else "" | ||||
|         elif instance.alert_type == "check": | ||||
|             return ( | ||||
|                 instance.assigned_check.agent.hostname | ||||
|                 if instance.assigned_check | ||||
|                 else "" | ||||
|             ) | ||||
|         elif instance.alert_type == "task": | ||||
|             return ( | ||||
|                 instance.assigned_task.agent.hostname if instance.assigned_task else "" | ||||
|             ) | ||||
|         else: | ||||
|             return "" | ||||
|  | ||||
|     def get_client(self, instance): | ||||
|         if instance.alert_type == "availability": | ||||
|             return instance.agent.client.name if instance.agent else "" | ||||
|         elif instance.alert_type == "check": | ||||
|             return ( | ||||
|                 instance.assigned_check.agent.client.name | ||||
|                 if instance.assigned_check | ||||
|                 else "" | ||||
|             ) | ||||
|         elif instance.alert_type == "task": | ||||
|             return ( | ||||
|                 instance.assigned_task.agent.client.name | ||||
|                 if instance.assigned_task | ||||
|                 else "" | ||||
|             ) | ||||
|         else: | ||||
|             return "" | ||||
|  | ||||
|     def get_site(self, instance): | ||||
|         if instance.alert_type == "availability": | ||||
|             return instance.agent.site.name if instance.agent else "" | ||||
|         elif instance.alert_type == "check": | ||||
|             return ( | ||||
|                 instance.assigned_check.agent.site.name | ||||
|                 if instance.assigned_check | ||||
|                 else "" | ||||
|             ) | ||||
|         elif instance.alert_type == "task": | ||||
|             return ( | ||||
|                 instance.assigned_task.agent.site.name if instance.assigned_task else "" | ||||
|             ) | ||||
|         else: | ||||
|             return "" | ||||
|  | ||||
|     def get_alert_time(self, instance): | ||||
|         if instance.alert_time: | ||||
|             return instance.alert_time.astimezone(get_default_timezone()).timestamp() | ||||
|         else: | ||||
|             return None | ||||
|  | ||||
|     def get_resolve_on(self, instance): | ||||
|         if instance.resolved_on: | ||||
|             return instance.resolved_on.astimezone(get_default_timezone()).timestamp() | ||||
|         else: | ||||
|             return None | ||||
|  | ||||
|     def get_snoozed_until(self, instance): | ||||
|         if instance.snooze_until: | ||||
|             return instance.snooze_until.astimezone(get_default_timezone()).timestamp() | ||||
|         return None | ||||
|  | ||||
|     class Meta: | ||||
|         model = Alert | ||||
|         fields = "__all__" | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class AlertTemplateSerializer(ModelSerializer): | ||||
|     agent_settings = ReadOnlyField(source="has_agent_settings") | ||||
|     check_settings = ReadOnlyField(source="has_check_settings") | ||||
|     task_settings = ReadOnlyField(source="has_task_settings") | ||||
|     core_settings = ReadOnlyField(source="has_core_settings") | ||||
|     default_template = ReadOnlyField(source="is_default_template") | ||||
|     action_name = ReadOnlyField(source="action.name") | ||||
|     resolved_action_name = ReadOnlyField(source="resolved_action.name") | ||||
|     applied_count = SerializerMethodField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = AlertTemplate | ||||
|         fields = "__all__" | ||||
|  | ||||
|     def get_applied_count(self, instance): | ||||
|         count = 0 | ||||
|         count += instance.policies.count() | ||||
|         count += instance.clients.count() | ||||
|         count += instance.sites.count() | ||||
|         return count | ||||
|  | ||||
|  | ||||
| class AlertTemplateRelationSerializer(ModelSerializer): | ||||
|     policies = PolicySerializer(read_only=True, many=True) | ||||
|     clients = ClientSerializer(read_only=True, many=True) | ||||
|     sites = SiteSerializer(read_only=True, many=True) | ||||
|  | ||||
|     class Meta: | ||||
|         model = AlertTemplate | ||||
|         fields = "__all__" | ||||
|   | ||||
							
								
								
									
										24
									
								
								api/tacticalrmm/alerts/tasks.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								api/tacticalrmm/alerts/tasks.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from alerts.models import Alert | ||||
| from tacticalrmm.celery import app | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def unsnooze_alerts() -> str: | ||||
|  | ||||
|     Alert.objects.filter(snoozed=True, snooze_until__lte=djangotime.now()).update( | ||||
|         snoozed=False, snooze_until=None | ||||
|     ) | ||||
|  | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def cache_agents_alert_template(): | ||||
|     from agents.models import Agent | ||||
|  | ||||
|     for agent in Agent.objects.only("pk"): | ||||
|         agent.set_alert_template() | ||||
|  | ||||
|     return "ok" | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -1,7 +1,12 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from . import views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("alerts/", views.GetAddAlerts.as_view()), | ||||
|     path("bulk/", views.BulkAlerts.as_view()), | ||||
|     path("alerts/<int:pk>/", views.GetUpdateDeleteAlert.as_view()), | ||||
|     path("alerttemplates/", views.GetAddAlertTemplates.as_view()), | ||||
|     path("alerttemplates/<int:pk>/", views.GetUpdateDeleteAlertTemplate.as_view()), | ||||
|     path("alerttemplates/<int:pk>/related/", views.RelatedAlertTemplate.as_view()), | ||||
| ] | ||||
|   | ||||
| @@ -1,19 +1,104 @@ | ||||
| from datetime import datetime as dt | ||||
|  | ||||
| from django.db.models import Q | ||||
| from django.shortcuts import get_object_or_404 | ||||
|  | ||||
| from rest_framework.views import APIView | ||||
| from django.utils import timezone as djangotime | ||||
| from rest_framework.response import Response | ||||
| from rest_framework import status | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from .models import Alert | ||||
| from tacticalrmm.utils import notify_error | ||||
|  | ||||
| from .serializers import AlertSerializer | ||||
| from .models import Alert, AlertTemplate | ||||
| from .serializers import ( | ||||
|     AlertSerializer, | ||||
|     AlertTemplateRelationSerializer, | ||||
|     AlertTemplateSerializer, | ||||
| ) | ||||
| from .tasks import cache_agents_alert_template | ||||
|  | ||||
|  | ||||
| class GetAddAlerts(APIView): | ||||
|     def get(self, request): | ||||
|         alerts = Alert.objects.all() | ||||
|     def patch(self, request): | ||||
|  | ||||
|         return Response(AlertSerializer(alerts, many=True).data) | ||||
|         # top 10 alerts for dashboard icon | ||||
|         if "top" in request.data.keys(): | ||||
|             alerts = Alert.objects.filter( | ||||
|                 resolved=False, snoozed=False, hidden=False | ||||
|             ).order_by("alert_time")[: int(request.data["top"])] | ||||
|             count = Alert.objects.filter( | ||||
|                 resolved=False, snoozed=False, hidden=False | ||||
|             ).count() | ||||
|             return Response( | ||||
|                 { | ||||
|                     "alerts_count": count, | ||||
|                     "alerts": AlertSerializer(alerts, many=True).data, | ||||
|                 } | ||||
|             ) | ||||
|  | ||||
|         elif any( | ||||
|             key | ||||
|             in [ | ||||
|                 "timeFilter", | ||||
|                 "clientFilter", | ||||
|                 "severityFilter", | ||||
|                 "resolvedFilter", | ||||
|                 "snoozedFilter", | ||||
|             ] | ||||
|             for key in request.data.keys() | ||||
|         ): | ||||
|             clientFilter = Q() | ||||
|             severityFilter = Q() | ||||
|             timeFilter = Q() | ||||
|             resolvedFilter = Q() | ||||
|             snoozedFilter = Q() | ||||
|  | ||||
|             if ( | ||||
|                 "snoozedFilter" in request.data.keys() | ||||
|                 and not request.data["snoozedFilter"] | ||||
|             ): | ||||
|                 snoozedFilter = Q(snoozed=request.data["snoozedFilter"]) | ||||
|  | ||||
|             if ( | ||||
|                 "resolvedFilter" in request.data.keys() | ||||
|                 and not request.data["resolvedFilter"] | ||||
|             ): | ||||
|                 resolvedFilter = Q(resolved=request.data["resolvedFilter"]) | ||||
|  | ||||
|             if "clientFilter" in request.data.keys(): | ||||
|                 from agents.models import Agent | ||||
|                 from clients.models import Client | ||||
|  | ||||
|                 clients = Client.objects.filter( | ||||
|                     pk__in=request.data["clientFilter"] | ||||
|                 ).values_list("id") | ||||
|                 agents = Agent.objects.filter(site__client_id__in=clients).values_list( | ||||
|                     "id" | ||||
|                 ) | ||||
|  | ||||
|                 clientFilter = Q(agent__in=agents) | ||||
|  | ||||
|             if "severityFilter" in request.data.keys(): | ||||
|                 severityFilter = Q(severity__in=request.data["severityFilter"]) | ||||
|  | ||||
|             if "timeFilter" in request.data.keys(): | ||||
|                 timeFilter = Q( | ||||
|                     alert_time__lte=djangotime.make_aware(dt.today()), | ||||
|                     alert_time__gt=djangotime.make_aware(dt.today()) | ||||
|                     - djangotime.timedelta(days=int(request.data["timeFilter"])), | ||||
|                 ) | ||||
|  | ||||
|             alerts = ( | ||||
|                 Alert.objects.filter(clientFilter) | ||||
|                 .filter(severityFilter) | ||||
|                 .filter(resolvedFilter) | ||||
|                 .filter(snoozedFilter) | ||||
|                 .filter(timeFilter) | ||||
|             ) | ||||
|             return Response(AlertSerializer(alerts, many=True).data) | ||||
|  | ||||
|         else: | ||||
|             alerts = Alert.objects.all() | ||||
|             return Response(AlertSerializer(alerts, many=True).data) | ||||
|  | ||||
|     def post(self, request): | ||||
|         serializer = AlertSerializer(data=request.data, partial=True) | ||||
| @@ -32,7 +117,40 @@ class GetUpdateDeleteAlert(APIView): | ||||
|     def put(self, request, pk): | ||||
|         alert = get_object_or_404(Alert, pk=pk) | ||||
|  | ||||
|         serializer = AlertSerializer(instance=alert, data=request.data, partial=True) | ||||
|         data = request.data | ||||
|  | ||||
|         if "type" in data.keys(): | ||||
|             if data["type"] == "resolve": | ||||
|                 data = { | ||||
|                     "resolved": True, | ||||
|                     "resolved_on": djangotime.now(), | ||||
|                     "snoozed": False, | ||||
|                 } | ||||
|  | ||||
|                 # unable to set snooze_until to none in serialzier | ||||
|                 alert.snooze_until = None | ||||
|                 alert.save() | ||||
|             elif data["type"] == "snooze": | ||||
|                 if "snooze_days" in data.keys(): | ||||
|                     data = { | ||||
|                         "snoozed": True, | ||||
|                         "snooze_until": djangotime.now() | ||||
|                         + djangotime.timedelta(days=int(data["snooze_days"])), | ||||
|                     } | ||||
|                 else: | ||||
|                     return notify_error( | ||||
|                         "Missing 'snoozed_days' when trying to snooze alert" | ||||
|                     ) | ||||
|             elif data["type"] == "unsnooze": | ||||
|                 data = {"snoozed": False} | ||||
|  | ||||
|                 # unable to set snooze_until to none in serialzier | ||||
|                 alert.snooze_until = None | ||||
|                 alert.save() | ||||
|             else: | ||||
|                 return notify_error("There was an error in the request data") | ||||
|  | ||||
|         serializer = AlertSerializer(instance=alert, data=data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|  | ||||
| @@ -42,3 +160,77 @@ class GetUpdateDeleteAlert(APIView): | ||||
|         Alert.objects.get(pk=pk).delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class BulkAlerts(APIView): | ||||
|     def post(self, request): | ||||
|         if request.data["bulk_action"] == "resolve": | ||||
|             Alert.objects.filter(id__in=request.data["alerts"]).update( | ||||
|                 resolved=True, | ||||
|                 resolved_on=djangotime.now(), | ||||
|                 snoozed=False, | ||||
|                 snooze_until=None, | ||||
|             ) | ||||
|             return Response("ok") | ||||
|         elif request.data["bulk_action"] == "snooze": | ||||
|             if "snooze_days" in request.data.keys(): | ||||
|                 Alert.objects.filter(id__in=request.data["alerts"]).update( | ||||
|                     snoozed=True, | ||||
|                     snooze_until=djangotime.now() | ||||
|                     + djangotime.timedelta(days=int(request.data["snooze_days"])), | ||||
|                 ) | ||||
|                 return Response("ok") | ||||
|  | ||||
|         return notify_error("The request was invalid") | ||||
|  | ||||
|  | ||||
| class GetAddAlertTemplates(APIView): | ||||
|     def get(self, request): | ||||
|         alert_templates = AlertTemplate.objects.all() | ||||
|  | ||||
|         return Response(AlertTemplateSerializer(alert_templates, many=True).data) | ||||
|  | ||||
|     def post(self, request): | ||||
|         serializer = AlertTemplateSerializer(data=request.data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|  | ||||
|         # cache alert_template value on agents | ||||
|         cache_agents_alert_template.delay() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class GetUpdateDeleteAlertTemplate(APIView): | ||||
|     def get(self, request, pk): | ||||
|         alert_template = get_object_or_404(AlertTemplate, pk=pk) | ||||
|  | ||||
|         return Response(AlertTemplateSerializer(alert_template).data) | ||||
|  | ||||
|     def put(self, request, pk): | ||||
|         alert_template = get_object_or_404(AlertTemplate, pk=pk) | ||||
|  | ||||
|         serializer = AlertTemplateSerializer( | ||||
|             instance=alert_template, data=request.data, partial=True | ||||
|         ) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|  | ||||
|         # cache alert_template value on agents | ||||
|         cache_agents_alert_template.delay() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         get_object_or_404(AlertTemplate, pk=pk).delete() | ||||
|  | ||||
|         # cache alert_template value on agents | ||||
|         cache_agents_alert_template.delay() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class RelatedAlertTemplate(APIView): | ||||
|     def get(self, request, pk): | ||||
|         alert_template = get_object_or_404(AlertTemplate, pk=pk) | ||||
|         return Response(AlertTemplateRelationSerializer(alert_template).data) | ||||
|   | ||||
| @@ -1,5 +0,0 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class ApiConfig(AppConfig): | ||||
|     name = "api" | ||||
| @@ -1,11 +0,0 @@ | ||||
| from django.urls import path | ||||
| from . import views | ||||
| from apiv3 import views as v3_views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("triggerpatchscan/", views.trigger_patch_scan), | ||||
|     path("<int:pk>/checkrunner/", views.CheckRunner.as_view()), | ||||
|     path("<int:pk>/taskrunner/", views.TaskRunner.as_view()), | ||||
|     path("<int:pk>/saltinfo/", views.SaltInfo.as_view()), | ||||
|     path("<int:pk>/meshinfo/", v3_views.MeshInfo.as_view()), | ||||
| ] | ||||
| @@ -1,149 +0,0 @@ | ||||
| from loguru import logger | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.authentication import TokenAuthentication | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.decorators import ( | ||||
|     api_view, | ||||
|     authentication_classes, | ||||
|     permission_classes, | ||||
| ) | ||||
|  | ||||
| from agents.models import Agent | ||||
| from checks.models import Check | ||||
| from autotasks.models import AutomatedTask | ||||
|  | ||||
| from winupdate.tasks import check_for_updates_task | ||||
|  | ||||
| from autotasks.serializers import TaskRunnerGetSerializer, TaskRunnerPatchSerializer | ||||
| from checks.serializers import CheckRunnerGetSerializer, CheckResultsSerializer | ||||
|  | ||||
|  | ||||
| logger.configure(**settings.LOG_CONFIG) | ||||
|  | ||||
|  | ||||
| @api_view(["PATCH"]) | ||||
| @authentication_classes((TokenAuthentication,)) | ||||
| @permission_classes((IsAuthenticated,)) | ||||
| def trigger_patch_scan(request): | ||||
|     agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|     reboot_policy = agent.get_patch_policy().reboot_after_install | ||||
|     reboot = False | ||||
|  | ||||
|     if reboot_policy == "always": | ||||
|         reboot = True | ||||
|  | ||||
|     if request.data["reboot"]: | ||||
|         if reboot_policy == "required": | ||||
|             reboot = True | ||||
|         elif reboot_policy == "never": | ||||
|             agent.needs_reboot = True | ||||
|             agent.save(update_fields=["needs_reboot"]) | ||||
|  | ||||
|     if reboot: | ||||
|         r = agent.salt_api_cmd( | ||||
|             timeout=15, | ||||
|             func="system.reboot", | ||||
|             arg=7, | ||||
|             kwargs={"in_seconds": True}, | ||||
|         ) | ||||
|  | ||||
|         if r == "timeout" or r == "error" or (isinstance(r, bool) and not r): | ||||
|             check_for_updates_task.apply_async( | ||||
|                 queue="wupdate", kwargs={"pk": agent.pk, "wait": False} | ||||
|             ) | ||||
|         else: | ||||
|             logger.info(f"{agent.hostname} is rebooting after updates were installed.") | ||||
|     else: | ||||
|         check_for_updates_task.apply_async( | ||||
|             queue="wupdate", kwargs={"pk": agent.pk, "wait": False} | ||||
|         ) | ||||
|  | ||||
|     return Response("ok") | ||||
|  | ||||
|  | ||||
| class CheckRunner(APIView): | ||||
|     """ | ||||
|     For windows agent | ||||
|     """ | ||||
|  | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, pk): | ||||
|         agent = get_object_or_404(Agent, pk=pk) | ||||
|         checks = Check.objects.filter(agent__pk=pk, overriden_by_policy=False) | ||||
|  | ||||
|         ret = { | ||||
|             "agent": agent.pk, | ||||
|             "check_interval": agent.check_interval, | ||||
|             "checks": CheckRunnerGetSerializer(checks, many=True).data, | ||||
|         } | ||||
|         return Response(ret) | ||||
|  | ||||
|     def patch(self, request, pk): | ||||
|         check = get_object_or_404(Check, pk=pk) | ||||
|  | ||||
|         if check.check_type != "cpuload" and check.check_type != "memory": | ||||
|             serializer = CheckResultsSerializer( | ||||
|                 instance=check, data=request.data, partial=True | ||||
|             ) | ||||
|             serializer.is_valid(raise_exception=True) | ||||
|             serializer.save(last_run=djangotime.now()) | ||||
|  | ||||
|         else: | ||||
|             check.last_run = djangotime.now() | ||||
|             check.save(update_fields=["last_run"]) | ||||
|  | ||||
|         check.handle_check(request.data) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class TaskRunner(APIView): | ||||
|     """ | ||||
|     For the windows python agent | ||||
|     """ | ||||
|  | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, pk): | ||||
|  | ||||
|         task = get_object_or_404(AutomatedTask, pk=pk) | ||||
|         return Response(TaskRunnerGetSerializer(task).data) | ||||
|  | ||||
|     def patch(self, request, pk): | ||||
|         task = get_object_or_404(AutomatedTask, pk=pk) | ||||
|  | ||||
|         serializer = TaskRunnerPatchSerializer( | ||||
|             instance=task, data=request.data, partial=True | ||||
|         ) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save(last_run=djangotime.now()) | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class SaltInfo(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, pk): | ||||
|         agent = get_object_or_404(Agent, pk=pk) | ||||
|         ret = { | ||||
|             "latestVer": settings.LATEST_SALT_VER, | ||||
|             "currentVer": agent.salt_ver, | ||||
|             "salt_id": agent.salt_id, | ||||
|         } | ||||
|         return Response(ret) | ||||
|  | ||||
|     def patch(self, request, pk): | ||||
|         agent = get_object_or_404(Agent, pk=pk) | ||||
|         agent.salt_ver = request.data["ver"] | ||||
|         agent.save(update_fields=["salt_ver"]) | ||||
|         return Response("ok") | ||||
| @@ -1,5 +0,0 @@ | ||||
| from django.apps import AppConfig | ||||
|  | ||||
|  | ||||
| class Apiv2Config(AppConfig): | ||||
|     name = 'apiv2' | ||||
| @@ -1,38 +0,0 @@ | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
| from unittest.mock import patch | ||||
| from model_bakery import baker | ||||
| from itertools import cycle | ||||
|  | ||||
|  | ||||
| class TestAPIv2(TacticalTestCase): | ||||
|     def setUp(self): | ||||
|         self.authenticate() | ||||
|         self.setup_coresettings() | ||||
|  | ||||
|     @patch("agents.models.Agent.salt_api_cmd") | ||||
|     def test_sync_modules(self, mock_ret): | ||||
|         # setup data | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         url = "/api/v2/saltminion/" | ||||
|         payload = {"agent_id": agent.agent_id} | ||||
|  | ||||
|         mock_ret.return_value = "error" | ||||
|         r = self.client.patch(url, payload, format="json") | ||||
|         self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|         mock_ret.return_value = [] | ||||
|         r = self.client.patch(url, payload, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data, "Modules are already in sync") | ||||
|  | ||||
|         mock_ret.return_value = ["modules.win_agent"] | ||||
|         r = self.client.patch(url, payload, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data, "Successfully synced salt modules") | ||||
|  | ||||
|         mock_ret.return_value = ["askdjaskdjasd", "modules.win_agent"] | ||||
|         r = self.client.patch(url, payload, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data, "Successfully synced salt modules") | ||||
|  | ||||
|         self.check_not_authenticated("patch", url) | ||||
| @@ -1,14 +0,0 @@ | ||||
| from django.urls import path | ||||
| from . import views | ||||
| from apiv3 import views as v3_views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("newagent/", v3_views.NewAgent.as_view()), | ||||
|     path("meshexe/", v3_views.MeshExe.as_view()), | ||||
|     path("saltminion/", v3_views.SaltMinion.as_view()), | ||||
|     path("<str:agentid>/saltminion/", v3_views.SaltMinion.as_view()), | ||||
|     path("sysinfo/", v3_views.SysInfo.as_view()), | ||||
|     path("hello/", v3_views.Hello.as_view()), | ||||
|     path("checkrunner/", views.CheckRunner.as_view()), | ||||
|     path("<str:agentid>/checkrunner/", views.CheckRunner.as_view()), | ||||
| ] | ||||
| @@ -1,41 +0,0 @@ | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from rest_framework.authentication import TokenAuthentication | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from agents.models import Agent | ||||
| from checks.models import Check | ||||
|  | ||||
| from checks.serializers import CheckRunnerGetSerializerV2 | ||||
|  | ||||
|  | ||||
| class CheckRunner(APIView): | ||||
|     """ | ||||
|     For the windows python agent | ||||
|     """ | ||||
|  | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|         agent.last_seen = djangotime.now() | ||||
|         agent.save(update_fields=["last_seen"]) | ||||
|         checks = Check.objects.filter(agent__pk=agent.pk, overriden_by_policy=False) | ||||
|  | ||||
|         ret = { | ||||
|             "agent": agent.pk, | ||||
|             "check_interval": agent.check_interval, | ||||
|             "checks": CheckRunnerGetSerializerV2(checks, many=True).data, | ||||
|         } | ||||
|         return Response(ret) | ||||
|  | ||||
|     def patch(self, request): | ||||
|         check = get_object_or_404(Check, pk=request.data["id"]) | ||||
|         check.last_run = djangotime.now() | ||||
|         check.save(update_fields=["last_run"]) | ||||
|         status = check.handle_checkv2(request.data) | ||||
|         return Response(status) | ||||
| @@ -1,11 +1,12 @@ | ||||
| import os | ||||
| import json | ||||
| import os | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from django.conf import settings | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
| from unittest.mock import patch | ||||
| from django.utils import timezone as djangotime | ||||
| from model_bakery import baker | ||||
| from itertools import cycle | ||||
|  | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
|  | ||||
| class TestAPIv3(TacticalTestCase): | ||||
| @@ -17,8 +18,44 @@ class TestAPIv3(TacticalTestCase): | ||||
|     def test_get_checks(self): | ||||
|         url = f"/api/v3/{self.agent.agent_id}/checkrunner/" | ||||
|  | ||||
|         # add a check | ||||
|         check1 = baker.make_recipe("checks.ping_check", agent=self.agent) | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data["check_interval"], self.agent.check_interval)  # type: ignore | ||||
|         self.assertEqual(len(r.data["checks"]), 1)  # type: ignore | ||||
|  | ||||
|         # override check run interval | ||||
|         check2 = baker.make_recipe( | ||||
|             "checks.ping_check", agent=self.agent, run_interval=20 | ||||
|         ) | ||||
|  | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data["check_interval"], 20)  # type: ignore | ||||
|         self.assertEqual(len(r.data["checks"]), 2)  # type: ignore | ||||
|  | ||||
|         # Set last_run on both checks and should return an empty list | ||||
|         check1.last_run = djangotime.now() | ||||
|         check1.save() | ||||
|         check2.last_run = djangotime.now() | ||||
|         check2.save() | ||||
|  | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data["check_interval"], 20)  # type: ignore | ||||
|         self.assertFalse(r.data["checks"])  # type: ignore | ||||
|  | ||||
|         # set last_run greater than interval | ||||
|         check1.last_run = djangotime.now() - djangotime.timedelta(seconds=200) | ||||
|         check1.save() | ||||
|         check2.last_run = djangotime.now() - djangotime.timedelta(seconds=200) | ||||
|         check2.save() | ||||
|  | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data["check_interval"], 20)  # type: ignore | ||||
|         self.assertEquals(len(r.data["checks"]), 2)  # type: ignore | ||||
|  | ||||
|         url = "/api/v3/Maj34ACb324j234asdj2n34kASDjh34-DESKTOPTEST123/checkrunner/" | ||||
|         r = self.client.get(url) | ||||
| @@ -26,46 +63,10 @@ class TestAPIv3(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("get", url) | ||||
|  | ||||
|     def test_get_salt_minion(self): | ||||
|         url = f"/api/v3/{self.agent.agent_id}/saltminion/" | ||||
|         url2 = f"/api/v2/{self.agent.agent_id}/saltminion/" | ||||
|  | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertIn("latestVer", r.json().keys()) | ||||
|         self.assertIn("currentVer", r.json().keys()) | ||||
|         self.assertIn("salt_id", r.json().keys()) | ||||
|         self.assertIn("downloadURL", r.json().keys()) | ||||
|  | ||||
|         r2 = self.client.get(url2) | ||||
|         self.assertEqual(r2.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("get", url) | ||||
|         self.check_not_authenticated("get", url2) | ||||
|  | ||||
|     def test_get_mesh_info(self): | ||||
|         url = f"/api/v3/{self.agent.pk}/meshinfo/" | ||||
|         url2 = f"/api/v1/{self.agent.pk}/meshinfo/" | ||||
|  | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         r = self.client.get(url2) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("get", url) | ||||
|         self.check_not_authenticated("get", url2) | ||||
|  | ||||
|     def test_get_winupdater(self): | ||||
|         url = f"/api/v3/{self.agent.agent_id}/winupdater/" | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("get", url) | ||||
|  | ||||
|     def test_sysinfo(self): | ||||
|         # TODO replace this with golang wmi sample data | ||||
|  | ||||
|         url = f"/api/v3/sysinfo/" | ||||
|         url = "/api/v3/sysinfo/" | ||||
|         with open( | ||||
|             os.path.join( | ||||
|                 settings.BASE_DIR, "tacticalrmm/test_data/wmi_python_agent.json" | ||||
| @@ -80,19 +81,125 @@ class TestAPIv3(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("patch", url) | ||||
|  | ||||
|     def test_hello_patch(self): | ||||
|         url = f"/api/v3/hello/" | ||||
|     def test_checkrunner_interval(self): | ||||
|         url = f"/api/v3/{self.agent.agent_id}/checkinterval/" | ||||
|         r = self.client.get(url, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual( | ||||
|             r.json(), | ||||
|             {"agent": self.agent.pk, "check_interval": self.agent.check_interval}, | ||||
|         ) | ||||
|  | ||||
|         # add check to agent with check interval set | ||||
|         check = baker.make_recipe( | ||||
|             "checks.ping_check", agent=self.agent, run_interval=30 | ||||
|         ) | ||||
|  | ||||
|         r = self.client.get(url, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual( | ||||
|             r.json(), | ||||
|             {"agent": self.agent.pk, "check_interval": 30}, | ||||
|         ) | ||||
|  | ||||
|         # minimum check run interval is 15 seconds | ||||
|         check = baker.make_recipe("checks.ping_check", agent=self.agent, run_interval=5) | ||||
|  | ||||
|         r = self.client.get(url, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual( | ||||
|             r.json(), | ||||
|             {"agent": self.agent.pk, "check_interval": 15}, | ||||
|         ) | ||||
|  | ||||
|     def test_run_checks(self): | ||||
|         # force run all checks regardless of interval | ||||
|         agent = baker.make_recipe("agents.online_agent") | ||||
|         baker.make_recipe("checks.ping_check", agent=agent) | ||||
|         baker.make_recipe("checks.diskspace_check", agent=agent) | ||||
|         baker.make_recipe("checks.cpuload_check", agent=agent) | ||||
|         baker.make_recipe("checks.memory_check", agent=agent) | ||||
|         baker.make_recipe("checks.eventlog_check", agent=agent) | ||||
|         for _ in range(10): | ||||
|             baker.make_recipe("checks.script_check", agent=agent) | ||||
|  | ||||
|         url = f"/api/v3/{agent.agent_id}/runchecks/" | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.json()["agent"], agent.pk) | ||||
|         self.assertIsInstance(r.json()["check_interval"], int) | ||||
|         self.assertEqual(len(r.json()["checks"]), 15) | ||||
|  | ||||
|     def test_checkin_patch(self): | ||||
|         from logs.models import PendingAction | ||||
|  | ||||
|         url = "/api/v3/checkin/" | ||||
|         agent_updated = baker.make_recipe("agents.agent", version="1.3.0") | ||||
|         PendingAction.objects.create( | ||||
|             agent=agent_updated, | ||||
|             action_type="agentupdate", | ||||
|             details={ | ||||
|                 "url": agent_updated.winagent_dl, | ||||
|                 "version": agent_updated.version, | ||||
|                 "inno": agent_updated.win_inno_exe, | ||||
|             }, | ||||
|         ) | ||||
|         action = agent_updated.pendingactions.filter(action_type="agentupdate").first() | ||||
|         self.assertEqual(action.status, "pending") | ||||
|  | ||||
|         # test agent failed to update and still on same version | ||||
|         payload = { | ||||
|             "agent_id": self.agent.agent_id, | ||||
|             "logged_in_username": "None", | ||||
|             "disks": [], | ||||
|             "func": "hello", | ||||
|             "agent_id": agent_updated.agent_id, | ||||
|             "version": "1.3.0", | ||||
|         } | ||||
|  | ||||
|         r = self.client.patch(url, payload, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         action = agent_updated.pendingactions.filter(action_type="agentupdate").first() | ||||
|         self.assertEqual(action.status, "pending") | ||||
|  | ||||
|         payload["logged_in_username"] = "Bob" | ||||
|         # test agent successful update | ||||
|         payload["version"] = settings.LATEST_AGENT_VER | ||||
|         r = self.client.patch(url, payload, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         action = agent_updated.pendingactions.filter(action_type="agentupdate").first() | ||||
|         self.assertEqual(action.status, "completed") | ||||
|         action.delete() | ||||
|  | ||||
|         self.check_not_authenticated("patch", url) | ||||
|     @patch("apiv3.views.reload_nats") | ||||
|     def test_agent_recovery(self, reload_nats): | ||||
|         reload_nats.return_value = "ok" | ||||
|         r = self.client.get("/api/v3/34jahsdkjasncASDjhg2b3j4r/recover/") | ||||
|         self.assertEqual(r.status_code, 404) | ||||
|  | ||||
|         agent = baker.make_recipe("agents.online_agent") | ||||
|         url = f"/api/v3/{agent.agent_id}/recovery/" | ||||
|  | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.json(), {"mode": "pass", "shellcmd": ""}) | ||||
|         reload_nats.assert_not_called() | ||||
|  | ||||
|         baker.make("agents.RecoveryAction", agent=agent, mode="mesh") | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.json(), {"mode": "mesh", "shellcmd": ""}) | ||||
|         reload_nats.assert_not_called() | ||||
|  | ||||
|         baker.make( | ||||
|             "agents.RecoveryAction", | ||||
|             agent=agent, | ||||
|             mode="command", | ||||
|             command="shutdown /r /t 5 /f", | ||||
|         ) | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual( | ||||
|             r.json(), {"mode": "command", "shellcmd": "shutdown /r /t 5 /f"} | ||||
|         ) | ||||
|         reload_nats.assert_not_called() | ||||
|  | ||||
|         baker.make("agents.RecoveryAction", agent=agent, mode="rpc") | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.json(), {"mode": "rpc", "shellcmd": ""}) | ||||
|         reload_nats.assert_called_once() | ||||
|   | ||||
| @@ -1,17 +1,23 @@ | ||||
| from django.urls import path | ||||
|  | ||||
| from . import views | ||||
|  | ||||
| urlpatterns = [ | ||||
|     path("hello/", views.Hello.as_view()), | ||||
|     path("checkrunner/", views.CheckRunner.as_view()), | ||||
|     path("<str:agentid>/checkrunner/", views.CheckRunner.as_view()), | ||||
|     path("<str:agentid>/runchecks/", views.RunChecks.as_view()), | ||||
|     path("<str:agentid>/checkinterval/", views.CheckRunnerInterval.as_view()), | ||||
|     path("<int:pk>/<str:agentid>/taskrunner/", views.TaskRunner.as_view()), | ||||
|     path("saltminion/", views.SaltMinion.as_view()), | ||||
|     path("<str:agentid>/saltminion/", views.SaltMinion.as_view()), | ||||
|     path("<int:pk>/meshinfo/", views.MeshInfo.as_view()), | ||||
|     path("meshexe/", views.MeshExe.as_view()), | ||||
|     path("sysinfo/", views.SysInfo.as_view()), | ||||
|     path("newagent/", views.NewAgent.as_view()), | ||||
|     path("winupdater/", views.WinUpdater.as_view()), | ||||
|     path("<str:agentid>/winupdater/", views.WinUpdater.as_view()), | ||||
|     path("software/", views.Software.as_view()), | ||||
|     path("installer/", views.Installer.as_view()), | ||||
|     path("checkin/", views.CheckIn.as_view()), | ||||
|     path("syncmesh/", views.SyncMeshNodeID.as_view()), | ||||
|     path("choco/", views.Choco.as_view()), | ||||
|     path("winupdates/", views.WinUpdates.as_view()), | ||||
|     path("superseded/", views.SupersededWinUpdate.as_view()), | ||||
|     path("<int:pk>/chocoresult/", views.ChocoResult.as_view()), | ||||
|     path("<str:agentid>/recovery/", views.AgentRecovery.as_view()), | ||||
| ] | ||||
|   | ||||
| @@ -1,70 +1,86 @@ | ||||
| import asyncio | ||||
| import os | ||||
| import requests | ||||
| from loguru import logger | ||||
| import time | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.http import HttpResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
| from django.http import HttpResponse | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from loguru import logger | ||||
| from packaging import version as pyver | ||||
| from rest_framework.authentication import TokenAuthentication | ||||
| from rest_framework.authtoken.models import Token | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
| from rest_framework.authentication import TokenAuthentication | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.authtoken.models import Token | ||||
|  | ||||
| from agents.models import Agent | ||||
| from checks.models import Check | ||||
| from autotasks.models import AutomatedTask | ||||
| from accounts.models import User | ||||
| from winupdate.models import WinUpdatePolicy | ||||
| from checks.serializers import CheckRunnerGetSerializerV3 | ||||
| from agents.models import Agent | ||||
| from agents.serializers import WinAgentSerializer | ||||
| from autotasks.models import AutomatedTask | ||||
| from autotasks.serializers import TaskGOGetSerializer, TaskRunnerPatchSerializer | ||||
| from winupdate.serializers import ApprovedUpdateSerializer | ||||
|  | ||||
| from agents.tasks import ( | ||||
|     agent_recovery_email_task, | ||||
|     agent_recovery_sms_task, | ||||
|     get_wmi_detail_task, | ||||
|     sync_salt_modules_task, | ||||
| ) | ||||
| from winupdate.tasks import check_for_updates_task | ||||
| from software.tasks import get_installed_software, install_chocolatey | ||||
| from checks.models import Check | ||||
| from checks.serializers import CheckRunnerGetSerializer | ||||
| from checks.utils import bytes2human | ||||
| from tacticalrmm.utils import notify_error, reload_nats | ||||
| from logs.models import PendingAction | ||||
| from software.models import InstalledSoftware | ||||
| from tacticalrmm.utils import SoftwareList, filter_software, notify_error, reload_nats | ||||
| from winupdate.models import WinUpdate, WinUpdatePolicy | ||||
|  | ||||
| logger.configure(**settings.LOG_CONFIG) | ||||
|  | ||||
|  | ||||
| class Hello(APIView): | ||||
|     """ | ||||
|     The agent's checkin endpoint | ||||
|     patch: called every 30 to 120 seconds | ||||
|     post: called on agent windows service startup | ||||
|     """ | ||||
| class CheckIn(APIView): | ||||
|  | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def patch(self, request): | ||||
|         from alerts.models import Alert | ||||
|  | ||||
|         updated = False | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         if pyver.parse(request.data["version"]) > pyver.parse( | ||||
|             agent.version | ||||
|         ) or pyver.parse(request.data["version"]) == pyver.parse( | ||||
|             settings.LATEST_AGENT_VER | ||||
|         ): | ||||
|             updated = True | ||||
|         agent.version = request.data["version"] | ||||
|         agent.last_seen = djangotime.now() | ||||
|         agent.save(update_fields=["version", "last_seen"]) | ||||
|  | ||||
|         # change agent update pending status to completed if agent has just updated | ||||
|         if ( | ||||
|             updated | ||||
|             and agent.pendingactions.filter(  # type: ignore | ||||
|                 action_type="agentupdate", status="pending" | ||||
|             ).exists() | ||||
|         ): | ||||
|             agent.pendingactions.filter(  # type: ignore | ||||
|                 action_type="agentupdate", status="pending" | ||||
|             ).update(status="completed") | ||||
|  | ||||
|         # handles any alerting actions | ||||
|         if Alert.objects.filter(agent=agent, resolved=False).exists(): | ||||
|             Alert.handle_alert_resolve(agent) | ||||
|  | ||||
|         # get any pending actions | ||||
|         if agent.pendingactions.filter(status="pending").exists():  # type: ignore | ||||
|             agent.handle_pending_actions() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|     def put(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         serializer = WinAgentSerializer(instance=agent, data=request.data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|  | ||||
|         disks = request.data["disks"] | ||||
|         new = [] | ||||
|         # python agent | ||||
|         if isinstance(disks, dict): | ||||
|             for k, v in disks.items(): | ||||
|                 new.append(v) | ||||
|         else: | ||||
|             # golang agent | ||||
|         if request.data["func"] == "disks": | ||||
|             disks = request.data["disks"] | ||||
|             new = [] | ||||
|             for disk in disks: | ||||
|                 tmp = {} | ||||
|                 for k, v in disk.items(): | ||||
|                 for _, _ in disk.items(): | ||||
|                     tmp["device"] = disk["device"] | ||||
|                     tmp["fstype"] = disk["fstype"] | ||||
|                     tmp["total"] = bytes2human(disk["total"]) | ||||
| @@ -73,113 +89,249 @@ class Hello(APIView): | ||||
|                     tmp["percent"] = int(disk["percent"]) | ||||
|                 new.append(tmp) | ||||
|  | ||||
|         if request.data["logged_in_username"] == "None": | ||||
|             serializer.save(last_seen=djangotime.now(), disks=new) | ||||
|         else: | ||||
|             serializer.save( | ||||
|                 last_seen=djangotime.now(), | ||||
|                 disks=new, | ||||
|                 last_logged_in_user=request.data["logged_in_username"], | ||||
|             serializer.is_valid(raise_exception=True) | ||||
|             serializer.save(disks=new) | ||||
|             return Response("ok") | ||||
|  | ||||
|         if request.data["func"] == "loggedonuser": | ||||
|             if request.data["logged_in_username"] != "None": | ||||
|                 serializer.is_valid(raise_exception=True) | ||||
|                 serializer.save(last_logged_in_user=request.data["logged_in_username"]) | ||||
|                 return Response("ok") | ||||
|  | ||||
|         if request.data["func"] == "software": | ||||
|             raw: SoftwareList = request.data["software"] | ||||
|             if not isinstance(raw, list): | ||||
|                 return notify_error("err") | ||||
|  | ||||
|             sw = filter_software(raw) | ||||
|             if not InstalledSoftware.objects.filter(agent=agent).exists(): | ||||
|                 InstalledSoftware(agent=agent, software=sw).save() | ||||
|             else: | ||||
|                 s = agent.installedsoftware_set.first()  # type: ignore | ||||
|                 s.software = sw | ||||
|                 s.save(update_fields=["software"]) | ||||
|  | ||||
|             return Response("ok") | ||||
|  | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|         return Response("ok") | ||||
|  | ||||
|     # called once during tacticalagent windows service startup | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         if not agent.choco_installed: | ||||
|             asyncio.run(agent.nats_cmd({"func": "installchoco"}, wait=False)) | ||||
|  | ||||
|         time.sleep(0.5) | ||||
|         asyncio.run(agent.nats_cmd({"func": "getwinupdates"}, wait=False)) | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class SyncMeshNodeID(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         if agent.mesh_node_id != request.data["nodeid"]: | ||||
|             agent.mesh_node_id = request.data["nodeid"] | ||||
|             agent.save(update_fields=["mesh_node_id"]) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class Choco(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         agent.choco_installed = request.data["installed"] | ||||
|         agent.save(update_fields=["choco_installed"]) | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class WinUpdates(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def put(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         reboot_policy: str = agent.get_patch_policy().reboot_after_install | ||||
|         reboot = False | ||||
|  | ||||
|         if reboot_policy == "always": | ||||
|             reboot = True | ||||
|  | ||||
|         if request.data["needs_reboot"]: | ||||
|             if reboot_policy == "required": | ||||
|                 reboot = True | ||||
|             elif reboot_policy == "never": | ||||
|                 agent.needs_reboot = True | ||||
|                 agent.save(update_fields=["needs_reboot"]) | ||||
|  | ||||
|         if reboot: | ||||
|             asyncio.run(agent.nats_cmd({"func": "rebootnow"}, wait=False)) | ||||
|             logger.info(f"{agent.hostname} is rebooting after updates were installed.") | ||||
|  | ||||
|         agent.delete_superseded_updates() | ||||
|         return Response("ok") | ||||
|  | ||||
|     def patch(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         u = agent.winupdates.filter(guid=request.data["guid"]).last()  # type: ignore | ||||
|         success: bool = request.data["success"] | ||||
|         if success: | ||||
|             u.result = "success" | ||||
|             u.downloaded = True | ||||
|             u.installed = True | ||||
|             u.date_installed = djangotime.now() | ||||
|             u.save( | ||||
|                 update_fields=[ | ||||
|                     "result", | ||||
|                     "downloaded", | ||||
|                     "installed", | ||||
|                     "date_installed", | ||||
|                 ] | ||||
|             ) | ||||
|         else: | ||||
|             u.result = "failed" | ||||
|             u.save(update_fields=["result"]) | ||||
|  | ||||
|         if agent.agentoutages.exists() and agent.agentoutages.last().is_active: | ||||
|             last_outage = agent.agentoutages.last() | ||||
|             last_outage.recovery_time = djangotime.now() | ||||
|             last_outage.save(update_fields=["recovery_time"]) | ||||
|  | ||||
|             if agent.overdue_email_alert: | ||||
|                 agent_recovery_email_task.delay(pk=last_outage.pk) | ||||
|             if agent.overdue_text_alert: | ||||
|                 agent_recovery_sms_task.delay(pk=last_outage.pk) | ||||
|  | ||||
|         recovery = agent.recoveryactions.filter(last_run=None).last() | ||||
|         if recovery is not None: | ||||
|             recovery.last_run = djangotime.now() | ||||
|             recovery.save(update_fields=["last_run"]) | ||||
|             return Response(recovery.send()) | ||||
|  | ||||
|         # handle agent update | ||||
|         if agent.pendingactions.filter( | ||||
|             action_type="agentupdate", status="pending" | ||||
|         ).exists(): | ||||
|             update = agent.pendingactions.filter( | ||||
|                 action_type="agentupdate", status="pending" | ||||
|             ).last() | ||||
|             update.status = "completed" | ||||
|             update.save(update_fields=["status"]) | ||||
|             return Response(update.details) | ||||
|  | ||||
|         # get any pending actions | ||||
|         if agent.pendingactions.filter(status="pending").exists(): | ||||
|             agent.handle_pending_actions() | ||||
|  | ||||
|         agent.delete_superseded_updates() | ||||
|         return Response("ok") | ||||
|  | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         updates = request.data["wua_updates"] | ||||
|         for update in updates: | ||||
|             if agent.winupdates.filter(guid=update["guid"]).exists():  # type: ignore | ||||
|                 u = agent.winupdates.filter(guid=update["guid"]).last()  # type: ignore | ||||
|                 u.downloaded = update["downloaded"] | ||||
|                 u.installed = update["installed"] | ||||
|                 u.save(update_fields=["downloaded", "installed"]) | ||||
|             else: | ||||
|                 try: | ||||
|                     kb = "KB" + update["kb_article_ids"][0] | ||||
|                 except: | ||||
|                     continue | ||||
|  | ||||
|         serializer = WinAgentSerializer(instance=agent, data=request.data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save(last_seen=djangotime.now()) | ||||
|                 WinUpdate( | ||||
|                     agent=agent, | ||||
|                     guid=update["guid"], | ||||
|                     kb=kb, | ||||
|                     title=update["title"], | ||||
|                     installed=update["installed"], | ||||
|                     downloaded=update["downloaded"], | ||||
|                     description=update["description"], | ||||
|                     severity=update["severity"], | ||||
|                     categories=update["categories"], | ||||
|                     category_ids=update["category_ids"], | ||||
|                     kb_article_ids=update["kb_article_ids"], | ||||
|                     more_info_urls=update["more_info_urls"], | ||||
|                     support_url=update["support_url"], | ||||
|                     revision_number=update["revision_number"], | ||||
|                 ).save() | ||||
|  | ||||
|         sync_salt_modules_task.delay(agent.pk) | ||||
|         get_installed_software.delay(agent.pk) | ||||
|         get_wmi_detail_task.delay(agent.pk) | ||||
|         check_for_updates_task.apply_async( | ||||
|             queue="wupdate", kwargs={"pk": agent.pk, "wait": True} | ||||
|         ) | ||||
|         agent.delete_superseded_updates() | ||||
|  | ||||
|         if not agent.choco_installed: | ||||
|             install_chocolatey.delay(agent.pk, wait=True) | ||||
|         # more superseded updates cleanup | ||||
|         if pyver.parse(agent.version) <= pyver.parse("1.4.2"): | ||||
|             for u in agent.winupdates.filter(  # type: ignore | ||||
|                 date_installed__isnull=True, result="failed" | ||||
|             ).exclude(installed=True): | ||||
|                 u.delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class CheckRunner(APIView): | ||||
|     """ | ||||
|     For the windows golang agent | ||||
|     """ | ||||
| class SupersededWinUpdate(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         updates = agent.winupdates.filter(guid=request.data["guid"])  # type: ignore | ||||
|         for u in updates: | ||||
|             u.delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class RunChecks(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|         checks = Check.objects.filter(agent__pk=agent.pk, overriden_by_policy=False) | ||||
|  | ||||
|         ret = { | ||||
|             "agent": agent.pk, | ||||
|             "check_interval": agent.check_interval, | ||||
|             "checks": CheckRunnerGetSerializerV3(checks, many=True).data, | ||||
|             "checks": CheckRunnerGetSerializer(checks, many=True).data, | ||||
|         } | ||||
|         return Response(ret) | ||||
|  | ||||
|  | ||||
| class CheckRunner(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|         checks = agent.agentchecks.filter(overriden_by_policy=False)  # type: ignore | ||||
|  | ||||
|         run_list = [ | ||||
|             check | ||||
|             for check in checks | ||||
|             # always run if check hasn't run yet | ||||
|             if not check.last_run | ||||
|             # if a check interval is set, see if the correct amount of seconds have passed | ||||
|             or ( | ||||
|                 check.run_interval | ||||
|                 and ( | ||||
|                     check.last_run | ||||
|                     < djangotime.now() | ||||
|                     - djangotime.timedelta(seconds=check.run_interval) | ||||
|                 ) | ||||
|                 # if check interval isn't set, make sure the agent's check interval has passed before running | ||||
|             ) | ||||
|             or ( | ||||
|                 check.last_run | ||||
|                 < djangotime.now() - djangotime.timedelta(seconds=agent.check_interval) | ||||
|             ) | ||||
|         ] | ||||
|         ret = { | ||||
|             "agent": agent.pk, | ||||
|             "check_interval": agent.check_run_interval(), | ||||
|             "checks": CheckRunnerGetSerializer(run_list, many=True).data, | ||||
|         } | ||||
|         return Response(ret) | ||||
|  | ||||
|     def patch(self, request): | ||||
|         from logs.models import AuditLog | ||||
|  | ||||
|         check = get_object_or_404(Check, pk=request.data["id"]) | ||||
|         check.last_run = djangotime.now() | ||||
|         check.save(update_fields=["last_run"]) | ||||
|         status = check.handle_checkv2(request.data) | ||||
|  | ||||
|         # create audit entry | ||||
|         AuditLog.objects.create( | ||||
|             username=check.agent.hostname, | ||||
|             agent=check.agent.hostname, | ||||
|             object_type="agent", | ||||
|             action="check_run", | ||||
|             message=f"{check.readable_desc} was run on {check.agent.hostname}. Status: {status}", | ||||
|             after_value=Check.serialize(check), | ||||
|         ) | ||||
|  | ||||
|         return Response(status) | ||||
|  | ||||
|  | ||||
| class TaskRunner(APIView): | ||||
|     """ | ||||
|     For the windows golang agent | ||||
|     """ | ||||
| class CheckRunnerInterval(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|  | ||||
|         return Response( | ||||
|             {"agent": agent.pk, "check_interval": agent.check_run_interval()} | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class TaskRunner(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
| @@ -189,6 +341,7 @@ class TaskRunner(APIView): | ||||
|         return Response(TaskGOGetSerializer(task).data) | ||||
|  | ||||
|     def patch(self, request, pk, agentid): | ||||
|         from alerts.models import Alert | ||||
|         from logs.models import AuditLog | ||||
|  | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
| @@ -200,7 +353,18 @@ class TaskRunner(APIView): | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save(last_run=djangotime.now()) | ||||
|  | ||||
|         new_task = AutomatedTask.objects.get(pk=task.pk) | ||||
|         status = "failing" if task.retcode != 0 else "passing" | ||||
|  | ||||
|         new_task: AutomatedTask = AutomatedTask.objects.get(pk=task.pk) | ||||
|         new_task.status = status | ||||
|         new_task.save() | ||||
|  | ||||
|         if status == "passing": | ||||
|             if Alert.objects.filter(assigned_task=new_task, resolved=False).exists(): | ||||
|                 Alert.handle_alert_resolve(new_task) | ||||
|         else: | ||||
|             Alert.handle_alert_failure(new_task) | ||||
|  | ||||
|         AuditLog.objects.create( | ||||
|             username=agent.hostname, | ||||
|             agent=agent.hostname, | ||||
| @@ -213,154 +377,6 @@ class TaskRunner(APIView): | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class SaltMinion(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|         ret = { | ||||
|             "latestVer": settings.LATEST_SALT_VER, | ||||
|             "currentVer": agent.salt_ver, | ||||
|             "salt_id": agent.salt_id, | ||||
|             "downloadURL": agent.winsalt_dl, | ||||
|         } | ||||
|         return Response(ret) | ||||
|  | ||||
|     def post(self, request): | ||||
|         # accept the salt key | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         if agent.salt_id != request.data["saltid"]: | ||||
|             return notify_error("Salt keys do not match") | ||||
|  | ||||
|         try: | ||||
|             resp = requests.post( | ||||
|                 f"http://{settings.SALT_HOST}:8123/run", | ||||
|                 json=[ | ||||
|                     { | ||||
|                         "client": "wheel", | ||||
|                         "fun": "key.accept", | ||||
|                         "match": request.data["saltid"], | ||||
|                         "username": settings.SALT_USERNAME, | ||||
|                         "password": settings.SALT_PASSWORD, | ||||
|                         "eauth": "pam", | ||||
|                     } | ||||
|                 ], | ||||
|                 timeout=30, | ||||
|             ) | ||||
|         except Exception: | ||||
|             return notify_error("No communication between agent and salt-api") | ||||
|  | ||||
|         try: | ||||
|             data = resp.json()["return"][0]["data"] | ||||
|             minion = data["return"]["minions"][0] | ||||
|         except Exception: | ||||
|             return notify_error("Key error") | ||||
|  | ||||
|         if data["success"] and minion == request.data["saltid"]: | ||||
|             return Response("Salt key was accepted") | ||||
|         else: | ||||
|             return notify_error("Not accepted") | ||||
|  | ||||
|     def patch(self, request): | ||||
|         # sync modules | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         r = agent.salt_api_cmd(timeout=45, func="saltutil.sync_modules") | ||||
|  | ||||
|         if r == "timeout" or r == "error": | ||||
|             return notify_error("Failed to sync salt modules") | ||||
|  | ||||
|         if isinstance(r, list) and any("modules" in i for i in r): | ||||
|             return Response("Successfully synced salt modules") | ||||
|         elif isinstance(r, list) and not r: | ||||
|             return Response("Modules are already in sync") | ||||
|         else: | ||||
|             return notify_error(f"Failed to sync salt modules: {str(r)}") | ||||
|  | ||||
|     def put(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         agent.salt_ver = request.data["ver"] | ||||
|         agent.save(update_fields=["salt_ver"]) | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class WinUpdater(APIView): | ||||
|  | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|         agent.delete_superseded_updates() | ||||
|         patches = agent.winupdates.filter(action="approve").exclude(installed=True) | ||||
|         return Response(ApprovedUpdateSerializer(patches, many=True).data) | ||||
|  | ||||
|     # agent sends patch results as it's installing them | ||||
|     def patch(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         kb = request.data["kb"] | ||||
|         results = request.data["results"] | ||||
|         update = agent.winupdates.get(kb=kb) | ||||
|  | ||||
|         if results == "error" or results == "failed": | ||||
|             update.result = results | ||||
|             update.save(update_fields=["result"]) | ||||
|         elif results == "success": | ||||
|             update.result = "success" | ||||
|             update.downloaded = True | ||||
|             update.installed = True | ||||
|             update.date_installed = djangotime.now() | ||||
|             update.save( | ||||
|                 update_fields=[ | ||||
|                     "result", | ||||
|                     "downloaded", | ||||
|                     "installed", | ||||
|                     "date_installed", | ||||
|                 ] | ||||
|             ) | ||||
|         elif results == "alreadyinstalled": | ||||
|             update.result = "success" | ||||
|             update.downloaded = True | ||||
|             update.installed = True | ||||
|             update.save(update_fields=["result", "downloaded", "installed"]) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|     # agent calls this after it's finished installing all patches | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         reboot_policy = agent.get_patch_policy().reboot_after_install | ||||
|         reboot = False | ||||
|  | ||||
|         if reboot_policy == "always": | ||||
|             reboot = True | ||||
|  | ||||
|         if request.data["reboot"]: | ||||
|             if reboot_policy == "required": | ||||
|                 reboot = True | ||||
|             elif reboot_policy == "never": | ||||
|                 agent.needs_reboot = True | ||||
|                 agent.save(update_fields=["needs_reboot"]) | ||||
|  | ||||
|         if reboot: | ||||
|             if agent.has_nats: | ||||
|                 asyncio.run(agent.nats_cmd({"func": "rebootnow"}, wait=False)) | ||||
|             else: | ||||
|                 agent.salt_api_async( | ||||
|                     func="system.reboot", | ||||
|                     arg=7, | ||||
|                     kwargs={"in_seconds": True}, | ||||
|                 ) | ||||
|  | ||||
|             logger.info(f"{agent.hostname} is rebooting after updates were installed.") | ||||
|         else: | ||||
|             check_for_updates_task.apply_async( | ||||
|                 queue="wupdate", kwargs={"pk": agent.pk, "wait": False} | ||||
|             ) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class SysInfo(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
| @@ -376,21 +392,6 @@ class SysInfo(APIView): | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class MeshInfo(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, pk): | ||||
|         agent = get_object_or_404(Agent, pk=pk) | ||||
|         return Response(agent.mesh_node_id) | ||||
|  | ||||
|     def patch(self, request, pk): | ||||
|         agent = get_object_or_404(Agent, pk=pk) | ||||
|         agent.mesh_node_id = request.data["nodeidhex"] | ||||
|         agent.save(update_fields=["mesh_node_id"]) | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class MeshExe(APIView): | ||||
|     """ Sends the mesh exe to the installer """ | ||||
|  | ||||
| @@ -440,10 +441,10 @@ class NewAgent(APIView): | ||||
|         agent.salt_id = f"{agent.hostname}-{agent.pk}" | ||||
|         agent.save(update_fields=["salt_id"]) | ||||
|  | ||||
|         user = User.objects.create_user( | ||||
|         user = User.objects.create_user(  # type: ignore | ||||
|             username=request.data["agent_id"], | ||||
|             agent=agent, | ||||
|             password=User.objects.make_random_password(60), | ||||
|             password=User.objects.make_random_password(60),  # type: ignore | ||||
|         ) | ||||
|  | ||||
|         token = Token.objects.create(user=user) | ||||
| @@ -455,10 +456,6 @@ class NewAgent(APIView): | ||||
|  | ||||
|         reload_nats() | ||||
|  | ||||
|         # Generate policies for new agent | ||||
|         agent.generate_checks_from_policies() | ||||
|         agent.generate_tasks_from_policies() | ||||
|  | ||||
|         # create agent install audit record | ||||
|         AuditLog.objects.create( | ||||
|             username=request.user, | ||||
| @@ -476,3 +473,98 @@ class NewAgent(APIView): | ||||
|                 "token": token.key, | ||||
|             } | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class Software(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def post(self, request): | ||||
|         agent = get_object_or_404(Agent, agent_id=request.data["agent_id"]) | ||||
|         raw: SoftwareList = request.data["software"] | ||||
|         if not isinstance(raw, list): | ||||
|             return notify_error("err") | ||||
|  | ||||
|         sw = filter_software(raw) | ||||
|         if not InstalledSoftware.objects.filter(agent=agent).exists(): | ||||
|             InstalledSoftware(agent=agent, software=sw).save() | ||||
|         else: | ||||
|             s = agent.installedsoftware_set.first()  # type: ignore | ||||
|             s.software = sw | ||||
|             s.save(update_fields=["software"]) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class Installer(APIView): | ||||
|     def get(self, request): | ||||
|         # used to check if token is valid. will return 401 if not | ||||
|         return Response("ok") | ||||
|  | ||||
|     def post(self, request): | ||||
|         if "version" not in request.data: | ||||
|             return notify_error("Invalid data") | ||||
|  | ||||
|         ver = request.data["version"] | ||||
|         if pyver.parse(ver) < pyver.parse(settings.LATEST_AGENT_VER): | ||||
|             return notify_error( | ||||
|                 f"Old installer detected (version {ver} ). Latest version is {settings.LATEST_AGENT_VER} Please generate a new installer from the RMM" | ||||
|             ) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class ChocoResult(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def patch(self, request, pk): | ||||
|         action = get_object_or_404(PendingAction, pk=pk) | ||||
|         results: str = request.data["results"] | ||||
|  | ||||
|         software_name = action.details["name"].lower() | ||||
|         success = [ | ||||
|             "install", | ||||
|             "of", | ||||
|             software_name, | ||||
|             "was", | ||||
|             "successful", | ||||
|             "installed", | ||||
|         ] | ||||
|         duplicate = [software_name, "already", "installed", "--force", "reinstall"] | ||||
|         installed = False | ||||
|  | ||||
|         if all(x in results.lower() for x in success): | ||||
|             installed = True | ||||
|         elif all(x in results.lower() for x in duplicate): | ||||
|             installed = True | ||||
|  | ||||
|         action.details["output"] = results | ||||
|         action.details["installed"] = installed | ||||
|         action.status = "completed" | ||||
|         action.save(update_fields=["details", "status"]) | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class AgentRecovery(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         agent = get_object_or_404(Agent, agent_id=agentid) | ||||
|         recovery = agent.recoveryactions.filter(last_run=None).last()  # type: ignore | ||||
|         ret = {"mode": "pass", "shellcmd": ""} | ||||
|         if recovery is None: | ||||
|             return Response(ret) | ||||
|  | ||||
|         recovery.last_run = djangotime.now() | ||||
|         recovery.save(update_fields=["last_run"]) | ||||
|  | ||||
|         ret["mode"] = recovery.mode | ||||
|  | ||||
|         if recovery.mode == "command": | ||||
|             ret["shellcmd"] = recovery.command | ||||
|         elif recovery.mode == "rpc": | ||||
|             reload_nats() | ||||
|  | ||||
|         return Response(ret) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| # Generated by Django 3.0.6 on 2020-06-04 17:13 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -6,11 +6,11 @@ from django.db import migrations | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('automation', '0005_auto_20200922_1344'), | ||||
|         ("automation", "0005_auto_20200922_1344"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.DeleteModel( | ||||
|             name='PolicyExclusions', | ||||
|             name="PolicyExclusions", | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -0,0 +1,20 @@ | ||||
| # Generated by Django 3.1.4 on 2021-02-12 14:08 | ||||
|  | ||||
| import django.db.models.deletion | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('alerts', '0004_auto_20210212_1408'), | ||||
|         ('automation', '0006_delete_policyexclusions'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='policy', | ||||
|             name='alert_template', | ||||
|             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='policies', to='alerts.alerttemplate'), | ||||
|         ), | ||||
|     ] | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user