Compare commits
	
		
			799 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 2e6c9795ec | ||
|  | c6b667f8b3 | ||
|  | ad4cddb4f3 | ||
|  | ddba83b993 | ||
|  | 91c33b0431 | ||
|  | d1df40633a | ||
|  | 7f9fc484e8 | ||
|  | ecf564648e | ||
|  | 150e3190bc | ||
|  | 63947346e9 | ||
|  | 86816ce357 | ||
|  | 0d34831df4 | ||
|  | c35da67401 | ||
|  | fb47022380 | ||
|  | 46c5128418 | ||
|  | 4a5bfee616 | ||
|  | f8314e0f8e | ||
|  | 9624af4e67 | ||
|  | 5bec4768e7 | ||
|  | 3851b0943a | ||
|  | cc1f640a50 | ||
|  | ec0a2dc053 | ||
|  | a6166a1ad7 | ||
|  | 41e3d1f490 | ||
|  | 2cbecaa552 | ||
|  | 8d543dcc7d | ||
|  | 18b1afe34f | ||
|  | 0f86bbfad8 | ||
|  | 0d021a800a | ||
|  | 038304384a | ||
|  | 2c09ad6b91 | ||
|  | 0bd09d03c1 | ||
|  | faa0e6c289 | ||
|  | c28d800d7f | ||
|  | 4fd772ecd8 | ||
|  | 5520a84062 | ||
|  | 66c7123f7c | ||
|  | bacf4154fd | ||
|  | 61790d2261 | ||
|  | 899111a310 | ||
|  | 3bfa35e1c7 | ||
|  | ebefcb7fc1 | ||
|  | ce11685371 | ||
|  | 9edb848947 | ||
|  | f326096fad | ||
|  | 46f0b23f4f | ||
|  | 1c1d3bd619 | ||
|  | d894f92d5e | ||
|  | 6c44191fe4 | ||
|  | 0deb78a9af | ||
|  | 9c15f4ba88 | ||
|  | 4ba27ec1d6 | ||
|  | c8dd80530a | ||
|  | eda5ea7d1a | ||
|  | 77a916e1a8 | ||
|  | 7ba2a4b27b | ||
|  | d33f69720a | ||
|  | 59c880dc36 | ||
|  | e5c355e8f9 | ||
|  | d36fadf3ca | ||
|  | b618cbdf7c | ||
|  | 15ec7173aa | ||
|  | 4166e92754 | ||
|  | 85166b6e8b | ||
|  | 5278599675 | ||
|  | 18cac8ba5d | ||
|  | dfccbceea6 | ||
|  | fc4b651e46 | ||
|  | fb89922ecf | ||
|  | 8ab23c8cd9 | ||
|  | 787a2c5071 | ||
|  | da76a20345 | ||
|  | 9688dbdb36 | ||
|  | 6fa16e1a5e | ||
|  | 71a2e3cfca | ||
|  | e9c0f7e200 | ||
|  | 25154a4331 | ||
|  | 22c152f600 | ||
|  | 3eab61cbc3 | ||
|  | a029c1d0db | ||
|  | 706757d215 | ||
|  | 9054c233f4 | ||
|  | efb0748fc9 | ||
|  | 751b0ef716 | ||
|  | 716450b97e | ||
|  | 2c289a4d8f | ||
|  | a4ad4c033f | ||
|  | 511bca9d66 | ||
|  | ac3fb03b2d | ||
|  | 282087d0f3 | ||
|  | 781282599c | ||
|  | d611ab0ee2 | ||
|  | 411cbdffee | ||
|  | cfd19e02a7 | ||
|  | 717eeb3903 | ||
|  | a394fb8757 | ||
|  | 2125a7ffdb | ||
|  | 00c0a6ec60 | ||
|  | 090bcf89ac | ||
|  | 4a768dec48 | ||
|  | c8d72ddd3b | ||
|  | 5cf618695f | ||
|  | 8a1f497265 | ||
|  | acdf20f800 | ||
|  | dbd1003002 | ||
|  | 48db3d3fcc | ||
|  | 41ccd14f25 | ||
|  | 60800df798 | ||
|  | 9c36f2cbc5 | ||
|  | 0b4fff907a | ||
|  | 442f09d0fe | ||
|  | 50af28b2aa | ||
|  | 28ad74a68e | ||
|  | 13cdbae38f | ||
|  | 55c77df5ae | ||
|  | 9b1d2fd985 | ||
|  | 91b7ea0367 | ||
|  | 96d3926d09 | ||
|  | c709b5a7eb | ||
|  | df82914005 | ||
|  | b1bdc38283 | ||
|  | beb1215329 | ||
|  | 51784388b9 | ||
|  | dbbbd53a4d | ||
|  | f9d992c969 | ||
|  | 29a4d61e90 | ||
|  | 2667cdb26c | ||
|  | a1669a5104 | ||
|  | 059f1bd63d | ||
|  | 82ae5e442c | ||
|  | b10114cd7c | ||
|  | 33f730aac4 | ||
|  | 92fdfdb05c | ||
|  | fbaf3f3623 | ||
|  | 5f400bc513 | ||
|  | 0fc59645fc | ||
|  | e2dee272b8 | ||
|  | 364cf362f4 | ||
|  | 8394a263c4 | ||
|  | 0e9aa26cfc | ||
|  | 6a23d63266 | ||
|  | af2fc15964 | ||
|  | 5919037a4a | ||
|  | a761dab229 | ||
|  | fa656e1f56 | ||
|  | 77e141e84a | ||
|  | 2439965fa8 | ||
|  | f66afbee90 | ||
|  | 5a89d23a67 | ||
|  | 07c8dad1c3 | ||
|  | beb8b18e98 | ||
|  | 887bb5d7cc | ||
|  | 4a9542d970 | ||
|  | c049d9d5ff | ||
|  | c2cc4389a0 | ||
|  | 12b5011266 | ||
|  | 6e3cad454c | ||
|  | 8251bd028c | ||
|  | da87d452c2 | ||
|  | 9bca0dfb3c | ||
|  | 57904c4a97 | ||
|  | 4e74d851e9 | ||
|  | e5c1f69b02 | ||
|  | 9d390d064c | ||
|  | 4994d7892c | ||
|  | 1ea06e3c42 | ||
|  | a4b7a6dfc7 | ||
|  | 7fe1cce606 | ||
|  | 7e5abe32e0 | ||
|  | 47caf7c142 | ||
|  | cf4d777344 | ||
|  | 255927c346 | ||
|  | e8c5fc79a6 | ||
|  | b309b24d0b | ||
|  | 13f4cca9d5 | ||
|  | b3c0273e0c | ||
|  | 1df7fdf703 | ||
|  | cbf38309e2 | ||
|  | 2ec7257dd7 | ||
|  | 531aac6923 | ||
|  | 59b4604c77 | ||
|  | 52aa269af9 | ||
|  | 8a03d9c498 | ||
|  | a36fc7ecfd | ||
|  | 7b0c269bce | ||
|  | c10bf9b357 | ||
|  | 0606642953 | ||
|  | d1b2cae201 | ||
|  | 097e567122 | ||
|  | d22e1d6a24 | ||
|  | 2827069bd9 | ||
|  | 614e3bd2a0 | ||
|  | ff756a01d2 | ||
|  | db14606dbe | ||
|  | de0a69ede5 | ||
|  | 5bf5065d9a | ||
|  | 0235dadbf7 | ||
|  | 203a15b447 | ||
|  | fe4dfe2194 | ||
|  | c2eb93abe0 | ||
|  | d32b834ae7 | ||
|  | cecf45a698 | ||
|  | 69cd348cc3 | ||
|  | 868025ffa3 | ||
|  | 60126a8cc5 | ||
|  | 8cfba49559 | ||
|  | 168f053c6f | ||
|  | 897e1d4539 | ||
|  | 5ef6a0f4ea | ||
|  | eb80e32812 | ||
|  | 620dadafe4 | ||
|  | e76fa878d2 | ||
|  | 376b421eb9 | ||
|  | e1643aca80 | ||
|  | 4e97c0c5c9 | ||
|  | 2d51b122af | ||
|  | 05b88a3c73 | ||
|  | 3c087d49e9 | ||
|  | d81fcccf10 | ||
|  | ee3a7bbbfc | ||
|  | 82d9e2fb16 | ||
|  | 6ab39d6f70 | ||
|  | 4aa413e697 | ||
|  | 04b3fc54b0 | ||
|  | e4c5a4e886 | ||
|  | a0ee7a59eb | ||
|  | b4a05160df | ||
|  | 1a437b3961 | ||
|  | bda8555190 | ||
|  | 10ca38f91d | ||
|  | a468faad20 | ||
|  | 7a20be4aff | ||
|  | 06b974c8a4 | ||
|  | 7284d9fcd8 | ||
|  | 515394049a | ||
|  | 35c8b4f535 | ||
|  | 1a325a66b4 | ||
|  | 7d82116fb9 | ||
|  | 8a7bd4f21b | ||
|  | 2e5a2ef12d | ||
|  | 89aceda65a | ||
|  | 39fd83aa16 | ||
|  | a23d811fe8 | ||
|  | a238779724 | ||
|  | 3a848bc037 | ||
|  | 0528ecb454 | ||
|  | 141835593c | ||
|  | 3d06200368 | ||
|  | 729bef9a77 | ||
|  | 94f33bd642 | ||
|  | 7e010cdbca | ||
|  | 8887bcd941 | ||
|  | 56aeeee04c | ||
|  | 98eb3c7287 | ||
|  | 6819c1989b | ||
|  | 7e01dd3e97 | ||
|  | ea4f2c3de8 | ||
|  | b2f63b8761 | ||
|  | 65865101ce | ||
|  | c3637afe69 | ||
|  | ab543ddf0c | ||
|  | 80595e76e7 | ||
|  | d49e68737a | ||
|  | 712e15ba80 | ||
|  | 986160e667 | ||
|  | 1ae4e23db1 | ||
|  | bad646141c | ||
|  | 7911235b68 | ||
|  | 12dee4d14d | ||
|  | cba841beb8 | ||
|  | 4e3ebf7078 | ||
|  | 1c34969f64 | ||
|  | dc26cabacd | ||
|  | a7bffcd471 | ||
|  | 6ae56ac2cc | ||
|  | 03c087020c | ||
|  | 857a1ab9c4 | ||
|  | 64d9530e13 | ||
|  | 5dac1efc30 | ||
|  | 18bc74bc96 | ||
|  | f64efc63f8 | ||
|  | e84b897991 | ||
|  | 519647ef93 | ||
|  | f694fe00e4 | ||
|  | 0b951f27b6 | ||
|  | 8aa082c9df | ||
|  | f2c5d47bd8 | ||
|  | ac7642cc15 | ||
|  | 8f34865dab | ||
|  | c762d12a40 | ||
|  | fe1e71dc07 | ||
|  | 85b0350ed4 | ||
|  | a980491455 | ||
|  | 5798c0ccaa | ||
|  | 742f49ca1f | ||
|  | 5560fc805b | ||
|  | 9d4f8a4e8c | ||
|  | b4d25d6285 | ||
|  | a504a376bd | ||
|  | f61ea6e90a | ||
|  | b2651df36f | ||
|  | b56c086841 | ||
|  | 0b92fee42e | ||
|  | 4343478c7b | ||
|  | 94649cbfc7 | ||
|  | fb83f84d84 | ||
|  | e099a5a32e | ||
|  | 84c2632d40 | ||
|  | 3417ee25eb | ||
|  | 6ada30102c | ||
|  | ac86ca7266 | ||
|  | bb1d3edf71 | ||
|  | 97b9253017 | ||
|  | 971c2180c9 | ||
|  | f96dc6991e | ||
|  | 6855493b2f | ||
|  | ff0d1f7c42 | ||
|  | 3ae5824761 | ||
|  | 702e865715 | ||
|  | 6bcf64c83f | ||
|  | 18b270c9d0 | ||
|  | 783376acb0 | ||
|  | 81dab470d2 | ||
|  | a12f0feb66 | ||
|  | d3c99d9c1c | ||
|  | 3eb3586c0f | ||
|  | fdde16cf56 | ||
|  | b8bc5596fd | ||
|  | 47842a79c7 | ||
|  | 391d5bc386 | ||
|  | ba8561e357 | ||
|  | 6aa1170cef | ||
|  | 6d4363e685 | ||
|  | 6b02b1e1e8 | ||
|  | df3e68fbaf | ||
|  | 58a5550989 | ||
|  | ccc9e44ace | ||
|  | f225c5cf9a | ||
|  | 5c62c7992c | ||
|  | 70b8f09ccb | ||
|  | abfeafa026 | ||
|  | aa029b005f | ||
|  | 6cc55e8f36 | ||
|  | b753d2ca1e | ||
|  | 1e50329c9e | ||
|  | 4942811694 | ||
|  | 59e37e0ccb | ||
|  | 20aa86d8a9 | ||
|  | 64c5ab7042 | ||
|  | d210f5171a | ||
|  | c7eee0f14d | ||
|  | 221753b62e | ||
|  | d213e4d37f | ||
|  | f8695f21d3 | ||
|  | 4ac1030289 | ||
|  | 93c7117319 | ||
|  | 974afd92ce | ||
|  | dd1d15f1a4 | ||
|  | be847baaed | ||
|  | 2b819e6751 | ||
|  | 66247cc005 | ||
|  | eafd38d3f2 | ||
|  | c4e590e7a0 | ||
|  | b92a594114 | ||
|  | 9dfb16f6b8 | ||
|  | 4b74866d85 | ||
|  | f532c85247 | ||
|  | b1cc00c1bc | ||
|  | 5696aa49d5 | ||
|  | e12dc936fd | ||
|  | 6d39a7fb75 | ||
|  | c87c312349 | ||
|  | e9c1886cdd | ||
|  | 13e4b1a781 | ||
|  | 3766fb14ef | ||
|  | 29ee50e38b | ||
|  | d1ab69dc31 | ||
|  | e3c4a54193 | ||
|  | 2abbd2e3cf | ||
|  | f9387a5851 | ||
|  | 7a9fb74b54 | ||
|  | d754f3dd4c | ||
|  | f54fc9e990 | ||
|  | 8952095da5 | ||
|  | 597240d501 | ||
|  | 7377906d02 | ||
|  | ce6da1bce3 | ||
|  | 1bf8ff73f8 | ||
|  | 564aaaf3df | ||
|  | 64ba69b2d0 | ||
|  | ce5ada42af | ||
|  | 1ce5973713 | ||
|  | b035b53092 | ||
|  | 7d0e02358c | ||
|  | 374ff0aeb5 | ||
|  | 947a43111e | ||
|  | 9970911249 | ||
|  | 5fed81c27b | ||
|  | dce4f1a5ae | ||
|  | 7e1fc32a1c | ||
|  | a69f14f504 | ||
|  | 931069458d | ||
|  | a5259baab0 | ||
|  | 8aaa27350d | ||
|  | 6db6eb70da | ||
|  | ac74d2b7c2 | ||
|  | 2b316aeae9 | ||
|  | aff96a45c6 | ||
|  | 9ee246440f | ||
|  | e2f524ce7a | ||
|  | a58b054292 | ||
|  | ea9e5be1fc | ||
|  | 760ea4727c | ||
|  | f57f2e53a0 | ||
|  | 136a393a17 | ||
|  | 8bbaab78b7 | ||
|  | 067cd59637 | ||
|  | ce6ac7bf53 | ||
|  | 99271c4477 | ||
|  | 156142ed58 | ||
|  | 4b5516c0eb | ||
|  | c3d8d2d240 | ||
|  | c29cf70025 | ||
|  | 6ebce55be3 | ||
|  | 01c4a85bc0 | ||
|  | 12d4206d84 | ||
|  | 946de18bea | ||
|  | 904eb3538c | ||
|  | c851ca9328 | ||
|  | 0ac415ad83 | ||
|  | b3ba34d980 | ||
|  | 52740271d9 | ||
|  | c2e444249a | ||
|  | 97310b091e | ||
|  | 4dda9cc3a1 | ||
|  | a0538b57e2 | ||
|  | d7f394eeb6 | ||
|  | 1bc4571d42 | ||
|  | 22e878502a | ||
|  | 03c1b6e30c | ||
|  | 374a434d98 | ||
|  | f1e85ff0e9 | ||
|  | 6b010f76ea | ||
|  | 0c3e9f7824 | ||
|  | ccca578622 | ||
|  | 56f7c18550 | ||
|  | d438f71bbb | ||
|  | ca5df24b6d | ||
|  | 4a6c2d106f | ||
|  | cd25a9568b | ||
|  | f78a787adb | ||
|  | dc520fa77c | ||
|  | 8f06d4dd9d | ||
|  | a7047183e1 | ||
|  | c0b145da24 | ||
|  | 52e7fd6f72 | ||
|  | 4bbe22b1c7 | ||
|  | 4747ffc08b | ||
|  | 9d07131fd6 | ||
|  | 721126d3db | ||
|  | 2b65f5e3dc | ||
|  | 57f10cf387 | ||
|  | f60c8a173b | ||
|  | 857cd690be | ||
|  | a407b60152 | ||
|  | 2c3c55adc0 | ||
|  | f586b4da17 | ||
|  | 0b7eb41049 | ||
|  | bd19c4e2bd | ||
|  | e8a73087d6 | ||
|  | dde4fd82f4 | ||
|  | 0420c393f3 | ||
|  | c88dac6437 | ||
|  | cd450f55e2 | ||
|  | 190ee7f9fb | ||
|  | fd057300cc | ||
|  | 56791089c1 | ||
|  | e91cb32ca3 | ||
|  | 9ab20df8d2 | ||
|  | 050350501c | ||
|  | d078acdf73 | ||
|  | b786a688b5 | ||
|  | 6b7fe40dd2 | ||
|  | 6f6c422246 | ||
|  | d371ff4f60 | ||
|  | d1a8348912 | ||
|  | be956d3cb6 | ||
|  | ba5beb81b7 | ||
|  | 106bbe5244 | ||
|  | f39d0e7ba2 | ||
|  | de7a1fd8ff | ||
|  | 1ac2b25876 | ||
|  | 9e014d1371 | ||
|  | 93b274a113 | ||
|  | 474c7ae873 | ||
|  | 31690d4cad | ||
|  | bbfc7e7e49 | ||
|  | 1c0aa55e7a | ||
|  | 29778ca19e | ||
|  | 9e87318cc5 | ||
|  | c645be6b70 | ||
|  | 57fc5ac088 | ||
|  | 924774f52a | ||
|  | 446a7a0844 | ||
|  | 5cfeed76d0 | ||
|  | de419319d8 | ||
|  | 7a3d36899b | ||
|  | f5dbb363f4 | ||
|  | 2bbc59a212 | ||
|  | 3403d76aae | ||
|  | 58399cedb6 | ||
|  | 9bca7e9e11 | ||
|  | 3a61430e44 | ||
|  | 7d8c783a7d | ||
|  | a2e996b550 | ||
|  | cfc1c31050 | ||
|  | 45106bf6f9 | ||
|  | 6e3cfe491b | ||
|  | 12f2158afd | ||
|  | 6d78773c55 | ||
|  | 43a62d4eb6 | ||
|  | cc08dfda96 | ||
|  | 622e33588e | ||
|  | 67980b58a0 | ||
|  | 027e444955 | ||
|  | d838750389 | ||
|  | 71d8bd5266 | ||
|  | ec4ae24bbd | ||
|  | 1128149359 | ||
|  | bdfc6634ec | ||
|  | ca4d19667b | ||
|  | c71aa7baa7 | ||
|  | fd80ccd2c5 | ||
|  | 9dc0b24399 | ||
|  | 747954e6fb | ||
|  | 274f4f227e | ||
|  | 92197d8d49 | ||
|  | aee06920eb | ||
|  | 5111b17d3c | ||
|  | 2849d8f45d | ||
|  | bac60d9bd4 | ||
|  | 9c797162f4 | ||
|  | 09d184e2f8 | ||
|  | 7bca618906 | ||
|  | 67607103e9 | ||
|  | 73c9956fe4 | ||
|  | b42f2ffe33 | ||
|  | 30a3f185ef | ||
|  | 4f1b41227f | ||
|  | 83b9d13ec9 | ||
|  | cee7896c37 | ||
|  | 0377009d2b | ||
|  | b472f3644e | ||
|  | 5d8ea837c8 | ||
|  | 82de6bc849 | ||
|  | cb4bc68c48 | ||
|  | 3ce6b38247 | ||
|  | 716c0fe979 | ||
|  | c993790b7a | ||
|  | aa32286531 | ||
|  | 6f94abde00 | ||
|  | fa19538c9d | ||
|  | 84c858b878 | ||
|  | 865de142d4 | ||
|  | 9118162553 | ||
|  | f4fc6ee9b4 | ||
|  | 108c38d57b | ||
|  | a1d73eb830 | ||
|  | 997906a610 | ||
|  | b6e5d120d3 | ||
|  | d469d0b435 | ||
|  | e9f823e000 | ||
|  | d7fb76ba74 | ||
|  | b7dde1a0d9 | ||
|  | 15095d8c23 | ||
|  | dfbebc7606 | ||
|  | 895309d93d | ||
|  | bcf50e821a | ||
|  | 30195800dd | ||
|  | 6532b0f149 | ||
|  | 5e108e4057 | ||
|  | c2b2f4d222 | ||
|  | bc4329ad21 | ||
|  | aec6d1b2f6 | ||
|  | 2baf119299 | ||
|  | 6fe4c5a2ed | ||
|  | 4abc8e41d8 | ||
|  | af694f1ce9 | ||
|  | 7c3a5fcb83 | ||
|  | 57f64b18c6 | ||
|  | 4cccc7c2f8 | ||
|  | 903a2d6a6e | ||
|  | 34c674487a | ||
|  | d15a8c5af3 | ||
|  | 3e0dec9383 | ||
|  | 8b810aad81 | ||
|  | e676bcb4f4 | ||
|  | a7aed77764 | ||
|  | 88875c0257 | ||
|  | f711a0c91a | ||
|  | d8a076cc6e | ||
|  | c900831ee9 | ||
|  | 76a30c7ef4 | ||
|  | ae5d0b1d81 | ||
|  | cd5e87be34 | ||
|  | 3e967f58d2 | ||
|  | 1ea005ba7e | ||
|  | 092772ba90 | ||
|  | b959854a76 | ||
|  | 8ccb1ebe4f | ||
|  | 91b3be6467 | ||
|  | d79d5feacc | ||
|  | 5cc78ef9d5 | ||
|  | 8639cd5a72 | ||
|  | 021ddc17e7 | ||
|  | ee47b8d004 | ||
|  | 55d267c935 | ||
|  | 0fd0b9128d | ||
|  | d9cf505b50 | ||
|  | 6079332dda | ||
|  | 929ec20365 | ||
|  | d0cad3055f | ||
|  | 4974a13bc0 | ||
|  | bd048df225 | ||
|  | ed83cbd574 | ||
|  | 7230207853 | ||
|  | 1ead8a72ab | ||
|  | 36a2e9d931 | ||
|  | 0f147a5518 | ||
|  | fce511a18b | ||
|  | 64bb61b009 | ||
|  | c6eefec5ce | ||
|  | 4c6f829c92 | ||
|  | 8c5cdd2acb | ||
|  | e5357599c4 | ||
|  | 3800f19966 | ||
|  | 7336f84a4b | ||
|  | 7bf4a5b2b5 | ||
|  | 43a7b97218 | ||
|  | 9f95c57a09 | ||
|  | 8f6056ae66 | ||
|  | 9bcac6b10e | ||
|  | 86318e1b7d | ||
|  | a8a1458833 | ||
|  | 942c1e2dfe | ||
|  | a6b6814eae | ||
|  | 0af95aa9b1 | ||
|  | b4b9256867 | ||
|  | a6f1281a98 | ||
|  | b54480928a | ||
|  | 741c74e267 | ||
|  | 3061dba5ed | ||
|  | 09f5f4027e | ||
|  | 925695fd56 | ||
|  | 3c758be856 | ||
|  | 569b76a7e3 | ||
|  | dca69eff9c | ||
|  | 6b8fedc675 | ||
|  | c42a379e7c | ||
|  | a40858adbf | ||
|  | 19bc720bc9 | ||
|  | bf79ca30bb | ||
|  | 75454895e5 | ||
|  | c81aa2d6fe | ||
|  | 376f6369b8 | ||
|  | b1e67a1ed3 | ||
|  | 7393a30bd1 | ||
|  | c934065f8e | ||
|  | 56124d2b50 | ||
|  | e8a003ff8a | ||
|  | 4c789225b2 | ||
|  | 59dcdd5393 | ||
|  | b28316a4f2 | ||
|  | 4f44671acd | ||
|  | b5eed69712 | ||
|  | b79aacb2a7 | ||
|  | 8a2eb7b058 | ||
|  | 7316d076a2 | ||
|  | 479d3bcb40 | ||
|  | f2358f1530 | ||
|  | 47d9e1b966 | ||
|  | c53657d693 | ||
|  | f19ce59e00 | ||
|  | 076f3e05d6 | ||
|  | 7d017f9494 | ||
|  | 675de4e420 | ||
|  | 418a709c6c | ||
|  | 1d7dd1b754 | ||
|  | 3fa70d6d2b | ||
|  | 9c67f52161 | ||
|  | 9f2f23fa96 | ||
|  | 46d955691a | ||
|  | 3f8800187d | ||
|  | ebbe90dfa8 | ||
|  | 074f898160 | ||
|  | a0e1783e18 | ||
|  | fc83e11d8b | ||
|  | f43627b170 | ||
|  | 8964441f44 | ||
|  | cfd7a0c621 | ||
|  | 15a422873e | ||
|  | d1f5583cd7 | ||
|  | 08f07c6f3e | ||
|  | 35a08debc3 | ||
|  | a3424c480f | ||
|  | 118ced0a43 | ||
|  | 6d355ef0cd | ||
|  | a8aa5ac231 | ||
|  | df6bc0b3c9 | ||
|  | 6b965b765c | ||
|  | d7aea6b5ba | ||
|  | 1e9a46855d | ||
|  | 91e9c18110 | ||
|  | 8ffa6088d7 | ||
|  | 52d2f8364f | ||
|  | 1f679af6fa | ||
|  | 1ba92cdcd5 | ||
|  | 45c60ba5f5 | ||
|  | d3eef45608 | ||
|  | 1960c113d4 | ||
|  | 63d6b4a1c9 | ||
|  | 9f47bb1252 | ||
|  | df4fea31d0 | ||
|  | 98ef1484c8 | ||
|  | c4ef9960b9 | ||
|  | 6b6f7744aa | ||
|  | 9192fa0fe2 | ||
|  | 3c7c2dc1a5 | ||
|  | 5c176a1af0 | ||
|  | 6d03a1cc76 | ||
|  | 1cf10edef1 | ||
|  | 6a97c63bf4 | ||
|  | 15f9612bfa | ||
|  | 9a7c90b194 | ||
|  | 91f2708a87 | ||
|  | 7bf3ecd89d | ||
|  | 4768581631 | ||
|  | aa4cd10e13 | ||
|  | 066396916d | ||
|  | 34ae57e6fe | ||
|  | 107c2b50e2 | ||
|  | a832765203 | ||
|  | 977fee82b5 | ||
|  | 8c74cbc1c6 | ||
|  | b38eec5039 | ||
|  | 6c20b932fa | ||
|  | deb24c638f | ||
|  | 40fcdb4d28 | ||
|  | f3e44cf458 | ||
|  | 498748217d | ||
|  | 483bf331fa | ||
|  | 9d62b4acdd | ||
|  | c9deef6e76 | ||
|  | 8ba6f8b0e1 | ||
|  | 824cbdc84b | ||
|  | 448c59ea88 | ||
|  | 91b858bf33 | ||
|  | c12bede980 | ||
|  | 71e9fa3d16 | ||
|  | 6800b9aaae | ||
|  | 77d44f25f9 | ||
|  | ab6227828b | ||
|  | 719ba56c59 | ||
|  | dacedf4018 | ||
|  | 2526fa3c47 | ||
|  | 7e2295c382 | ||
|  | 6ef02004ff | ||
|  | 0e60d062e9 | ||
|  | 80a94f97c4 | ||
|  | c18bc5fe67 | ||
|  | 02b98a2429 | ||
|  | 0383aeaa87 | ||
|  | 15a41d532e | ||
|  | 0f49725789 | ||
|  | 1db6733e66 | ||
|  | 0343ee4f6b | ||
|  | 2c37d2233a | ||
|  | 0cb8ccfddd | ||
|  | 41c0e85d00 | ||
|  | 35b1a39ed8 | ||
|  | 61a577ba70 | ||
|  | a1e32584fa | ||
|  | 28e0ee536d | ||
|  | 9d64a9c038 | ||
|  | 702ba969c2 | ||
|  | 6dde8ee2b8 | ||
|  | 018420310c | ||
|  | 6d49d34033 | ||
|  | 1fbd403164 | ||
|  | 13f544d2be | ||
|  | 3c9e64de81 | ||
|  | 5a9bafbc32 | ||
|  | b89d96b66f | ||
|  | b7176191ac | ||
|  | 453c5f47c2 | ||
|  | eea62e1263 | ||
|  | 4fb2a0f1ca | ||
|  | e740c4d980 | ||
|  | 253e4596e2 | 
| @@ -1,11 +1,11 @@ | ||||
| # pulls community scripts from git repo | ||||
| FROM python:3.10.8-slim AS GET_SCRIPTS_STAGE | ||||
| FROM python:3.11.8-slim AS GET_SCRIPTS_STAGE | ||||
|  | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y --no-install-recommends git && \ | ||||
|     git clone https://github.com/amidaware/community-scripts.git /community-scripts | ||||
|  | ||||
| FROM python:3.10.8-slim | ||||
| FROM python:3.11.8-slim | ||||
|  | ||||
| ENV TACTICAL_DIR /opt/tactical | ||||
| ENV TACTICAL_READY_FILE ${TACTICAL_DIR}/tmp/tactical.ready | ||||
| @@ -18,7 +18,7 @@ ENV PYTHONUNBUFFERED=1 | ||||
| EXPOSE 8000 8383 8005 | ||||
|  | ||||
| RUN apt-get update && \ | ||||
|     apt-get install -y build-essential | ||||
|     apt-get install -y build-essential weasyprint | ||||
|  | ||||
| RUN groupadd -g 1000 tactical && \ | ||||
|     useradd -u 1000 -g 1000 tactical | ||||
| @@ -27,7 +27,7 @@ RUN groupadd -g 1000 tactical && \ | ||||
| COPY --from=GET_SCRIPTS_STAGE /community-scripts /community-scripts | ||||
|  | ||||
| # Copy dev python reqs | ||||
| COPY .devcontainer/requirements.txt  / | ||||
| COPY .devcontainer/requirements.txt / | ||||
|  | ||||
| # Copy docker entrypoint.sh | ||||
| COPY .devcontainer/entrypoint.sh / | ||||
|   | ||||
| @@ -216,6 +216,7 @@ services: | ||||
|       - "443:4443" | ||||
|     volumes: | ||||
|       - tactical-data-dev:/opt/tactical | ||||
|       - ..:/workspace:cached | ||||
|  | ||||
| volumes: | ||||
|   tactical-data-dev: null | ||||
|   | ||||
| @@ -33,12 +33,12 @@ function check_tactical_ready { | ||||
| } | ||||
|  | ||||
| function django_setup { | ||||
|   until (echo > /dev/tcp/"${POSTGRES_HOST}"/"${POSTGRES_PORT}") &> /dev/null; do | ||||
|   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_SERVICE}"/4443) &> /dev/null; do | ||||
|   until (echo >/dev/tcp/"${MESH_SERVICE}"/4443) &>/dev/null; do | ||||
|     echo "waiting for meshcentral container to be ready..." | ||||
|     sleep 5 | ||||
|   done | ||||
| @@ -49,8 +49,11 @@ function django_setup { | ||||
|   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 | ||||
|  | ||||
|   BASE_DOMAIN=$(echo "import tldextract; no_fetch_extract = tldextract.TLDExtract(suffix_list_urls=()); extracted = no_fetch_extract('${API_HOST}'); print(f'{extracted.domain}.{extracted.suffix}')" | python) | ||||
|  | ||||
|   localvars="$( | ||||
|     cat <<EOF | ||||
| SECRET_KEY = '${DJANGO_SEKRET}' | ||||
|  | ||||
| DEBUG = True | ||||
| @@ -64,11 +67,17 @@ KEY_FILE = '${CERT_PRIV_PATH}' | ||||
|  | ||||
| SCRIPTS_DIR = '/community-scripts' | ||||
|  | ||||
| ALLOWED_HOSTS = ['${API_HOST}', '*'] | ||||
|  | ||||
| ADMIN_URL = 'admin/' | ||||
|  | ||||
| CORS_ORIGIN_ALLOW_ALL = True | ||||
| ALLOWED_HOSTS = ['${API_HOST}', '${APP_HOST}', '*'] | ||||
|  | ||||
| CORS_ORIGIN_WHITELIST = ['https://${APP_HOST}'] | ||||
|  | ||||
| SESSION_COOKIE_DOMAIN = '${BASE_DOMAIN}' | ||||
| CSRF_COOKIE_DOMAIN = '${BASE_DOMAIN}' | ||||
| CSRF_TRUSTED_ORIGINS = ['https://${API_HOST}', 'https://${APP_HOST}'] | ||||
|  | ||||
| HEADLESS_FRONTEND_URLS = {'socialaccount_login_error': 'https://${APP_HOST}/account/provider/callback'} | ||||
|  | ||||
| DATABASES = { | ||||
|     'default': { | ||||
| @@ -78,6 +87,17 @@ DATABASES = { | ||||
|         'PASSWORD': '${POSTGRES_PASS}', | ||||
|         'HOST': '${POSTGRES_HOST}', | ||||
|         'PORT': '${POSTGRES_PORT}', | ||||
|     }, | ||||
|     'reporting': { | ||||
|         'ENGINE': 'django.db.backends.postgresql', | ||||
|         'NAME': '${POSTGRES_DB}', | ||||
|         'USER': 'reporting_user', | ||||
|         'PASSWORD': 'read_password', | ||||
|         'HOST': '${POSTGRES_HOST}', | ||||
|         'PORT': '${POSTGRES_PORT}', | ||||
|         'OPTIONS': { | ||||
|             'options': '-c default_transaction_read_only=on' | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| @@ -87,14 +107,16 @@ MESH_TOKEN_KEY = '${MESH_TOKEN}' | ||||
| REDIS_HOST    = '${REDIS_HOST}' | ||||
| MESH_WS_URL = '${MESH_WS_URL}' | ||||
| ADMIN_ENABLED = True | ||||
| TRMM_INSECURE = True | ||||
| EOF | ||||
| )" | ||||
|   )" | ||||
|  | ||||
|   echo "${localvars}" > ${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
|   echo "${localvars}" >${WORKSPACE_DIR}/api/tacticalrmm/tacticalrmm/local_settings.py | ||||
|  | ||||
|   # run migrations and init scripts | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py pre_update_tasks | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py migrate --no-input | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py generate_json_schemas | ||||
|   "${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 | ||||
| @@ -104,9 +126,8 @@ EOF | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py create_natsapi_conf | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py create_installer_user | ||||
|   "${VIRTUAL_ENV}"/bin/python manage.py post_update_tasks | ||||
|    | ||||
|  | ||||
|   # create super user  | ||||
|   # 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 | ||||
| } | ||||
|  | ||||
| @@ -120,6 +141,8 @@ if [ "$1" = 'tactical-init-dev' ]; then | ||||
|   mkdir -p /meshcentral-data | ||||
|   mkdir -p ${TACTICAL_DIR}/tmp | ||||
|   mkdir -p ${TACTICAL_DIR}/certs | ||||
|   mkdir -p ${TACTICAL_DIR}/reporting | ||||
|   mkdir -p ${TACTICAL_DIR}/reporting/assets | ||||
|   mkdir -p /mongo/data/db | ||||
|   mkdir -p /redis/data | ||||
|   touch /meshcentral-data/.initialized && chown -R 1000:1000 /meshcentral-data | ||||
| @@ -127,6 +150,7 @@ if [ "$1" = 'tactical-init-dev' ]; then | ||||
|   touch ${TACTICAL_DIR}/certs/.initialized && chown -R 1000:1000 ${TACTICAL_DIR}/certs | ||||
|   touch /mongo/data/db/.initialized && chown -R 1000:1000 /mongo/data/db | ||||
|   touch /redis/data/.initialized && chown -R 1000:1000 /redis/data | ||||
|   touch ${TACTICAL_DIR}/reporting && chown -R 1000:1000 ${TACTICAL_DIR}/reporting | ||||
|   mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/exe | ||||
|   mkdir -p ${TACTICAL_DIR}/api/tacticalrmm/private/log | ||||
|   touch ${TACTICAL_DIR}/api/tacticalrmm/private/log/django_debug.log | ||||
|   | ||||
							
								
								
									
										3
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.github/ISSUE_TEMPLATE/bug_report.md
									
									
									
									
										vendored
									
									
								
							| @@ -14,11 +14,12 @@ assignees: '' | ||||
|  | ||||
| **Installation Method:** | ||||
|   - [ ] Standard | ||||
|   - [ ] Standard with `--insecure` flag at install | ||||
|   - [ ] 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] | ||||
| - Agent OS: [e.g. Win 10 v2004, Server 2016] | ||||
|  | ||||
| **Describe the bug** | ||||
| A clear and concise description of what the bug is. | ||||
|   | ||||
							
								
								
									
										14
									
								
								.github/workflows/ci-tests.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										14
									
								
								.github/workflows/ci-tests.yml
									
									
									
									
										vendored
									
									
								
							| @@ -14,14 +14,14 @@ jobs: | ||||
|     name: Tests | ||||
|     strategy: | ||||
|       matrix: | ||||
|         python-version: ["3.10.8"] | ||||
|         python-version: ["3.11.8"] | ||||
|  | ||||
|     steps: | ||||
|       - uses: actions/checkout@v3 | ||||
|       - uses: actions/checkout@v4 | ||||
|  | ||||
|       - uses: harmon758/postgresql-action@v1 | ||||
|         with: | ||||
|           postgresql version: "14" | ||||
|           postgresql version: "15" | ||||
|           postgresql db: "pipeline" | ||||
|           postgresql user: "pipeline" | ||||
|           postgresql password: "pipeline123456" | ||||
| @@ -57,6 +57,14 @@ jobs: | ||||
|               exit 1 | ||||
|           fi | ||||
|  | ||||
|       - name: Lint with flake8 | ||||
|         working-directory: api/tacticalrmm | ||||
|         run: | | ||||
|           flake8 --config .flake8 . | ||||
|           if [ $? -ne 0 ]; then | ||||
|               exit 1 | ||||
|           fi | ||||
|  | ||||
|       - name: Run django tests | ||||
|         env: | ||||
|           GHACTIONS: "yes" | ||||
|   | ||||
							
								
								
									
										70
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										70
									
								
								.github/workflows/codeql-analysis.yml
									
									
									
									
										vendored
									
									
								
							| @@ -1,70 +0,0 @@ | ||||
| # For most projects, this workflow file will not need changing; you simply need | ||||
| # to commit it to your repository. | ||||
| # | ||||
| # You may wish to alter this file to override the set of languages analyzed, | ||||
| # or to provide custom queries or build logic. | ||||
| # | ||||
| # ******** NOTE ******** | ||||
| # We have attempted to detect the languages in your repository. Please check | ||||
| # the `language` matrix defined below to confirm you have the correct set of | ||||
| # supported CodeQL languages. | ||||
| # | ||||
| name: "CodeQL" | ||||
|  | ||||
| on: | ||||
|   push: | ||||
|     branches: [ develop ] | ||||
|   pull_request: | ||||
|     # The branches below must be a subset of the branches above | ||||
|     branches: [ develop ] | ||||
|   schedule: | ||||
|     - cron: '19 14 * * 6' | ||||
|  | ||||
| jobs: | ||||
|   analyze: | ||||
|     name: Analyze | ||||
|     runs-on: ubuntu-latest | ||||
|     permissions: | ||||
|       actions: read | ||||
|       contents: read | ||||
|       security-events: write | ||||
|  | ||||
|     strategy: | ||||
|       fail-fast: false | ||||
|       matrix: | ||||
|         language: [ 'go', 'python' ] | ||||
|         # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] | ||||
|         # Learn more about CodeQL language support at https://git.io/codeql-language-support | ||||
|  | ||||
|     steps: | ||||
|     - name: Checkout repository | ||||
|       uses: actions/checkout@v2 | ||||
|  | ||||
|     # Initializes the CodeQL tools for scanning. | ||||
|     - name: Initialize CodeQL | ||||
|       uses: github/codeql-action/init@v1 | ||||
|       with: | ||||
|         languages: ${{ matrix.language }} | ||||
|         # If you wish to specify custom queries, you can do so here or in a config file. | ||||
|         # By default, queries listed here will override any specified in a config file. | ||||
|         # Prefix the list here with "+" to use these queries and those in the config file. | ||||
|         # queries: ./path/to/local/query, your-org/your-repo/queries@main | ||||
|  | ||||
|     # Autobuild attempts to build any compiled languages  (C/C++, C#, or Java). | ||||
|     # If this step fails, then you should remove it and run the build manually (see below) | ||||
|     - name: Autobuild | ||||
|       uses: github/codeql-action/autobuild@v1 | ||||
|  | ||||
|     # ℹ️ Command-line programs to run using the OS shell. | ||||
|     # 📚 https://git.io/JvXDl | ||||
|  | ||||
|     # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines | ||||
|     #    and modify them (or add more) to build your code if your project | ||||
|     #    uses a compiled language | ||||
|  | ||||
|     #- run: | | ||||
|     #   make bootstrap | ||||
|     #   make release | ||||
|  | ||||
|     - name: Perform CodeQL Analysis | ||||
|       uses: github/codeql-action/analyze@v1 | ||||
							
								
								
									
										20
									
								
								.github/workflows/docker-build-push.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										20
									
								
								.github/workflows/docker-build-push.yml
									
									
									
									
										vendored
									
									
								
							| @@ -9,24 +9,24 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Check out the repo | ||||
|         uses: actions/checkout@v2 | ||||
|          | ||||
|         uses: actions/checkout@v4 | ||||
|  | ||||
|       - 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  | ||||
|         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: | ||||
| @@ -36,7 +36,7 @@ jobs: | ||||
|           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: | ||||
| @@ -46,7 +46,7 @@ jobs: | ||||
|           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: | ||||
| @@ -56,7 +56,7 @@ jobs: | ||||
|           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: | ||||
| @@ -66,7 +66,7 @@ jobs: | ||||
|           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: | ||||
|   | ||||
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -56,3 +56,6 @@ daphne.sock.lock | ||||
| .pytest_cache | ||||
| coverage.xml | ||||
| setup_dev.yml | ||||
| 11env/ | ||||
| query_schema.json | ||||
| gunicorn_config.py | ||||
							
								
								
									
										37
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										37
									
								
								.vscode/settings.json
									
									
									
									
										vendored
									
									
								
							| @@ -1,34 +1,14 @@ | ||||
| { | ||||
|   "python.defaultInterpreterPath": "api/env/bin/python", | ||||
|   "python.languageServer": "Pylance", | ||||
|   "python.analysis.extraPaths": [ | ||||
|     "api/tacticalrmm", | ||||
|     "api/env" | ||||
|   ], | ||||
|   "python.analysis.extraPaths": ["api/tacticalrmm", "api/env"], | ||||
|   "python.analysis.diagnosticSeverityOverrides": { | ||||
|     "reportUnusedImport": "error", | ||||
|     "reportDuplicateImport": "error", | ||||
|     "reportGeneralTypeIssues": "none" | ||||
|     "reportGeneralTypeIssues": "none", | ||||
|     "reportOptionalMemberAccess": "none", | ||||
|   }, | ||||
|   "python.analysis.typeCheckingMode": "basic", | ||||
|   "python.linting.enabled": true, | ||||
|   "python.linting.mypyEnabled": true, | ||||
|   "python.linting.mypyArgs": [ | ||||
|     "--ignore-missing-imports", | ||||
|     "--follow-imports=silent", | ||||
|     "--show-column-numbers", | ||||
|     "--strict" | ||||
|   ], | ||||
|   "python.linting.ignorePatterns": [ | ||||
|     "**/site-packages/**/*.py", | ||||
|     ".vscode/*.py", | ||||
|     "**env/**" | ||||
|   ], | ||||
|   "python.formatting.provider": "black", | ||||
|   "mypy.targets": [ | ||||
|     "api/tacticalrmm" | ||||
|   ], | ||||
|   "mypy.runUsingActiveInterpreter": true, | ||||
|   "editor.bracketPairColorization.enabled": true, | ||||
|   "editor.guides.bracketPairs": true, | ||||
|   "editor.formatOnSave": true, | ||||
| @@ -37,7 +17,6 @@ | ||||
|     "**/docker/**/docker-compose*.yml": "dockercompose" | ||||
|   }, | ||||
|   "files.watcherExclude": { | ||||
|     "files.watcherExclude": { | ||||
|       "**/.git/objects/**": true, | ||||
|       "**/.git/subtree-cache/**": true, | ||||
|       "**/node_modules/": true, | ||||
| @@ -56,23 +35,25 @@ | ||||
|       "**/*.parquet*": true, | ||||
|       "**/*.pyc": true, | ||||
|       "**/*.zip": true | ||||
|     } | ||||
|   }, | ||||
|   "go.useLanguageServer": true, | ||||
|   "[go]": { | ||||
|     "editor.codeActionsOnSave": { | ||||
|       "source.organizeImports": false | ||||
|       "source.organizeImports": "never" | ||||
|     }, | ||||
|     "editor.snippetSuggestions": "none" | ||||
|   }, | ||||
|   "[go.mod]": { | ||||
|     "editor.codeActionsOnSave": { | ||||
|       "source.organizeImports": true | ||||
|       "source.organizeImports": "explicit" | ||||
|     } | ||||
|   }, | ||||
|   "gopls": { | ||||
|     "usePlaceholders": true, | ||||
|     "completeUnimported": true, | ||||
|     "staticcheck": true | ||||
|   }, | ||||
|   "[python]": { | ||||
|     "editor.defaultFormatter": "ms-python.black-formatter" | ||||
|   } | ||||
| } | ||||
| } | ||||
|   | ||||
| @@ -8,6 +8,7 @@ Tactical RMM is a remote monitoring & management tool, built with Django and Vue | ||||
| It uses an [agent](https://github.com/amidaware/rmmagent) written in golang and integrates with [MeshCentral](https://github.com/Ylianst/MeshCentral) | ||||
|  | ||||
| # [LIVE DEMO](https://demo.tacticalrmm.com/) | ||||
|  | ||||
| Demo database resets every hour. A lot of features are disabled for obvious reasons due to the nature of this app. | ||||
|  | ||||
| ### [Discord Chat](https://discord.gg/upGTkWp) | ||||
| @@ -19,11 +20,11 @@ Demo database resets every hour. A lot of features are disabled for obvious reas | ||||
| - Teamviewer-like remote desktop control | ||||
| - Real-time remote shell | ||||
| - Remote file browser (download and upload files) | ||||
| - Remote command and script execution (batch, powershell and python scripts) | ||||
| - Remote command and script execution (batch, powershell, python, nushell and deno scripts) | ||||
| - Event log viewer | ||||
| - Services management | ||||
| - Windows patch management | ||||
| - Automated checks with email/SMS alerting (cpu, disk, memory, services, scripts, event logs) | ||||
| - Automated checks with email/SMS/Webhook alerting (cpu, disk, memory, services, scripts, event logs) | ||||
| - Automated task runner (run scripts on a schedule) | ||||
| - Remote software installation via chocolatey | ||||
| - Software and hardware inventory | ||||
| @@ -33,10 +34,12 @@ Demo database resets every hour. A lot of features are disabled for obvious reas | ||||
| - Windows 7, 8.1, 10, 11, Server 2008R2, 2012R2, 2016, 2019, 2022 | ||||
|  | ||||
| ## Linux agent versions supported | ||||
|  | ||||
| - Any distro with systemd which includes but is not limited to: Debian (10, 11), Ubuntu x86_64 (18.04, 20.04, 22.04), Synology 7, centos, freepbx and more! | ||||
|  | ||||
| ## Mac agent versions supported | ||||
| - 64 bit Intel and Apple Silicon (M1, M2) | ||||
|  | ||||
| - 64 bit Intel and Apple Silicon (M-Series) | ||||
|  | ||||
| ## Installation / Backup / Restore / Usage | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| --- | ||||
| user: "tactical" | ||||
| python_ver: "3.10.8" | ||||
| go_ver: "1.18.5" | ||||
| python_ver: "3.11.8" | ||||
| go_ver: "1.20.7" | ||||
| backend_repo: "https://github.com/amidaware/tacticalrmm.git" | ||||
| frontend_repo: "https://github.com/amidaware/tacticalrmm-web.git" | ||||
| scripts_repo: "https://github.com/amidaware/community-scripts.git" | ||||
|   | ||||
| @@ -13,7 +13,7 @@ http { | ||||
|         server_tokens off; | ||||
|         tcp_nopush on; | ||||
|         types_hash_max_size 2048; | ||||
|         server_names_hash_bucket_size 64; | ||||
|         server_names_hash_bucket_size 256; | ||||
|         include /etc/nginx/mime.types; | ||||
|         default_type application/octet-stream; | ||||
|         ssl_protocols TLSv1.2 TLSv1.3; | ||||
|   | ||||
| @@ -1,2 +0,0 @@ | ||||
| deb https://nginx.org/packages/debian/ bullseye nginx | ||||
| deb-src https://nginx.org/packages/debian/ bullseye nginx | ||||
| @@ -1,4 +1,13 @@ | ||||
| --- | ||||
| - name: Append subdomains to hosts | ||||
|   tags: hosts | ||||
|   become: yes | ||||
|   ansible.builtin.lineinfile: | ||||
|     path: /etc/hosts | ||||
|     backrefs: yes | ||||
|     regexp: '^(127\.0\.1\.1 .*)$' | ||||
|     line: "\\1 {{ api }} {{ mesh }} {{ rmm }}" | ||||
|  | ||||
| - name: set mouse mode for vim | ||||
|   tags: vim | ||||
|   become: yes | ||||
| @@ -32,11 +41,15 @@ | ||||
|   with_items: | ||||
|     - "{{ base_pkgs }}" | ||||
|  | ||||
| - name: set arch fact | ||||
|   ansible.builtin.set_fact: | ||||
|     goarch: "{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' }}" | ||||
|  | ||||
| - name: download and install golang | ||||
|   tags: golang | ||||
|   become: yes | ||||
|   ansible.builtin.unarchive: | ||||
|     src: "https://go.dev/dl/go{{ go_ver }}.linux-amd64.tar.gz" | ||||
|     src: "https://go.dev/dl/go{{ go_ver }}.linux-{{ goarch }}.tar.gz" | ||||
|     dest: /usr/local | ||||
|     remote_src: yes | ||||
|  | ||||
| @@ -102,7 +115,7 @@ | ||||
|   tags: postgres | ||||
|   become: yes | ||||
|   ansible.builtin.copy: | ||||
|     content: "deb http://apt.postgresql.org/pub/repos/apt bullseye-pgdg main" | ||||
|     content: "deb http://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main" | ||||
|     dest: /etc/apt/sources.list.d/pgdg.list | ||||
|     owner: root | ||||
|     group: root | ||||
| @@ -119,7 +132,7 @@ | ||||
|   tags: postgres | ||||
|   become: yes | ||||
|   ansible.builtin.apt: | ||||
|     pkg: postgresql-14 | ||||
|     pkg: postgresql-15 | ||||
|     state: present | ||||
|     update_cache: yes | ||||
|  | ||||
| @@ -131,7 +144,7 @@ | ||||
|     enabled: yes | ||||
|     state: started | ||||
|  | ||||
| - name: setup database | ||||
| - name: setup trmm database | ||||
|   tags: postgres | ||||
|   become: yes | ||||
|   become_user: postgres | ||||
| @@ -144,6 +157,23 @@ | ||||
|       psql -c "ALTER ROLE {{ db_user }} SET timezone TO 'UTC'" | ||||
|       psql -c "ALTER ROLE {{ db_user }} CREATEDB" | ||||
|       psql -c "GRANT ALL PRIVILEGES ON DATABASE tacticalrmm TO {{ db_user }}" | ||||
|       psql -c "ALTER DATABASE tacticalrmm OWNER TO {{ db_user }}" | ||||
|       psql -c "GRANT USAGE, CREATE ON SCHEMA PUBLIC TO {{ db_user }}" | ||||
|  | ||||
| - name: setup mesh database | ||||
|   tags: postgres | ||||
|   become: yes | ||||
|   become_user: postgres | ||||
|   ansible.builtin.shell: | ||||
|     cmd: | | ||||
|       psql -c "CREATE DATABASE meshcentral" | ||||
|       psql -c "CREATE USER {{ mesh_db_user }} WITH PASSWORD '{{ mesh_db_passwd }}'" | ||||
|       psql -c "ALTER ROLE {{ mesh_db_user }} SET client_encoding TO 'utf8'" | ||||
|       psql -c "ALTER ROLE {{ mesh_db_user }} SET default_transaction_isolation TO 'read committed'" | ||||
|       psql -c "ALTER ROLE {{ mesh_db_user }} SET timezone TO 'UTC'" | ||||
|       psql -c "GRANT ALL PRIVILEGES ON DATABASE meshcentral TO {{ mesh_db_user }}" | ||||
|       psql -c "ALTER DATABASE meshcentral OWNER TO {{ mesh_db_user }}" | ||||
|       psql -c "GRANT USAGE, CREATE ON SCHEMA PUBLIC TO {{ mesh_db_user }}" | ||||
|  | ||||
| - name: create repo dirs | ||||
|   become: yes | ||||
| @@ -193,7 +223,7 @@ | ||||
| - name: download and extract nats | ||||
|   tags: nats | ||||
|   ansible.builtin.unarchive: | ||||
|     src: "https://github.com/nats-io/nats-server/releases/download/v{{ nats_server_ver.stdout }}/nats-server-v{{ nats_server_ver.stdout }}-linux-amd64.tar.gz" | ||||
|     src: "https://github.com/nats-io/nats-server/releases/download/v{{ nats_server_ver.stdout }}/nats-server-v{{ nats_server_ver.stdout }}-linux-{{ goarch }}.tar.gz" | ||||
|     dest: "{{ nats_tmp.path }}" | ||||
|     remote_src: yes | ||||
|  | ||||
| @@ -202,7 +232,7 @@ | ||||
|   become: yes | ||||
|   ansible.builtin.copy: | ||||
|     remote_src: yes | ||||
|     src: "{{ nats_tmp.path }}/nats-server-v{{ nats_server_ver.stdout }}-linux-amd64/nats-server" | ||||
|     src: "{{ nats_tmp.path }}/nats-server-v{{ nats_server_ver.stdout }}-linux-{{ goarch }}/nats-server" | ||||
|     dest: /usr/local/bin/nats-server | ||||
|     owner: "{{ user }}" | ||||
|     group: "{{ user }}" | ||||
| @@ -218,7 +248,7 @@ | ||||
| - name: download nodejs setup | ||||
|   tags: nodejs | ||||
|   ansible.builtin.get_url: | ||||
|     url: https://deb.nodesource.com/setup_16.x | ||||
|     url: https://deb.nodesource.com/setup_18.x | ||||
|     dest: "{{ nodejs_tmp.path }}/setup_node.sh" | ||||
|     mode: "0755" | ||||
|  | ||||
| @@ -299,14 +329,14 @@ | ||||
|   tags: nginx | ||||
|   become: yes | ||||
|   ansible.builtin.apt_key: | ||||
|     url: https://nginx.org/packages/keys/nginx_signing.key | ||||
|     url: https://nginx.org/keys/nginx_signing.key | ||||
|     state: present | ||||
|  | ||||
| - name: add nginx repo | ||||
|   tags: nginx | ||||
|   become: yes | ||||
|   ansible.builtin.copy: | ||||
|     src: nginx.repo | ||||
|   ansible.builtin.template: | ||||
|     src: nginx.repo.j2 | ||||
|     dest: /etc/apt/sources.list.d/nginx.list | ||||
|     owner: "root" | ||||
|     group: "root" | ||||
| @@ -382,12 +412,16 @@ | ||||
|     enabled: yes | ||||
|     state: restarted | ||||
|  | ||||
| - name: set natsapi fact | ||||
|   ansible.builtin.set_fact: | ||||
|     natsapi: "{{ 'nats-api' if ansible_architecture == 'x86_64' else 'nats-api-arm64' }}" | ||||
|  | ||||
| - name: copy nats-api bin | ||||
|   tags: nats-api | ||||
|   become: yes | ||||
|   ansible.builtin.copy: | ||||
|     remote_src: yes | ||||
|     src: "{{ backend_dir }}/natsapi/bin/nats-api" | ||||
|     src: "{{ backend_dir }}/natsapi/bin/{{ natsapi }}" | ||||
|     dest: /usr/local/bin/nats-api | ||||
|     owner: "{{ user }}" | ||||
|     group: "{{ user }}" | ||||
| @@ -407,7 +441,7 @@ | ||||
|   tags: pip | ||||
|   ansible.builtin.shell: | ||||
|     chdir: "{{ backend_dir }}/api" | ||||
|     cmd: python3.10 -m venv env | ||||
|     cmd: python3.11 -m venv env | ||||
|  | ||||
| - name: update pip to latest | ||||
|   tags: pip | ||||
| @@ -473,39 +507,6 @@ | ||||
|     - { src: nats-server.systemd.j2, dest: /etc/systemd/system/nats.service } | ||||
|     - { src: mesh.systemd.j2, dest: /etc/systemd/system/meshcentral.service } | ||||
|  | ||||
| - name: import mongodb repo signing key | ||||
|   tags: mongo | ||||
|   become: yes | ||||
|   ansible.builtin.apt_key: | ||||
|     url: https://www.mongodb.org/static/pgp/server-4.4.asc | ||||
|     state: present | ||||
|  | ||||
| - name: setup mongodb repo | ||||
|   tags: mongo | ||||
|   become: yes | ||||
|   ansible.builtin.copy: | ||||
|     content: "deb https://repo.mongodb.org/apt/debian buster/mongodb-org/4.4 main" | ||||
|     dest: /etc/apt/sources.list.d/mongodb-org-4.4.list | ||||
|     owner: root | ||||
|     group: root | ||||
|     mode: "0644" | ||||
|  | ||||
| - name: install mongodb | ||||
|   tags: mongo | ||||
|   become: yes | ||||
|   ansible.builtin.apt: | ||||
|     pkg: mongodb-org | ||||
|     state: present | ||||
|     update_cache: yes | ||||
|  | ||||
| - name: ensure mongodb enabled and started | ||||
|   tags: mongo | ||||
|   become: yes | ||||
|   ansible.builtin.service: | ||||
|     name: mongod | ||||
|     enabled: yes | ||||
|     state: started | ||||
|  | ||||
| - name: get mesh_ver | ||||
|   tags: mesh | ||||
|   ansible.builtin.shell: grep "^MESH_VER" {{ settings_file }} | awk -F'[= "]' '{print $5}' | ||||
|   | ||||
| @@ -2,10 +2,6 @@ SECRET_KEY = "{{ django_secret }}" | ||||
| DEBUG = True | ||||
| ALLOWED_HOSTS = ['{{ api }}'] | ||||
| ADMIN_URL = "admin/" | ||||
| CORS_ORIGIN_WHITELIST = [ | ||||
|     "http://{{ rmm }}:8080", | ||||
|     "https://{{ rmm }}:8080", | ||||
| ] | ||||
| CORS_ORIGIN_ALLOW_ALL = True | ||||
| DATABASES = { | ||||
|     'default': { | ||||
| @@ -17,9 +13,8 @@ DATABASES = { | ||||
|         'PORT': '5432', | ||||
|     } | ||||
| } | ||||
| REDIS_HOST    = "localhost" | ||||
| ADMIN_ENABLED = True | ||||
| CERT_FILE = "{{ fullchain_src }}" | ||||
| KEY_FILE = "{{ privkey_src }}" | ||||
| CERT_FILE = "{{ fullchain_dest }}" | ||||
| KEY_FILE = "{{ privkey_dest }}" | ||||
| MESH_USERNAME = "{{ mesh_user }}" | ||||
| MESH_SITE = "https://{{ mesh }}" | ||||
|   | ||||
| @@ -1,8 +1,6 @@ | ||||
| { | ||||
|   "settings": { | ||||
|     "Cert": "{{ mesh }}", | ||||
|     "MongoDb": "mongodb://127.0.0.1:27017", | ||||
|     "MongoDbName": "meshcentral", | ||||
|     "WANonly": true, | ||||
|     "Minify": 1, | ||||
|     "Port": 4430, | ||||
| @@ -10,19 +8,25 @@ | ||||
|     "RedirPort": 800, | ||||
|     "AllowLoginToken": true, | ||||
|     "AllowFraming": true, | ||||
|     "AgentPong": 300, | ||||
|     "AgentPing": 35, | ||||
|     "AllowHighQualityDesktop": true, | ||||
|     "TlsOffload": "127.0.0.1", | ||||
|     "agentCoreDump": false, | ||||
|     "Compression": true, | ||||
|     "WsCompression": true, | ||||
|     "AgentWsCompression": true, | ||||
|     "MaxInvalidLogin": { "time": 5, "count": 5, "coolofftime": 30 } | ||||
|     "MaxInvalidLogin": { "time": 5, "count": 5, "coolofftime": 30 }, | ||||
|     "postgres": { | ||||
|       "user": "{{ mesh_db_user }}", | ||||
|       "password": "{{ mesh_db_passwd }}", | ||||
|       "port": "5432", | ||||
|       "host": "localhost" | ||||
|     } | ||||
|   }, | ||||
|   "domains": { | ||||
|     "": { | ||||
|       "Title": "Tactical RMM", | ||||
|       "Title2": "Tactical RMM", | ||||
|       "Title": "Tactical RMM Dev", | ||||
|       "Title2": "Tactical RMM Dev", | ||||
|       "NewAccounts": false, | ||||
|       "CertUrl": "https://{{ mesh }}:443/", | ||||
|       "GeoLocation": true, | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [Unit] | ||||
| Description=MeshCentral Server | ||||
| After=network.target mongod.service nginx.service | ||||
| After=network.target postgresql.service nginx.service | ||||
|  | ||||
| [Service] | ||||
| Type=simple | ||||
|   | ||||
							
								
								
									
										2
									
								
								ansible/roles/trmm_dev/templates/nginx.repo.j2
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								ansible/roles/trmm_dev/templates/nginx.repo.j2
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | ||||
| deb https://nginx.org/packages/debian/ {{ ansible_distribution_release }} nginx | ||||
| deb-src https://nginx.org/packages/debian/ {{ ansible_distribution_release }} nginx | ||||
| @@ -1,4 +1,4 @@ | ||||
| DEV_URL = "http://{{ api }}:8000" | ||||
| DEV_HOST = "{{ rmm }}" | ||||
| DEV_HOST = "0.0.0.0" | ||||
| DEV_PORT = "8080" | ||||
| USE_HTTPS = false | ||||
| @@ -13,6 +13,8 @@ | ||||
|     mesh_password: "changeme" | ||||
|     db_user: "changeme" | ||||
|     db_passwd: "changeme" | ||||
|     mesh_db_user: "changeme" | ||||
|     mesh_db_passwd: "changeme" | ||||
|     django_secret: "changeme" | ||||
|     django_user: "changeme" | ||||
|     django_password: "changeme" | ||||
|   | ||||
							
								
								
									
										12
									
								
								api/tacticalrmm/.flake8
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								api/tacticalrmm/.flake8
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| [flake8] | ||||
| ignore = E501,W503,E722,E203 | ||||
| exclude = | ||||
|     .mypy* | ||||
|     .pytest* | ||||
|     .git | ||||
|     demo_data.py | ||||
|     manage.py | ||||
|     */__pycache__/* | ||||
|     */env/* | ||||
|     /usr/local/lib/* | ||||
|     **/migrations/* | ||||
| @@ -3,6 +3,7 @@ import uuid | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from accounts.models import User | ||||
| from tacticalrmm.helpers import make_random_password | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
| @@ -17,7 +18,7 @@ class Command(BaseCommand): | ||||
|         User.objects.create_user( | ||||
|             username=uuid.uuid4().hex, | ||||
|             is_installer_user=True, | ||||
|             password=User.objects.make_random_password(60), | ||||
|             password=make_random_password(len=60), | ||||
|             block_dashboard_login=True, | ||||
|         ) | ||||
|         self.stdout.write("Installer user has been created") | ||||
|   | ||||
| @@ -1,11 +1,11 @@ | ||||
| import os | ||||
| import subprocess | ||||
| from contextlib import suppress | ||||
|  | ||||
| import pyotp | ||||
| from django.conf import settings | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from accounts.models import User | ||||
| from tacticalrmm.util_settings import get_webdomain | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
| @@ -22,26 +22,13 @@ class Command(BaseCommand): | ||||
|             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): | ||||
|             with suppress(Exception): | ||||
|                 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()) | ||||
|  | ||||
|         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) | ||||
|         url = pyotp.totp.TOTP(code).provisioning_uri( | ||||
|             username, issuer_name=get_webdomain(settings.CORS_ORIGIN_WHITELIST[0]) | ||||
|         ) | ||||
|         subprocess.run(f'qr "{url}"', shell=True) | ||||
|         self.stdout.write( | ||||
|             self.style.WARNING("Scan the barcode above with your authenticator app") | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| from getpass import getpass | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from accounts.models import User | ||||
| @@ -17,7 +19,13 @@ class Command(BaseCommand): | ||||
|             self.stdout.write(self.style.ERROR(f"User {username} doesn't exist")) | ||||
|             return | ||||
|  | ||||
|         passwd = input("Enter new password: ") | ||||
|         user.set_password(passwd) | ||||
|         pass1, pass2 = "foo", "bar" | ||||
|         while pass1 != pass2: | ||||
|             pass1 = getpass() | ||||
|             pass2 = getpass(prompt="Confirm Password:") | ||||
|             if pass1 != pass2: | ||||
|                 self.stdout.write(self.style.ERROR("Passwords don't match")) | ||||
|  | ||||
|         user.set_password(pass1) | ||||
|         user.save() | ||||
|         self.stdout.write(self.style.SUCCESS(f"Password for {username} was reset!")) | ||||
|   | ||||
| @@ -0,0 +1,25 @@ | ||||
| # Generated by Django 4.2.1 on 2023-05-17 07:11 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("accounts", "0031_user_date_format"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="user", | ||||
|             name="default_agent_tbl_tab", | ||||
|             field=models.CharField( | ||||
|                 choices=[ | ||||
|                     ("server", "Servers"), | ||||
|                     ("workstation", "Workstations"), | ||||
|                     ("mixed", "Mixed"), | ||||
|                 ], | ||||
|                 default="mixed", | ||||
|                 max_length=50, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,32 @@ | ||||
| # Generated by Django 4.2.1 on 2023-05-23 04:54 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("accounts", "0032_alter_user_default_agent_tbl_tab"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="dash_info_color", | ||||
|             field=models.CharField(default="info", max_length=255), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="dash_negative_color", | ||||
|             field=models.CharField(default="negative", max_length=255), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="dash_positive_color", | ||||
|             field=models.CharField(default="positive", max_length=255), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="user", | ||||
|             name="dash_warning_color", | ||||
|             field=models.CharField(default="warning", max_length=255), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,17 @@ | ||||
| # Generated by Django 4.1.9 on 2023-05-26 23:59 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("accounts", "0033_user_dash_info_color_user_dash_negative_color_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_send_wol", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,22 @@ | ||||
| # Generated by Django 4.2.5 on 2023-10-08 22:24 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("accounts", "0034_role_can_send_wol"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_manage_reports", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_view_reports", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,16 @@ | ||||
| # Generated by Django 4.2.7 on 2023-11-09 19:57 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("accounts", "0035_role_can_manage_reports_role_can_view_reports"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name="role", | ||||
|             name="can_ping_agents", | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 4.2.13 on 2024-06-28 20:21 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("accounts", "0036_remove_role_can_ping_agents"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_run_server_scripts", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_use_webterm", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,23 @@ | ||||
| # Generated by Django 4.2.16 on 2024-10-06 05:44 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("accounts", "0037_role_can_run_server_scripts_role_can_use_webterm"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_edit_global_keystore", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="role", | ||||
|             name="can_view_global_keystore", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,5 +1,6 @@ | ||||
| from typing import Optional | ||||
|  | ||||
| from allauth.socialaccount.models import SocialAccount | ||||
| from django.contrib.auth.models import AbstractUser | ||||
| from django.core.cache import cache | ||||
| from django.db import models | ||||
| @@ -31,7 +32,7 @@ class User(AbstractUser, BaseAuditModel): | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     default_agent_tbl_tab = models.CharField( | ||||
|         max_length=50, choices=AgentTableTabs.choices, default=AgentTableTabs.SERVER | ||||
|         max_length=50, choices=AgentTableTabs.choices, default=AgentTableTabs.MIXED | ||||
|     ) | ||||
|     agents_per_page = models.PositiveIntegerField(default=50)  # not currently used | ||||
|     client_tree_sort = models.CharField( | ||||
| @@ -39,6 +40,10 @@ class User(AbstractUser, BaseAuditModel): | ||||
|     ) | ||||
|     client_tree_splitter = models.PositiveIntegerField(default=11) | ||||
|     loading_bar_color = models.CharField(max_length=255, default="red") | ||||
|     dash_info_color = models.CharField(max_length=255, default="info") | ||||
|     dash_positive_color = models.CharField(max_length=255, default="positive") | ||||
|     dash_negative_color = models.CharField(max_length=255, default="negative") | ||||
|     dash_warning_color = models.CharField(max_length=255, default="warning") | ||||
|     clear_search_when_switching = models.BooleanField(default=True) | ||||
|     date_format = models.CharField(max_length=30, blank=True, null=True) | ||||
|     is_installer_user = models.BooleanField(default=False) | ||||
| @@ -60,6 +65,19 @@ class User(AbstractUser, BaseAuditModel): | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|  | ||||
|     @property | ||||
|     def mesh_user_id(self): | ||||
|         return f"user//{self.mesh_username}" | ||||
|  | ||||
|     @property | ||||
|     def mesh_username(self): | ||||
|         # lower() needed for mesh api | ||||
|         return f"{self.username.replace(' ', '').lower()}___{self.pk}" | ||||
|  | ||||
|     @property | ||||
|     def is_sso_user(self): | ||||
|         return SocialAccount.objects.filter(user_id=self.pk).exists() | ||||
|  | ||||
|     @staticmethod | ||||
|     def serialize(user): | ||||
|         # serializes the task and returns json | ||||
| @@ -91,7 +109,6 @@ class Role(BaseAuditModel): | ||||
|  | ||||
|     # agents | ||||
|     can_list_agents = models.BooleanField(default=False) | ||||
|     can_ping_agents = models.BooleanField(default=False) | ||||
|     can_use_mesh = models.BooleanField(default=False) | ||||
|     can_uninstall_agents = models.BooleanField(default=False) | ||||
|     can_update_agents = models.BooleanField(default=False) | ||||
| @@ -105,6 +122,7 @@ class Role(BaseAuditModel): | ||||
|     can_run_bulk = models.BooleanField(default=False) | ||||
|     can_recover_agents = models.BooleanField(default=False) | ||||
|     can_list_agent_history = models.BooleanField(default=False) | ||||
|     can_send_wol = models.BooleanField(default=False) | ||||
|  | ||||
|     # core | ||||
|     can_list_notes = models.BooleanField(default=False) | ||||
| @@ -116,6 +134,10 @@ class Role(BaseAuditModel): | ||||
|     can_run_urlactions = models.BooleanField(default=False) | ||||
|     can_view_customfields = models.BooleanField(default=False) | ||||
|     can_manage_customfields = models.BooleanField(default=False) | ||||
|     can_run_server_scripts = models.BooleanField(default=False) | ||||
|     can_use_webterm = models.BooleanField(default=False) | ||||
|     can_view_global_keystore = models.BooleanField(default=False) | ||||
|     can_edit_global_keystore = models.BooleanField(default=False) | ||||
|  | ||||
|     # checks | ||||
|     can_list_checks = models.BooleanField(default=False) | ||||
| @@ -181,14 +203,17 @@ class Role(BaseAuditModel): | ||||
|     can_list_api_keys = models.BooleanField(default=False) | ||||
|     can_manage_api_keys = models.BooleanField(default=False) | ||||
|  | ||||
|     # reporting | ||||
|     can_view_reports = models.BooleanField(default=False) | ||||
|     can_manage_reports = models.BooleanField(default=False) | ||||
|  | ||||
|     def __str__(self): | ||||
|         return self.name | ||||
|  | ||||
|     def save(self, *args, **kwargs) -> None: | ||||
|  | ||||
|         # delete cache on save | ||||
|         cache.delete(f"{ROLE_CACHE_PREFIX}{self.name}") | ||||
|         super(BaseAuditModel, self).save(*args, **kwargs) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     @staticmethod | ||||
|     def serialize(role): | ||||
|   | ||||
| @@ -1,30 +1,30 @@ | ||||
| from rest_framework import permissions | ||||
|  | ||||
| from tacticalrmm.permissions import _has_perm | ||||
| from tacticalrmm.utils import get_core_settings | ||||
|  | ||||
|  | ||||
| class AccountsPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if r.method == "GET": | ||||
|             return _has_perm(r, "can_list_accounts") | ||||
|         else: | ||||
|  | ||||
|             # allow users to reset their own password/2fa see issue #686 | ||||
|             base_path = "/accounts/users/" | ||||
|             paths = ["reset/", "reset_totp/"] | ||||
|         # allow users to reset their own password/2fa see issue #686 | ||||
|         base_path = "/accounts/users/" | ||||
|         paths = ("reset/", "reset_totp/") | ||||
|  | ||||
|             if r.path in [base_path + i for i in paths]: | ||||
|                 from accounts.models import User | ||||
|         if r.path in [base_path + i for i in paths]: | ||||
|             from accounts.models import User | ||||
|  | ||||
|                 try: | ||||
|                     user = User.objects.get(pk=r.data["id"]) | ||||
|                 except User.DoesNotExist: | ||||
|                     pass | ||||
|                 else: | ||||
|                     if user == r.user: | ||||
|                         return True | ||||
|             try: | ||||
|                 user = User.objects.get(pk=r.data["id"]) | ||||
|             except User.DoesNotExist: | ||||
|                 pass | ||||
|             else: | ||||
|                 if user == r.user: | ||||
|                     return True | ||||
|  | ||||
|             return _has_perm(r, "can_manage_accounts") | ||||
|         return _has_perm(r, "can_manage_accounts") | ||||
|  | ||||
|  | ||||
| class RolesPerms(permissions.BasePermission): | ||||
| @@ -41,3 +41,14 @@ class APIKeyPerms(permissions.BasePermission): | ||||
|             return _has_perm(r, "can_list_api_keys") | ||||
|  | ||||
|         return _has_perm(r, "can_manage_api_keys") | ||||
|  | ||||
|  | ||||
| class LocalUserPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         settings = get_core_settings() | ||||
|         return not settings.block_local_user_logon | ||||
|  | ||||
|  | ||||
| class SelfResetSSOPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         return not r.user.is_sso_user | ||||
|   | ||||
| @@ -1,10 +1,13 @@ | ||||
| import pyotp | ||||
| from django.conf import settings | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     ReadOnlyField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
|  | ||||
| from tacticalrmm.util_settings import get_webdomain | ||||
|  | ||||
| from .models import APIKey, Role, User | ||||
|  | ||||
|  | ||||
| @@ -20,6 +23,10 @@ class UserUISerializer(ModelSerializer): | ||||
|             "client_tree_sort", | ||||
|             "client_tree_splitter", | ||||
|             "loading_bar_color", | ||||
|             "dash_info_color", | ||||
|             "dash_positive_color", | ||||
|             "dash_negative_color", | ||||
|             "dash_warning_color", | ||||
|             "clear_search_when_switching", | ||||
|             "block_dashboard_login", | ||||
|             "date_format", | ||||
| @@ -45,7 +52,6 @@ class UserSerializer(ModelSerializer): | ||||
|  | ||||
|  | ||||
| class TOTPSetupSerializer(ModelSerializer): | ||||
|  | ||||
|     qr_url = SerializerMethodField() | ||||
|  | ||||
|     class Meta: | ||||
| @@ -58,7 +64,7 @@ class TOTPSetupSerializer(ModelSerializer): | ||||
|  | ||||
|     def get_qr_url(self, obj): | ||||
|         return pyotp.totp.TOTP(obj.totp_key).provisioning_uri( | ||||
|             obj.username, issuer_name="Tactical RMM" | ||||
|             obj.username, issuer_name=get_webdomain(settings.CORS_ORIGIN_WHITELIST[0]) | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -80,7 +86,6 @@ class RoleAuditSerializer(ModelSerializer): | ||||
|  | ||||
|  | ||||
| class APIKeySerializer(ModelSerializer): | ||||
|  | ||||
|     username = ReadOnlyField(source="user.username") | ||||
|  | ||||
|     class Meta: | ||||
|   | ||||
| @@ -11,19 +11,20 @@ from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
| class TestAccounts(TacticalTestCase): | ||||
|     def setUp(self): | ||||
|         self.setup_coresettings() | ||||
|         self.setup_client() | ||||
|         self.bob = User(username="bob") | ||||
|         self.bob.set_password("hunter2") | ||||
|         self.bob.save() | ||||
|  | ||||
|     def test_check_creds(self): | ||||
|         url = "/checkcreds/" | ||||
|         url = "/v2/checkcreds/" | ||||
|  | ||||
|         data = {"username": "bob", "password": "hunter2"} | ||||
|         r = self.client.post(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertIn("totp", r.data.keys()) | ||||
|         self.assertEqual(r.data["totp"], "totp not set") | ||||
|         self.assertEqual(r.data["totp"], False) | ||||
|  | ||||
|         data = {"username": "bob", "password": "a3asdsa2314"} | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -40,7 +41,7 @@ class TestAccounts(TacticalTestCase): | ||||
|         data = {"username": "bob", "password": "hunter2"} | ||||
|         r = self.client.post(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data, "ok") | ||||
|         self.assertEqual(r.data["totp"], True) | ||||
|  | ||||
|         # test user set to block dashboard logins | ||||
|         self.bob.block_dashboard_login = True | ||||
| @@ -50,7 +51,7 @@ class TestAccounts(TacticalTestCase): | ||||
|  | ||||
|     @patch("pyotp.TOTP.verify") | ||||
|     def test_login_view(self, mock_verify): | ||||
|         url = "/login/" | ||||
|         url = "/v2/login/" | ||||
|  | ||||
|         mock_verify.return_value = True | ||||
|         data = {"username": "bob", "password": "hunter2", "twofactor": "123456"} | ||||
| @@ -197,7 +198,7 @@ class GetUpdateDeleteUser(TacticalTestCase): | ||||
|         r = self.client.delete(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|         url = f"/accounts/893452/users/" | ||||
|         url = "/accounts/893452/users/" | ||||
|         r = self.client.delete(url) | ||||
|         self.assertEqual(r.status_code, 404) | ||||
|  | ||||
| @@ -297,6 +298,27 @@ class TestUserAction(TacticalTestCase): | ||||
|         self.check_not_authenticated("patch", url) | ||||
|  | ||||
|  | ||||
| class TestUserReset(TacticalTestCase): | ||||
|     def setUp(self): | ||||
|         self.authenticate() | ||||
|         self.setup_coresettings() | ||||
|  | ||||
|     def test_reset_pw(self): | ||||
|         url = "/accounts/resetpw/" | ||||
|         data = {"password": "superSekret123456"} | ||||
|         r = self.client.put(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("put", url) | ||||
|  | ||||
|     def test_reset_2fa(self): | ||||
|         url = "/accounts/reset2fa/" | ||||
|         r = self.client.put(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("put", url) | ||||
|  | ||||
|  | ||||
| class TestAPIKeyViews(TacticalTestCase): | ||||
|     def setUp(self): | ||||
|         self.setup_coresettings() | ||||
| @@ -383,7 +405,7 @@ class TestTOTPSetup(TacticalTestCase): | ||||
|  | ||||
|         r = self.client.post(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data, "totp token already set") | ||||
|         self.assertEqual(r.data, False) | ||||
|  | ||||
|  | ||||
| class TestAPIAuthentication(TacticalTestCase): | ||||
|   | ||||
| @@ -5,6 +5,10 @@ from . import views | ||||
| urlpatterns = [ | ||||
|     path("users/", views.GetAddUsers.as_view()), | ||||
|     path("<int:pk>/users/", views.GetUpdateDeleteUser.as_view()), | ||||
|     path("sessions/<str:pk>/", views.DeleteActiveLoginSession.as_view()), | ||||
|     path( | ||||
|         "users/<int:pk>/sessions/", views.GetDeleteActiveLoginSessionsPerUser.as_view() | ||||
|     ), | ||||
|     path("users/reset/", views.UserActions.as_view()), | ||||
|     path("users/reset_totp/", views.UserActions.as_view()), | ||||
|     path("users/setup_totp/", views.TOTPSetup.as_view()), | ||||
| @@ -13,4 +17,6 @@ urlpatterns = [ | ||||
|     path("roles/<int:pk>/", views.GetUpdateDeleteRole.as_view()), | ||||
|     path("apikeys/", views.GetAddAPIKeys.as_view()), | ||||
|     path("apikeys/<int:pk>/", views.GetUpdateDeleteAPIKey.as_view()), | ||||
|     path("resetpw/", views.ResetPass.as_view()), | ||||
|     path("reset2fa/", views.Reset2FA.as_view()), | ||||
| ] | ||||
|   | ||||
| @@ -1,8 +1,10 @@ | ||||
| from typing import TYPE_CHECKING | ||||
|  | ||||
| from django.conf import settings | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from django.http import HttpRequest | ||||
|  | ||||
|     from accounts.models import User | ||||
|  | ||||
|  | ||||
| @@ -16,3 +18,7 @@ def is_root_user(*, request: "HttpRequest", user: "User") -> bool: | ||||
|         getattr(settings, "DEMO", False) and request.user.username == settings.ROOT_USER | ||||
|     ) | ||||
|     return root or demo | ||||
|  | ||||
|  | ||||
| def is_superuser(user: "User") -> bool: | ||||
|     return user.role and getattr(user.role, "is_superuser") | ||||
|   | ||||
| @@ -1,20 +1,39 @@ | ||||
| import datetime | ||||
|  | ||||
| import pyotp | ||||
| from allauth.socialaccount.models import SocialAccount, SocialApp | ||||
| from django.conf import settings | ||||
| from django.contrib.auth import login | ||||
| from django.db import IntegrityError | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from ipware import get_client_ip | ||||
| from django.utils import timezone as djangotime | ||||
| from knox.models import AuthToken | ||||
| from knox.views import LoginView as KnoxLoginView | ||||
| from python_ipware import IpWare | ||||
| from rest_framework.authtoken.serializers import AuthTokenSerializer | ||||
| from rest_framework.permissions import AllowAny, IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.serializers import ( | ||||
|     ModelSerializer, | ||||
|     ReadOnlyField, | ||||
|     SerializerMethodField, | ||||
| ) | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from accounts.utils import is_root_user | ||||
| from core.tasks import sync_mesh_perms_task | ||||
| from logs.models import AuditLog | ||||
| from tacticalrmm.helpers import notify_error | ||||
| from tacticalrmm.utils import get_core_settings | ||||
|  | ||||
| from .models import APIKey, Role, User | ||||
| from .permissions import AccountsPerms, APIKeyPerms, RolesPerms | ||||
| from .permissions import ( | ||||
|     AccountsPerms, | ||||
|     APIKeyPerms, | ||||
|     LocalUserPerms, | ||||
|     RolesPerms, | ||||
|     SelfResetSSOPerms, | ||||
| ) | ||||
| from .serializers import ( | ||||
|     APIKeySerializer, | ||||
|     RoleSerializer, | ||||
| @@ -22,15 +41,16 @@ from .serializers import ( | ||||
|     UserSerializer, | ||||
|     UserUISerializer, | ||||
| ) | ||||
| from accounts.utils import is_root_user | ||||
|  | ||||
|  | ||||
| class CheckCreds(KnoxLoginView): | ||||
|  | ||||
| class CheckCredsV2(KnoxLoginView): | ||||
|     permission_classes = (AllowAny,) | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
|     # restrict time on tokens issued by this view to 3 min | ||||
|     def get_token_ttl(self): | ||||
|         return datetime.timedelta(seconds=180) | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
|         # check credentials | ||||
|         serializer = AuthTokenSerializer(data=request.data) | ||||
|         if not serializer.is_valid(): | ||||
| @@ -41,21 +61,25 @@ class CheckCreds(KnoxLoginView): | ||||
|  | ||||
|         user = serializer.validated_data["user"] | ||||
|  | ||||
|         if user.block_dashboard_login: | ||||
|         if user.block_dashboard_login or user.is_sso_user: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         # block local logon if configured | ||||
|         core_settings = get_core_settings() | ||||
|         if not user.is_superuser and core_settings.block_local_user_logon: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         # if totp token not set modify response to notify frontend | ||||
|         if not user.totp_key: | ||||
|             login(request, user) | ||||
|             response = super(CheckCreds, self).post(request, format=None) | ||||
|             response.data["totp"] = "totp not set" | ||||
|             response = super().post(request, format=None) | ||||
|             response.data["totp"] = False | ||||
|             return response | ||||
|  | ||||
|         return Response("ok") | ||||
|         return Response({"totp": True}) | ||||
|  | ||||
|  | ||||
| class LoginView(KnoxLoginView): | ||||
|  | ||||
| class LoginViewV2(KnoxLoginView): | ||||
|     permission_classes = (AllowAny,) | ||||
|  | ||||
|     def post(self, request, format=None): | ||||
| @@ -68,6 +92,14 @@ class LoginView(KnoxLoginView): | ||||
|         if user.block_dashboard_login: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         # block local logon if configured | ||||
|         core_settings = get_core_settings() | ||||
|         if not user.is_superuser and core_settings.block_local_user_logon: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         if user.is_sso_user: | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|         token = request.data["twofactor"] | ||||
|         totp = pyotp.TOTP(user.totp_key) | ||||
|  | ||||
| @@ -82,14 +114,20 @@ class LoginView(KnoxLoginView): | ||||
|             login(request, user) | ||||
|  | ||||
|             # save ip information | ||||
|             client_ip, _ = get_client_ip(request) | ||||
|             user.last_login_ip = client_ip | ||||
|             user.save() | ||||
|             ipw = IpWare() | ||||
|             client_ip, _ = ipw.get_client_ip(request.META) | ||||
|             if client_ip: | ||||
|                 user.last_login_ip = str(client_ip) | ||||
|                 user.save() | ||||
|  | ||||
|             AuditLog.audit_user_login_successful( | ||||
|                 request.data["username"], debug_info={"ip": request._client_ip} | ||||
|             ) | ||||
|             return super(LoginView, self).post(request, format=None) | ||||
|             response = super().post(request, format=None) | ||||
|             response.data["username"] = request.user.username | ||||
|             response.data["name"] = None | ||||
|  | ||||
|             return Response(response.data) | ||||
|         else: | ||||
|             AuditLog.audit_user_failed_twofactor( | ||||
|                 request.data["username"], debug_info={"ip": request._client_ip} | ||||
| @@ -97,9 +135,100 @@ class LoginView(KnoxLoginView): | ||||
|             return notify_error("Bad credentials") | ||||
|  | ||||
|  | ||||
| class GetDeleteActiveLoginSessionsPerUser(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     class TokenSerializer(ModelSerializer): | ||||
|         user = ReadOnlyField(source="user.username") | ||||
|  | ||||
|         class Meta: | ||||
|             model = AuthToken | ||||
|             fields = ( | ||||
|                 "digest", | ||||
|                 "user", | ||||
|                 "created", | ||||
|                 "expiry", | ||||
|             ) | ||||
|  | ||||
|     def get(self, request, pk): | ||||
|         tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( | ||||
|             expiry__gt=djangotime.now() | ||||
|         ) | ||||
|  | ||||
|         return Response(self.TokenSerializer(tokens, many=True).data) | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         tokens = get_object_or_404(User, pk=pk).auth_token_set.filter( | ||||
|             expiry__gt=djangotime.now() | ||||
|         ) | ||||
|  | ||||
|         tokens.delete() | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class DeleteActiveLoginSession(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         token = get_object_or_404(AuthToken, digest=pk) | ||||
|  | ||||
|         token.delete() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class GetAddUsers(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|  | ||||
|     class UserSerializerSSO(ModelSerializer): | ||||
|         social_accounts = SerializerMethodField() | ||||
|  | ||||
|         def get_social_accounts(self, obj): | ||||
|             accounts = SocialAccount.objects.filter(user_id=obj.pk) | ||||
|  | ||||
|             if accounts: | ||||
|                 social_accounts = [] | ||||
|                 for account in accounts: | ||||
|                     try: | ||||
|                         provider_account = account.get_provider_account() | ||||
|                         display = provider_account.to_str() | ||||
|                     except SocialApp.DoesNotExist: | ||||
|                         display = "Orphaned Provider" | ||||
|                     except Exception: | ||||
|                         display = "Unknown" | ||||
|  | ||||
|                     social_accounts.append( | ||||
|                         { | ||||
|                             "uid": account.uid, | ||||
|                             "provider": account.provider, | ||||
|                             "display": display, | ||||
|                             "last_login": account.last_login, | ||||
|                             "date_joined": account.date_joined, | ||||
|                             "extra_data": account.extra_data, | ||||
|                         } | ||||
|                     ) | ||||
|  | ||||
|                 return social_accounts | ||||
|  | ||||
|             return [] | ||||
|  | ||||
|         class Meta: | ||||
|             model = User | ||||
|             fields = [ | ||||
|                 "id", | ||||
|                 "username", | ||||
|                 "first_name", | ||||
|                 "last_name", | ||||
|                 "email", | ||||
|                 "is_active", | ||||
|                 "last_login", | ||||
|                 "last_login_ip", | ||||
|                 "role", | ||||
|                 "block_dashboard_login", | ||||
|                 "date_format", | ||||
|                 "social_accounts", | ||||
|             ] | ||||
|  | ||||
|     def get(self, request): | ||||
|         search = request.GET.get("search", None) | ||||
|  | ||||
| @@ -110,7 +239,7 @@ class GetAddUsers(APIView): | ||||
|         else: | ||||
|             users = User.objects.filter(agent=None, is_installer_user=False) | ||||
|  | ||||
|         return Response(UserSerializer(users, many=True).data) | ||||
|         return Response(self.UserSerializerSSO(users, many=True).data) | ||||
|  | ||||
|     def post(self, request): | ||||
|         # add new user | ||||
| @@ -134,6 +263,7 @@ class GetAddUsers(APIView): | ||||
|             user.role = role | ||||
|  | ||||
|         user.save() | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return Response(user.username) | ||||
|  | ||||
|  | ||||
| @@ -154,6 +284,7 @@ class GetUpdateDeleteUser(APIView): | ||||
|         serializer = UserSerializer(instance=user, data=request.data, partial=True) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|         sync_mesh_perms_task.delay() | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
| @@ -163,12 +294,13 @@ class GetUpdateDeleteUser(APIView): | ||||
|             return notify_error("The root user cannot be deleted from the UI") | ||||
|  | ||||
|         user.delete() | ||||
|  | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class UserActions(APIView): | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms] | ||||
|     permission_classes = [IsAuthenticated, AccountsPerms, LocalUserPerms] | ||||
|  | ||||
|     # reset password | ||||
|     def post(self, request): | ||||
|         user = get_object_or_404(User, pk=request.data["id"]) | ||||
| @@ -195,10 +327,8 @@ class UserActions(APIView): | ||||
|  | ||||
|  | ||||
| class TOTPSetup(APIView): | ||||
|  | ||||
|     # totp setup | ||||
|     def post(self, request): | ||||
|  | ||||
|         user = request.user | ||||
|         if not user.totp_key: | ||||
|             code = pyotp.random_base32() | ||||
| @@ -206,7 +336,7 @@ class TOTPSetup(APIView): | ||||
|             user.save(update_fields=["totp_key"]) | ||||
|             return Response(TOTPSetupSerializer(user).data) | ||||
|  | ||||
|         return Response("totp token already set") | ||||
|         return Response(False) | ||||
|  | ||||
|  | ||||
| class UserUI(APIView): | ||||
| @@ -245,11 +375,13 @@ class GetUpdateDeleteRole(APIView): | ||||
|         serializer = RoleSerializer(instance=role, data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         serializer.save() | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return Response("Role was edited") | ||||
|  | ||||
|     def delete(self, request, pk): | ||||
|         role = get_object_or_404(Role, pk=pk) | ||||
|         role.delete() | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return Response("Role was removed") | ||||
|  | ||||
|  | ||||
| @@ -267,7 +399,7 @@ class GetAddAPIKeys(APIView): | ||||
|         request.data["key"] = get_random_string(length=32).upper() | ||||
|         serializer = APIKeySerializer(data=request.data) | ||||
|         serializer.is_valid(raise_exception=True) | ||||
|         obj = serializer.save() | ||||
|         serializer.save() | ||||
|         return Response("The API Key was added") | ||||
|  | ||||
|  | ||||
| @@ -290,3 +422,23 @@ class GetUpdateDeleteAPIKey(APIView): | ||||
|         apikey = get_object_or_404(APIKey, pk=pk) | ||||
|         apikey.delete() | ||||
|         return Response("The API Key was deleted") | ||||
|  | ||||
|  | ||||
| class ResetPass(APIView): | ||||
|     permission_classes = [IsAuthenticated, SelfResetSSOPerms] | ||||
|  | ||||
|     def put(self, request): | ||||
|         user = request.user | ||||
|         user.set_password(request.data["password"]) | ||||
|         user.save() | ||||
|         return Response("Password was reset.") | ||||
|  | ||||
|  | ||||
| class Reset2FA(APIView): | ||||
|     permission_classes = [IsAuthenticated, SelfResetSSOPerms] | ||||
|  | ||||
|     def put(self, request): | ||||
|         user = request.user | ||||
|         user.totp_key = "" | ||||
|         user.save() | ||||
|         return Response("2FA was reset. Log out and back in to setup.") | ||||
|   | ||||
| @@ -9,7 +9,6 @@ from tacticalrmm.permissions import _has_perm_on_agent | ||||
|  | ||||
| class SendCMD(AsyncJsonWebsocketConsumer): | ||||
|     async def connect(self): | ||||
|  | ||||
|         self.user = self.scope["user"] | ||||
|  | ||||
|         if isinstance(self.user, AnonymousUser): | ||||
| @@ -48,7 +47,7 @@ class SendCMD(AsyncJsonWebsocketConsumer): | ||||
|         await self.send_json({"ret": ret}) | ||||
|  | ||||
|     async def disconnect(self, _): | ||||
|         await self.close() | ||||
|         pass | ||||
|  | ||||
|     def _has_perm(self, perm: str) -> bool: | ||||
|         if self.user.is_superuser or ( | ||||
|   | ||||
| @@ -10,7 +10,7 @@ from tacticalrmm.utils import reload_nats | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "Delete old agents" | ||||
|     help = "Delete multiple agents based on criteria" | ||||
|  | ||||
|     def add_arguments(self, parser): | ||||
|         parser.add_argument( | ||||
| @@ -23,6 +23,21 @@ class Command(BaseCommand): | ||||
|             type=str, | ||||
|             help="Delete agents that equal to or less than this version", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--site", | ||||
|             type=str, | ||||
|             help="Delete agents that belong to the specified site", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--client", | ||||
|             type=str, | ||||
|             help="Delete agents that belong to the specified client", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--hostname", | ||||
|             type=str, | ||||
|             help="Delete agents with hostname starting with argument", | ||||
|         ) | ||||
|         parser.add_argument( | ||||
|             "--delete", | ||||
|             action="store_true", | ||||
| @@ -32,25 +47,40 @@ class Command(BaseCommand): | ||||
|     def handle(self, *args, **kwargs): | ||||
|         days = kwargs["days"] | ||||
|         agentver = kwargs["agentver"] | ||||
|         site = kwargs["site"] | ||||
|         client = kwargs["client"] | ||||
|         hostname = kwargs["hostname"] | ||||
|         delete = kwargs["delete"] | ||||
|  | ||||
|         if not days and not agentver: | ||||
|         if not days and not agentver and not site and not client and not hostname: | ||||
|             self.stdout.write( | ||||
|                 self.style.ERROR("Must have at least one parameter: days or agentver") | ||||
|                 self.style.ERROR( | ||||
|                     "Must have at least one parameter: days, agentver, site, client or hostname" | ||||
|                 ) | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         q = Agent.objects.defer(*AGENT_DEFER) | ||||
|         agents = Agent.objects.select_related("site__client").defer(*AGENT_DEFER) | ||||
|  | ||||
|         agents = [] | ||||
|         if days: | ||||
|             overdue = djangotime.now() - djangotime.timedelta(days=days) | ||||
|             agents = [i for i in q if i.last_seen < overdue] | ||||
|             agents = agents.filter(last_seen__lt=overdue) | ||||
|  | ||||
|         if site: | ||||
|             agents = agents.filter(site__name=site) | ||||
|  | ||||
|         if client: | ||||
|             agents = agents.filter(site__client__name=client) | ||||
|  | ||||
|         if hostname: | ||||
|             agents = agents.filter(hostname__istartswith=hostname) | ||||
|  | ||||
|         if agentver: | ||||
|             agents = [i for i in q if pyver.parse(i.version) <= pyver.parse(agentver)] | ||||
|             agents = [ | ||||
|                 i for i in agents if pyver.parse(i.version) <= pyver.parse(agentver) | ||||
|             ] | ||||
|  | ||||
|         if not agents: | ||||
|         if len(agents) == 0: | ||||
|             self.stdout.write(self.style.ERROR("No agents matched")) | ||||
|             return | ||||
|  | ||||
|   | ||||
| @@ -5,14 +5,13 @@ from django.core.management.base import BaseCommand | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from agents.models import Agent | ||||
| from core.tasks import cache_db_fields_task, handle_resolved_stuff | ||||
| from core.tasks import cache_db_fields_task | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = "stuff for demo site in cron" | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|  | ||||
|         random_dates = [] | ||||
|         now = djangotime.now() | ||||
|  | ||||
| @@ -30,4 +29,3 @@ class Command(BaseCommand): | ||||
|             agent.save(update_fields=["last_seen"]) | ||||
|  | ||||
|         cache_db_fields_task() | ||||
|         handle_resolved_stuff() | ||||
|   | ||||
| @@ -75,7 +75,6 @@ class Command(BaseCommand): | ||||
|         return "".join(random.choice(chars) for _ in range(length)) | ||||
|  | ||||
|     def handle(self, *args, **kwargs) -> None: | ||||
|  | ||||
|         user = User.objects.first() | ||||
|         if user: | ||||
|             user.totp_key = "ABSA234234" | ||||
| @@ -295,7 +294,6 @@ class Command(BaseCommand): | ||||
|         show_tmp_dir_script.save() | ||||
|  | ||||
|         for count_agents in range(AGENTS_TO_GENERATE): | ||||
|  | ||||
|             client = random.choice(clients) | ||||
|  | ||||
|             if client == clients[0]: | ||||
| @@ -823,7 +821,6 @@ class Command(BaseCommand): | ||||
|                 pick = random.randint(1, 10) | ||||
|  | ||||
|                 if pick == 5 or pick == 3: | ||||
|  | ||||
|                     reboot_time = django_now + djangotime.timedelta( | ||||
|                         minutes=random.randint(1000, 500000) | ||||
|                     ) | ||||
|   | ||||
| @@ -0,0 +1,24 @@ | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from agents.models import Agent | ||||
| from tacticalrmm.constants import AGENT_DEFER | ||||
|  | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     def find_duplicates(self, lst): | ||||
|         return list(set([item for item in lst if lst.count(item) > 1])) | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         for agent in Agent.objects.defer(*AGENT_DEFER).prefetch_related( | ||||
|             "custom_fields__field" | ||||
|         ): | ||||
|             if dupes := self.find_duplicates( | ||||
|                 [i.field.name for i in agent.custom_fields.all()] | ||||
|             ): | ||||
|                 for dupe in dupes: | ||||
|                     cf = list( | ||||
|                         agent.custom_fields.filter(field__name=dupe).order_by("id") | ||||
|                     ) | ||||
|                     to_delete = cf[:-1] | ||||
|                     for i in to_delete: | ||||
|                         i.delete() | ||||
							
								
								
									
										631
									
								
								api/tacticalrmm/agents/migrations/0056_alter_agent_time_zone.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										631
									
								
								api/tacticalrmm/agents/migrations/0056_alter_agent_time_zone.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,631 @@ | ||||
| # Generated by Django 4.1.7 on 2023-02-28 22:14 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("agents", "0055_alter_agent_time_zone"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="agent", | ||||
|             name="time_zone", | ||||
|             field=models.CharField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("Africa/Abidjan", "Africa/Abidjan"), | ||||
|                     ("Africa/Accra", "Africa/Accra"), | ||||
|                     ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), | ||||
|                     ("Africa/Algiers", "Africa/Algiers"), | ||||
|                     ("Africa/Asmara", "Africa/Asmara"), | ||||
|                     ("Africa/Asmera", "Africa/Asmera"), | ||||
|                     ("Africa/Bamako", "Africa/Bamako"), | ||||
|                     ("Africa/Bangui", "Africa/Bangui"), | ||||
|                     ("Africa/Banjul", "Africa/Banjul"), | ||||
|                     ("Africa/Bissau", "Africa/Bissau"), | ||||
|                     ("Africa/Blantyre", "Africa/Blantyre"), | ||||
|                     ("Africa/Brazzaville", "Africa/Brazzaville"), | ||||
|                     ("Africa/Bujumbura", "Africa/Bujumbura"), | ||||
|                     ("Africa/Cairo", "Africa/Cairo"), | ||||
|                     ("Africa/Casablanca", "Africa/Casablanca"), | ||||
|                     ("Africa/Ceuta", "Africa/Ceuta"), | ||||
|                     ("Africa/Conakry", "Africa/Conakry"), | ||||
|                     ("Africa/Dakar", "Africa/Dakar"), | ||||
|                     ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), | ||||
|                     ("Africa/Djibouti", "Africa/Djibouti"), | ||||
|                     ("Africa/Douala", "Africa/Douala"), | ||||
|                     ("Africa/El_Aaiun", "Africa/El_Aaiun"), | ||||
|                     ("Africa/Freetown", "Africa/Freetown"), | ||||
|                     ("Africa/Gaborone", "Africa/Gaborone"), | ||||
|                     ("Africa/Harare", "Africa/Harare"), | ||||
|                     ("Africa/Johannesburg", "Africa/Johannesburg"), | ||||
|                     ("Africa/Juba", "Africa/Juba"), | ||||
|                     ("Africa/Kampala", "Africa/Kampala"), | ||||
|                     ("Africa/Khartoum", "Africa/Khartoum"), | ||||
|                     ("Africa/Kigali", "Africa/Kigali"), | ||||
|                     ("Africa/Kinshasa", "Africa/Kinshasa"), | ||||
|                     ("Africa/Lagos", "Africa/Lagos"), | ||||
|                     ("Africa/Libreville", "Africa/Libreville"), | ||||
|                     ("Africa/Lome", "Africa/Lome"), | ||||
|                     ("Africa/Luanda", "Africa/Luanda"), | ||||
|                     ("Africa/Lubumbashi", "Africa/Lubumbashi"), | ||||
|                     ("Africa/Lusaka", "Africa/Lusaka"), | ||||
|                     ("Africa/Malabo", "Africa/Malabo"), | ||||
|                     ("Africa/Maputo", "Africa/Maputo"), | ||||
|                     ("Africa/Maseru", "Africa/Maseru"), | ||||
|                     ("Africa/Mbabane", "Africa/Mbabane"), | ||||
|                     ("Africa/Mogadishu", "Africa/Mogadishu"), | ||||
|                     ("Africa/Monrovia", "Africa/Monrovia"), | ||||
|                     ("Africa/Nairobi", "Africa/Nairobi"), | ||||
|                     ("Africa/Ndjamena", "Africa/Ndjamena"), | ||||
|                     ("Africa/Niamey", "Africa/Niamey"), | ||||
|                     ("Africa/Nouakchott", "Africa/Nouakchott"), | ||||
|                     ("Africa/Ouagadougou", "Africa/Ouagadougou"), | ||||
|                     ("Africa/Porto-Novo", "Africa/Porto-Novo"), | ||||
|                     ("Africa/Sao_Tome", "Africa/Sao_Tome"), | ||||
|                     ("Africa/Timbuktu", "Africa/Timbuktu"), | ||||
|                     ("Africa/Tripoli", "Africa/Tripoli"), | ||||
|                     ("Africa/Tunis", "Africa/Tunis"), | ||||
|                     ("Africa/Windhoek", "Africa/Windhoek"), | ||||
|                     ("America/Adak", "America/Adak"), | ||||
|                     ("America/Anchorage", "America/Anchorage"), | ||||
|                     ("America/Anguilla", "America/Anguilla"), | ||||
|                     ("America/Antigua", "America/Antigua"), | ||||
|                     ("America/Araguaina", "America/Araguaina"), | ||||
|                     ( | ||||
|                         "America/Argentina/Buenos_Aires", | ||||
|                         "America/Argentina/Buenos_Aires", | ||||
|                     ), | ||||
|                     ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), | ||||
|                     ( | ||||
|                         "America/Argentina/ComodRivadavia", | ||||
|                         "America/Argentina/ComodRivadavia", | ||||
|                     ), | ||||
|                     ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), | ||||
|                     ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), | ||||
|                     ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), | ||||
|                     ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), | ||||
|                     ( | ||||
|                         "America/Argentina/Rio_Gallegos", | ||||
|                         "America/Argentina/Rio_Gallegos", | ||||
|                     ), | ||||
|                     ("America/Argentina/Salta", "America/Argentina/Salta"), | ||||
|                     ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), | ||||
|                     ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), | ||||
|                     ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), | ||||
|                     ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), | ||||
|                     ("America/Aruba", "America/Aruba"), | ||||
|                     ("America/Asuncion", "America/Asuncion"), | ||||
|                     ("America/Atikokan", "America/Atikokan"), | ||||
|                     ("America/Atka", "America/Atka"), | ||||
|                     ("America/Bahia", "America/Bahia"), | ||||
|                     ("America/Bahia_Banderas", "America/Bahia_Banderas"), | ||||
|                     ("America/Barbados", "America/Barbados"), | ||||
|                     ("America/Belem", "America/Belem"), | ||||
|                     ("America/Belize", "America/Belize"), | ||||
|                     ("America/Blanc-Sablon", "America/Blanc-Sablon"), | ||||
|                     ("America/Boa_Vista", "America/Boa_Vista"), | ||||
|                     ("America/Bogota", "America/Bogota"), | ||||
|                     ("America/Boise", "America/Boise"), | ||||
|                     ("America/Buenos_Aires", "America/Buenos_Aires"), | ||||
|                     ("America/Cambridge_Bay", "America/Cambridge_Bay"), | ||||
|                     ("America/Campo_Grande", "America/Campo_Grande"), | ||||
|                     ("America/Cancun", "America/Cancun"), | ||||
|                     ("America/Caracas", "America/Caracas"), | ||||
|                     ("America/Catamarca", "America/Catamarca"), | ||||
|                     ("America/Cayenne", "America/Cayenne"), | ||||
|                     ("America/Cayman", "America/Cayman"), | ||||
|                     ("America/Chicago", "America/Chicago"), | ||||
|                     ("America/Chihuahua", "America/Chihuahua"), | ||||
|                     ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), | ||||
|                     ("America/Coral_Harbour", "America/Coral_Harbour"), | ||||
|                     ("America/Cordoba", "America/Cordoba"), | ||||
|                     ("America/Costa_Rica", "America/Costa_Rica"), | ||||
|                     ("America/Creston", "America/Creston"), | ||||
|                     ("America/Cuiaba", "America/Cuiaba"), | ||||
|                     ("America/Curacao", "America/Curacao"), | ||||
|                     ("America/Danmarkshavn", "America/Danmarkshavn"), | ||||
|                     ("America/Dawson", "America/Dawson"), | ||||
|                     ("America/Dawson_Creek", "America/Dawson_Creek"), | ||||
|                     ("America/Denver", "America/Denver"), | ||||
|                     ("America/Detroit", "America/Detroit"), | ||||
|                     ("America/Dominica", "America/Dominica"), | ||||
|                     ("America/Edmonton", "America/Edmonton"), | ||||
|                     ("America/Eirunepe", "America/Eirunepe"), | ||||
|                     ("America/El_Salvador", "America/El_Salvador"), | ||||
|                     ("America/Ensenada", "America/Ensenada"), | ||||
|                     ("America/Fort_Nelson", "America/Fort_Nelson"), | ||||
|                     ("America/Fort_Wayne", "America/Fort_Wayne"), | ||||
|                     ("America/Fortaleza", "America/Fortaleza"), | ||||
|                     ("America/Glace_Bay", "America/Glace_Bay"), | ||||
|                     ("America/Godthab", "America/Godthab"), | ||||
|                     ("America/Goose_Bay", "America/Goose_Bay"), | ||||
|                     ("America/Grand_Turk", "America/Grand_Turk"), | ||||
|                     ("America/Grenada", "America/Grenada"), | ||||
|                     ("America/Guadeloupe", "America/Guadeloupe"), | ||||
|                     ("America/Guatemala", "America/Guatemala"), | ||||
|                     ("America/Guayaquil", "America/Guayaquil"), | ||||
|                     ("America/Guyana", "America/Guyana"), | ||||
|                     ("America/Halifax", "America/Halifax"), | ||||
|                     ("America/Havana", "America/Havana"), | ||||
|                     ("America/Hermosillo", "America/Hermosillo"), | ||||
|                     ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), | ||||
|                     ("America/Indiana/Knox", "America/Indiana/Knox"), | ||||
|                     ("America/Indiana/Marengo", "America/Indiana/Marengo"), | ||||
|                     ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), | ||||
|                     ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), | ||||
|                     ("America/Indiana/Vevay", "America/Indiana/Vevay"), | ||||
|                     ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), | ||||
|                     ("America/Indiana/Winamac", "America/Indiana/Winamac"), | ||||
|                     ("America/Indianapolis", "America/Indianapolis"), | ||||
|                     ("America/Inuvik", "America/Inuvik"), | ||||
|                     ("America/Iqaluit", "America/Iqaluit"), | ||||
|                     ("America/Jamaica", "America/Jamaica"), | ||||
|                     ("America/Jujuy", "America/Jujuy"), | ||||
|                     ("America/Juneau", "America/Juneau"), | ||||
|                     ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), | ||||
|                     ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), | ||||
|                     ("America/Knox_IN", "America/Knox_IN"), | ||||
|                     ("America/Kralendijk", "America/Kralendijk"), | ||||
|                     ("America/La_Paz", "America/La_Paz"), | ||||
|                     ("America/Lima", "America/Lima"), | ||||
|                     ("America/Los_Angeles", "America/Los_Angeles"), | ||||
|                     ("America/Louisville", "America/Louisville"), | ||||
|                     ("America/Lower_Princes", "America/Lower_Princes"), | ||||
|                     ("America/Maceio", "America/Maceio"), | ||||
|                     ("America/Managua", "America/Managua"), | ||||
|                     ("America/Manaus", "America/Manaus"), | ||||
|                     ("America/Marigot", "America/Marigot"), | ||||
|                     ("America/Martinique", "America/Martinique"), | ||||
|                     ("America/Matamoros", "America/Matamoros"), | ||||
|                     ("America/Mazatlan", "America/Mazatlan"), | ||||
|                     ("America/Mendoza", "America/Mendoza"), | ||||
|                     ("America/Menominee", "America/Menominee"), | ||||
|                     ("America/Merida", "America/Merida"), | ||||
|                     ("America/Metlakatla", "America/Metlakatla"), | ||||
|                     ("America/Mexico_City", "America/Mexico_City"), | ||||
|                     ("America/Miquelon", "America/Miquelon"), | ||||
|                     ("America/Moncton", "America/Moncton"), | ||||
|                     ("America/Monterrey", "America/Monterrey"), | ||||
|                     ("America/Montevideo", "America/Montevideo"), | ||||
|                     ("America/Montreal", "America/Montreal"), | ||||
|                     ("America/Montserrat", "America/Montserrat"), | ||||
|                     ("America/Nassau", "America/Nassau"), | ||||
|                     ("America/New_York", "America/New_York"), | ||||
|                     ("America/Nipigon", "America/Nipigon"), | ||||
|                     ("America/Nome", "America/Nome"), | ||||
|                     ("America/Noronha", "America/Noronha"), | ||||
|                     ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), | ||||
|                     ("America/North_Dakota/Center", "America/North_Dakota/Center"), | ||||
|                     ( | ||||
|                         "America/North_Dakota/New_Salem", | ||||
|                         "America/North_Dakota/New_Salem", | ||||
|                     ), | ||||
|                     ("America/Nuuk", "America/Nuuk"), | ||||
|                     ("America/Ojinaga", "America/Ojinaga"), | ||||
|                     ("America/Panama", "America/Panama"), | ||||
|                     ("America/Pangnirtung", "America/Pangnirtung"), | ||||
|                     ("America/Paramaribo", "America/Paramaribo"), | ||||
|                     ("America/Phoenix", "America/Phoenix"), | ||||
|                     ("America/Port-au-Prince", "America/Port-au-Prince"), | ||||
|                     ("America/Port_of_Spain", "America/Port_of_Spain"), | ||||
|                     ("America/Porto_Acre", "America/Porto_Acre"), | ||||
|                     ("America/Porto_Velho", "America/Porto_Velho"), | ||||
|                     ("America/Puerto_Rico", "America/Puerto_Rico"), | ||||
|                     ("America/Punta_Arenas", "America/Punta_Arenas"), | ||||
|                     ("America/Rainy_River", "America/Rainy_River"), | ||||
|                     ("America/Rankin_Inlet", "America/Rankin_Inlet"), | ||||
|                     ("America/Recife", "America/Recife"), | ||||
|                     ("America/Regina", "America/Regina"), | ||||
|                     ("America/Resolute", "America/Resolute"), | ||||
|                     ("America/Rio_Branco", "America/Rio_Branco"), | ||||
|                     ("America/Rosario", "America/Rosario"), | ||||
|                     ("America/Santa_Isabel", "America/Santa_Isabel"), | ||||
|                     ("America/Santarem", "America/Santarem"), | ||||
|                     ("America/Santiago", "America/Santiago"), | ||||
|                     ("America/Santo_Domingo", "America/Santo_Domingo"), | ||||
|                     ("America/Sao_Paulo", "America/Sao_Paulo"), | ||||
|                     ("America/Scoresbysund", "America/Scoresbysund"), | ||||
|                     ("America/Shiprock", "America/Shiprock"), | ||||
|                     ("America/Sitka", "America/Sitka"), | ||||
|                     ("America/St_Barthelemy", "America/St_Barthelemy"), | ||||
|                     ("America/St_Johns", "America/St_Johns"), | ||||
|                     ("America/St_Kitts", "America/St_Kitts"), | ||||
|                     ("America/St_Lucia", "America/St_Lucia"), | ||||
|                     ("America/St_Thomas", "America/St_Thomas"), | ||||
|                     ("America/St_Vincent", "America/St_Vincent"), | ||||
|                     ("America/Swift_Current", "America/Swift_Current"), | ||||
|                     ("America/Tegucigalpa", "America/Tegucigalpa"), | ||||
|                     ("America/Thule", "America/Thule"), | ||||
|                     ("America/Thunder_Bay", "America/Thunder_Bay"), | ||||
|                     ("America/Tijuana", "America/Tijuana"), | ||||
|                     ("America/Toronto", "America/Toronto"), | ||||
|                     ("America/Tortola", "America/Tortola"), | ||||
|                     ("America/Vancouver", "America/Vancouver"), | ||||
|                     ("America/Virgin", "America/Virgin"), | ||||
|                     ("America/Whitehorse", "America/Whitehorse"), | ||||
|                     ("America/Winnipeg", "America/Winnipeg"), | ||||
|                     ("America/Yakutat", "America/Yakutat"), | ||||
|                     ("America/Yellowknife", "America/Yellowknife"), | ||||
|                     ("Antarctica/Casey", "Antarctica/Casey"), | ||||
|                     ("Antarctica/Davis", "Antarctica/Davis"), | ||||
|                     ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), | ||||
|                     ("Antarctica/Macquarie", "Antarctica/Macquarie"), | ||||
|                     ("Antarctica/Mawson", "Antarctica/Mawson"), | ||||
|                     ("Antarctica/McMurdo", "Antarctica/McMurdo"), | ||||
|                     ("Antarctica/Palmer", "Antarctica/Palmer"), | ||||
|                     ("Antarctica/Rothera", "Antarctica/Rothera"), | ||||
|                     ("Antarctica/South_Pole", "Antarctica/South_Pole"), | ||||
|                     ("Antarctica/Syowa", "Antarctica/Syowa"), | ||||
|                     ("Antarctica/Troll", "Antarctica/Troll"), | ||||
|                     ("Antarctica/Vostok", "Antarctica/Vostok"), | ||||
|                     ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), | ||||
|                     ("Asia/Aden", "Asia/Aden"), | ||||
|                     ("Asia/Almaty", "Asia/Almaty"), | ||||
|                     ("Asia/Amman", "Asia/Amman"), | ||||
|                     ("Asia/Anadyr", "Asia/Anadyr"), | ||||
|                     ("Asia/Aqtau", "Asia/Aqtau"), | ||||
|                     ("Asia/Aqtobe", "Asia/Aqtobe"), | ||||
|                     ("Asia/Ashgabat", "Asia/Ashgabat"), | ||||
|                     ("Asia/Ashkhabad", "Asia/Ashkhabad"), | ||||
|                     ("Asia/Atyrau", "Asia/Atyrau"), | ||||
|                     ("Asia/Baghdad", "Asia/Baghdad"), | ||||
|                     ("Asia/Bahrain", "Asia/Bahrain"), | ||||
|                     ("Asia/Baku", "Asia/Baku"), | ||||
|                     ("Asia/Bangkok", "Asia/Bangkok"), | ||||
|                     ("Asia/Barnaul", "Asia/Barnaul"), | ||||
|                     ("Asia/Beirut", "Asia/Beirut"), | ||||
|                     ("Asia/Bishkek", "Asia/Bishkek"), | ||||
|                     ("Asia/Brunei", "Asia/Brunei"), | ||||
|                     ("Asia/Calcutta", "Asia/Calcutta"), | ||||
|                     ("Asia/Chita", "Asia/Chita"), | ||||
|                     ("Asia/Choibalsan", "Asia/Choibalsan"), | ||||
|                     ("Asia/Chongqing", "Asia/Chongqing"), | ||||
|                     ("Asia/Chungking", "Asia/Chungking"), | ||||
|                     ("Asia/Colombo", "Asia/Colombo"), | ||||
|                     ("Asia/Dacca", "Asia/Dacca"), | ||||
|                     ("Asia/Damascus", "Asia/Damascus"), | ||||
|                     ("Asia/Dhaka", "Asia/Dhaka"), | ||||
|                     ("Asia/Dili", "Asia/Dili"), | ||||
|                     ("Asia/Dubai", "Asia/Dubai"), | ||||
|                     ("Asia/Dushanbe", "Asia/Dushanbe"), | ||||
|                     ("Asia/Famagusta", "Asia/Famagusta"), | ||||
|                     ("Asia/Gaza", "Asia/Gaza"), | ||||
|                     ("Asia/Harbin", "Asia/Harbin"), | ||||
|                     ("Asia/Hebron", "Asia/Hebron"), | ||||
|                     ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), | ||||
|                     ("Asia/Hong_Kong", "Asia/Hong_Kong"), | ||||
|                     ("Asia/Hovd", "Asia/Hovd"), | ||||
|                     ("Asia/Irkutsk", "Asia/Irkutsk"), | ||||
|                     ("Asia/Istanbul", "Asia/Istanbul"), | ||||
|                     ("Asia/Jakarta", "Asia/Jakarta"), | ||||
|                     ("Asia/Jayapura", "Asia/Jayapura"), | ||||
|                     ("Asia/Jerusalem", "Asia/Jerusalem"), | ||||
|                     ("Asia/Kabul", "Asia/Kabul"), | ||||
|                     ("Asia/Kamchatka", "Asia/Kamchatka"), | ||||
|                     ("Asia/Karachi", "Asia/Karachi"), | ||||
|                     ("Asia/Kashgar", "Asia/Kashgar"), | ||||
|                     ("Asia/Kathmandu", "Asia/Kathmandu"), | ||||
|                     ("Asia/Katmandu", "Asia/Katmandu"), | ||||
|                     ("Asia/Khandyga", "Asia/Khandyga"), | ||||
|                     ("Asia/Kolkata", "Asia/Kolkata"), | ||||
|                     ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), | ||||
|                     ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), | ||||
|                     ("Asia/Kuching", "Asia/Kuching"), | ||||
|                     ("Asia/Kuwait", "Asia/Kuwait"), | ||||
|                     ("Asia/Macao", "Asia/Macao"), | ||||
|                     ("Asia/Macau", "Asia/Macau"), | ||||
|                     ("Asia/Magadan", "Asia/Magadan"), | ||||
|                     ("Asia/Makassar", "Asia/Makassar"), | ||||
|                     ("Asia/Manila", "Asia/Manila"), | ||||
|                     ("Asia/Muscat", "Asia/Muscat"), | ||||
|                     ("Asia/Nicosia", "Asia/Nicosia"), | ||||
|                     ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), | ||||
|                     ("Asia/Novosibirsk", "Asia/Novosibirsk"), | ||||
|                     ("Asia/Omsk", "Asia/Omsk"), | ||||
|                     ("Asia/Oral", "Asia/Oral"), | ||||
|                     ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), | ||||
|                     ("Asia/Pontianak", "Asia/Pontianak"), | ||||
|                     ("Asia/Pyongyang", "Asia/Pyongyang"), | ||||
|                     ("Asia/Qatar", "Asia/Qatar"), | ||||
|                     ("Asia/Qostanay", "Asia/Qostanay"), | ||||
|                     ("Asia/Qyzylorda", "Asia/Qyzylorda"), | ||||
|                     ("Asia/Rangoon", "Asia/Rangoon"), | ||||
|                     ("Asia/Riyadh", "Asia/Riyadh"), | ||||
|                     ("Asia/Saigon", "Asia/Saigon"), | ||||
|                     ("Asia/Sakhalin", "Asia/Sakhalin"), | ||||
|                     ("Asia/Samarkand", "Asia/Samarkand"), | ||||
|                     ("Asia/Seoul", "Asia/Seoul"), | ||||
|                     ("Asia/Shanghai", "Asia/Shanghai"), | ||||
|                     ("Asia/Singapore", "Asia/Singapore"), | ||||
|                     ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), | ||||
|                     ("Asia/Taipei", "Asia/Taipei"), | ||||
|                     ("Asia/Tashkent", "Asia/Tashkent"), | ||||
|                     ("Asia/Tbilisi", "Asia/Tbilisi"), | ||||
|                     ("Asia/Tehran", "Asia/Tehran"), | ||||
|                     ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), | ||||
|                     ("Asia/Thimbu", "Asia/Thimbu"), | ||||
|                     ("Asia/Thimphu", "Asia/Thimphu"), | ||||
|                     ("Asia/Tokyo", "Asia/Tokyo"), | ||||
|                     ("Asia/Tomsk", "Asia/Tomsk"), | ||||
|                     ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), | ||||
|                     ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), | ||||
|                     ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), | ||||
|                     ("Asia/Urumqi", "Asia/Urumqi"), | ||||
|                     ("Asia/Ust-Nera", "Asia/Ust-Nera"), | ||||
|                     ("Asia/Vientiane", "Asia/Vientiane"), | ||||
|                     ("Asia/Vladivostok", "Asia/Vladivostok"), | ||||
|                     ("Asia/Yakutsk", "Asia/Yakutsk"), | ||||
|                     ("Asia/Yangon", "Asia/Yangon"), | ||||
|                     ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), | ||||
|                     ("Asia/Yerevan", "Asia/Yerevan"), | ||||
|                     ("Atlantic/Azores", "Atlantic/Azores"), | ||||
|                     ("Atlantic/Bermuda", "Atlantic/Bermuda"), | ||||
|                     ("Atlantic/Canary", "Atlantic/Canary"), | ||||
|                     ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), | ||||
|                     ("Atlantic/Faeroe", "Atlantic/Faeroe"), | ||||
|                     ("Atlantic/Faroe", "Atlantic/Faroe"), | ||||
|                     ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), | ||||
|                     ("Atlantic/Madeira", "Atlantic/Madeira"), | ||||
|                     ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), | ||||
|                     ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), | ||||
|                     ("Atlantic/St_Helena", "Atlantic/St_Helena"), | ||||
|                     ("Atlantic/Stanley", "Atlantic/Stanley"), | ||||
|                     ("Australia/ACT", "Australia/ACT"), | ||||
|                     ("Australia/Adelaide", "Australia/Adelaide"), | ||||
|                     ("Australia/Brisbane", "Australia/Brisbane"), | ||||
|                     ("Australia/Broken_Hill", "Australia/Broken_Hill"), | ||||
|                     ("Australia/Canberra", "Australia/Canberra"), | ||||
|                     ("Australia/Currie", "Australia/Currie"), | ||||
|                     ("Australia/Darwin", "Australia/Darwin"), | ||||
|                     ("Australia/Eucla", "Australia/Eucla"), | ||||
|                     ("Australia/Hobart", "Australia/Hobart"), | ||||
|                     ("Australia/LHI", "Australia/LHI"), | ||||
|                     ("Australia/Lindeman", "Australia/Lindeman"), | ||||
|                     ("Australia/Lord_Howe", "Australia/Lord_Howe"), | ||||
|                     ("Australia/Melbourne", "Australia/Melbourne"), | ||||
|                     ("Australia/NSW", "Australia/NSW"), | ||||
|                     ("Australia/North", "Australia/North"), | ||||
|                     ("Australia/Perth", "Australia/Perth"), | ||||
|                     ("Australia/Queensland", "Australia/Queensland"), | ||||
|                     ("Australia/South", "Australia/South"), | ||||
|                     ("Australia/Sydney", "Australia/Sydney"), | ||||
|                     ("Australia/Tasmania", "Australia/Tasmania"), | ||||
|                     ("Australia/Victoria", "Australia/Victoria"), | ||||
|                     ("Australia/West", "Australia/West"), | ||||
|                     ("Australia/Yancowinna", "Australia/Yancowinna"), | ||||
|                     ("Brazil/Acre", "Brazil/Acre"), | ||||
|                     ("Brazil/DeNoronha", "Brazil/DeNoronha"), | ||||
|                     ("Brazil/East", "Brazil/East"), | ||||
|                     ("Brazil/West", "Brazil/West"), | ||||
|                     ("CET", "CET"), | ||||
|                     ("CST6CDT", "CST6CDT"), | ||||
|                     ("Canada/Atlantic", "Canada/Atlantic"), | ||||
|                     ("Canada/Central", "Canada/Central"), | ||||
|                     ("Canada/Eastern", "Canada/Eastern"), | ||||
|                     ("Canada/Mountain", "Canada/Mountain"), | ||||
|                     ("Canada/Newfoundland", "Canada/Newfoundland"), | ||||
|                     ("Canada/Pacific", "Canada/Pacific"), | ||||
|                     ("Canada/Saskatchewan", "Canada/Saskatchewan"), | ||||
|                     ("Canada/Yukon", "Canada/Yukon"), | ||||
|                     ("Chile/Continental", "Chile/Continental"), | ||||
|                     ("Chile/EasterIsland", "Chile/EasterIsland"), | ||||
|                     ("Cuba", "Cuba"), | ||||
|                     ("EET", "EET"), | ||||
|                     ("EST", "EST"), | ||||
|                     ("EST5EDT", "EST5EDT"), | ||||
|                     ("Egypt", "Egypt"), | ||||
|                     ("Eire", "Eire"), | ||||
|                     ("Etc/GMT", "Etc/GMT"), | ||||
|                     ("Etc/GMT+0", "Etc/GMT+0"), | ||||
|                     ("Etc/GMT+1", "Etc/GMT+1"), | ||||
|                     ("Etc/GMT+10", "Etc/GMT+10"), | ||||
|                     ("Etc/GMT+11", "Etc/GMT+11"), | ||||
|                     ("Etc/GMT+12", "Etc/GMT+12"), | ||||
|                     ("Etc/GMT+2", "Etc/GMT+2"), | ||||
|                     ("Etc/GMT+3", "Etc/GMT+3"), | ||||
|                     ("Etc/GMT+4", "Etc/GMT+4"), | ||||
|                     ("Etc/GMT+5", "Etc/GMT+5"), | ||||
|                     ("Etc/GMT+6", "Etc/GMT+6"), | ||||
|                     ("Etc/GMT+7", "Etc/GMT+7"), | ||||
|                     ("Etc/GMT+8", "Etc/GMT+8"), | ||||
|                     ("Etc/GMT+9", "Etc/GMT+9"), | ||||
|                     ("Etc/GMT-0", "Etc/GMT-0"), | ||||
|                     ("Etc/GMT-1", "Etc/GMT-1"), | ||||
|                     ("Etc/GMT-10", "Etc/GMT-10"), | ||||
|                     ("Etc/GMT-11", "Etc/GMT-11"), | ||||
|                     ("Etc/GMT-12", "Etc/GMT-12"), | ||||
|                     ("Etc/GMT-13", "Etc/GMT-13"), | ||||
|                     ("Etc/GMT-14", "Etc/GMT-14"), | ||||
|                     ("Etc/GMT-2", "Etc/GMT-2"), | ||||
|                     ("Etc/GMT-3", "Etc/GMT-3"), | ||||
|                     ("Etc/GMT-4", "Etc/GMT-4"), | ||||
|                     ("Etc/GMT-5", "Etc/GMT-5"), | ||||
|                     ("Etc/GMT-6", "Etc/GMT-6"), | ||||
|                     ("Etc/GMT-7", "Etc/GMT-7"), | ||||
|                     ("Etc/GMT-8", "Etc/GMT-8"), | ||||
|                     ("Etc/GMT-9", "Etc/GMT-9"), | ||||
|                     ("Etc/GMT0", "Etc/GMT0"), | ||||
|                     ("Etc/Greenwich", "Etc/Greenwich"), | ||||
|                     ("Etc/UCT", "Etc/UCT"), | ||||
|                     ("Etc/UTC", "Etc/UTC"), | ||||
|                     ("Etc/Universal", "Etc/Universal"), | ||||
|                     ("Etc/Zulu", "Etc/Zulu"), | ||||
|                     ("Europe/Amsterdam", "Europe/Amsterdam"), | ||||
|                     ("Europe/Andorra", "Europe/Andorra"), | ||||
|                     ("Europe/Astrakhan", "Europe/Astrakhan"), | ||||
|                     ("Europe/Athens", "Europe/Athens"), | ||||
|                     ("Europe/Belfast", "Europe/Belfast"), | ||||
|                     ("Europe/Belgrade", "Europe/Belgrade"), | ||||
|                     ("Europe/Berlin", "Europe/Berlin"), | ||||
|                     ("Europe/Bratislava", "Europe/Bratislava"), | ||||
|                     ("Europe/Brussels", "Europe/Brussels"), | ||||
|                     ("Europe/Bucharest", "Europe/Bucharest"), | ||||
|                     ("Europe/Budapest", "Europe/Budapest"), | ||||
|                     ("Europe/Busingen", "Europe/Busingen"), | ||||
|                     ("Europe/Chisinau", "Europe/Chisinau"), | ||||
|                     ("Europe/Copenhagen", "Europe/Copenhagen"), | ||||
|                     ("Europe/Dublin", "Europe/Dublin"), | ||||
|                     ("Europe/Gibraltar", "Europe/Gibraltar"), | ||||
|                     ("Europe/Guernsey", "Europe/Guernsey"), | ||||
|                     ("Europe/Helsinki", "Europe/Helsinki"), | ||||
|                     ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), | ||||
|                     ("Europe/Istanbul", "Europe/Istanbul"), | ||||
|                     ("Europe/Jersey", "Europe/Jersey"), | ||||
|                     ("Europe/Kaliningrad", "Europe/Kaliningrad"), | ||||
|                     ("Europe/Kiev", "Europe/Kiev"), | ||||
|                     ("Europe/Kirov", "Europe/Kirov"), | ||||
|                     ("Europe/Kyiv", "Europe/Kyiv"), | ||||
|                     ("Europe/Lisbon", "Europe/Lisbon"), | ||||
|                     ("Europe/Ljubljana", "Europe/Ljubljana"), | ||||
|                     ("Europe/London", "Europe/London"), | ||||
|                     ("Europe/Luxembourg", "Europe/Luxembourg"), | ||||
|                     ("Europe/Madrid", "Europe/Madrid"), | ||||
|                     ("Europe/Malta", "Europe/Malta"), | ||||
|                     ("Europe/Mariehamn", "Europe/Mariehamn"), | ||||
|                     ("Europe/Minsk", "Europe/Minsk"), | ||||
|                     ("Europe/Monaco", "Europe/Monaco"), | ||||
|                     ("Europe/Moscow", "Europe/Moscow"), | ||||
|                     ("Europe/Nicosia", "Europe/Nicosia"), | ||||
|                     ("Europe/Oslo", "Europe/Oslo"), | ||||
|                     ("Europe/Paris", "Europe/Paris"), | ||||
|                     ("Europe/Podgorica", "Europe/Podgorica"), | ||||
|                     ("Europe/Prague", "Europe/Prague"), | ||||
|                     ("Europe/Riga", "Europe/Riga"), | ||||
|                     ("Europe/Rome", "Europe/Rome"), | ||||
|                     ("Europe/Samara", "Europe/Samara"), | ||||
|                     ("Europe/San_Marino", "Europe/San_Marino"), | ||||
|                     ("Europe/Sarajevo", "Europe/Sarajevo"), | ||||
|                     ("Europe/Saratov", "Europe/Saratov"), | ||||
|                     ("Europe/Simferopol", "Europe/Simferopol"), | ||||
|                     ("Europe/Skopje", "Europe/Skopje"), | ||||
|                     ("Europe/Sofia", "Europe/Sofia"), | ||||
|                     ("Europe/Stockholm", "Europe/Stockholm"), | ||||
|                     ("Europe/Tallinn", "Europe/Tallinn"), | ||||
|                     ("Europe/Tirane", "Europe/Tirane"), | ||||
|                     ("Europe/Tiraspol", "Europe/Tiraspol"), | ||||
|                     ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), | ||||
|                     ("Europe/Uzhgorod", "Europe/Uzhgorod"), | ||||
|                     ("Europe/Vaduz", "Europe/Vaduz"), | ||||
|                     ("Europe/Vatican", "Europe/Vatican"), | ||||
|                     ("Europe/Vienna", "Europe/Vienna"), | ||||
|                     ("Europe/Vilnius", "Europe/Vilnius"), | ||||
|                     ("Europe/Volgograd", "Europe/Volgograd"), | ||||
|                     ("Europe/Warsaw", "Europe/Warsaw"), | ||||
|                     ("Europe/Zagreb", "Europe/Zagreb"), | ||||
|                     ("Europe/Zaporozhye", "Europe/Zaporozhye"), | ||||
|                     ("Europe/Zurich", "Europe/Zurich"), | ||||
|                     ("GB", "GB"), | ||||
|                     ("GB-Eire", "GB-Eire"), | ||||
|                     ("GMT", "GMT"), | ||||
|                     ("GMT+0", "GMT+0"), | ||||
|                     ("GMT-0", "GMT-0"), | ||||
|                     ("GMT0", "GMT0"), | ||||
|                     ("Greenwich", "Greenwich"), | ||||
|                     ("HST", "HST"), | ||||
|                     ("Hongkong", "Hongkong"), | ||||
|                     ("Iceland", "Iceland"), | ||||
|                     ("Indian/Antananarivo", "Indian/Antananarivo"), | ||||
|                     ("Indian/Chagos", "Indian/Chagos"), | ||||
|                     ("Indian/Christmas", "Indian/Christmas"), | ||||
|                     ("Indian/Cocos", "Indian/Cocos"), | ||||
|                     ("Indian/Comoro", "Indian/Comoro"), | ||||
|                     ("Indian/Kerguelen", "Indian/Kerguelen"), | ||||
|                     ("Indian/Mahe", "Indian/Mahe"), | ||||
|                     ("Indian/Maldives", "Indian/Maldives"), | ||||
|                     ("Indian/Mauritius", "Indian/Mauritius"), | ||||
|                     ("Indian/Mayotte", "Indian/Mayotte"), | ||||
|                     ("Indian/Reunion", "Indian/Reunion"), | ||||
|                     ("Iran", "Iran"), | ||||
|                     ("Israel", "Israel"), | ||||
|                     ("Jamaica", "Jamaica"), | ||||
|                     ("Japan", "Japan"), | ||||
|                     ("Kwajalein", "Kwajalein"), | ||||
|                     ("Libya", "Libya"), | ||||
|                     ("MET", "MET"), | ||||
|                     ("MST", "MST"), | ||||
|                     ("MST7MDT", "MST7MDT"), | ||||
|                     ("Mexico/BajaNorte", "Mexico/BajaNorte"), | ||||
|                     ("Mexico/BajaSur", "Mexico/BajaSur"), | ||||
|                     ("Mexico/General", "Mexico/General"), | ||||
|                     ("NZ", "NZ"), | ||||
|                     ("NZ-CHAT", "NZ-CHAT"), | ||||
|                     ("Navajo", "Navajo"), | ||||
|                     ("PRC", "PRC"), | ||||
|                     ("PST8PDT", "PST8PDT"), | ||||
|                     ("Pacific/Apia", "Pacific/Apia"), | ||||
|                     ("Pacific/Auckland", "Pacific/Auckland"), | ||||
|                     ("Pacific/Bougainville", "Pacific/Bougainville"), | ||||
|                     ("Pacific/Chatham", "Pacific/Chatham"), | ||||
|                     ("Pacific/Chuuk", "Pacific/Chuuk"), | ||||
|                     ("Pacific/Easter", "Pacific/Easter"), | ||||
|                     ("Pacific/Efate", "Pacific/Efate"), | ||||
|                     ("Pacific/Enderbury", "Pacific/Enderbury"), | ||||
|                     ("Pacific/Fakaofo", "Pacific/Fakaofo"), | ||||
|                     ("Pacific/Fiji", "Pacific/Fiji"), | ||||
|                     ("Pacific/Funafuti", "Pacific/Funafuti"), | ||||
|                     ("Pacific/Galapagos", "Pacific/Galapagos"), | ||||
|                     ("Pacific/Gambier", "Pacific/Gambier"), | ||||
|                     ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), | ||||
|                     ("Pacific/Guam", "Pacific/Guam"), | ||||
|                     ("Pacific/Honolulu", "Pacific/Honolulu"), | ||||
|                     ("Pacific/Johnston", "Pacific/Johnston"), | ||||
|                     ("Pacific/Kanton", "Pacific/Kanton"), | ||||
|                     ("Pacific/Kiritimati", "Pacific/Kiritimati"), | ||||
|                     ("Pacific/Kosrae", "Pacific/Kosrae"), | ||||
|                     ("Pacific/Kwajalein", "Pacific/Kwajalein"), | ||||
|                     ("Pacific/Majuro", "Pacific/Majuro"), | ||||
|                     ("Pacific/Marquesas", "Pacific/Marquesas"), | ||||
|                     ("Pacific/Midway", "Pacific/Midway"), | ||||
|                     ("Pacific/Nauru", "Pacific/Nauru"), | ||||
|                     ("Pacific/Niue", "Pacific/Niue"), | ||||
|                     ("Pacific/Norfolk", "Pacific/Norfolk"), | ||||
|                     ("Pacific/Noumea", "Pacific/Noumea"), | ||||
|                     ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), | ||||
|                     ("Pacific/Palau", "Pacific/Palau"), | ||||
|                     ("Pacific/Pitcairn", "Pacific/Pitcairn"), | ||||
|                     ("Pacific/Pohnpei", "Pacific/Pohnpei"), | ||||
|                     ("Pacific/Ponape", "Pacific/Ponape"), | ||||
|                     ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), | ||||
|                     ("Pacific/Rarotonga", "Pacific/Rarotonga"), | ||||
|                     ("Pacific/Saipan", "Pacific/Saipan"), | ||||
|                     ("Pacific/Samoa", "Pacific/Samoa"), | ||||
|                     ("Pacific/Tahiti", "Pacific/Tahiti"), | ||||
|                     ("Pacific/Tarawa", "Pacific/Tarawa"), | ||||
|                     ("Pacific/Tongatapu", "Pacific/Tongatapu"), | ||||
|                     ("Pacific/Truk", "Pacific/Truk"), | ||||
|                     ("Pacific/Wake", "Pacific/Wake"), | ||||
|                     ("Pacific/Wallis", "Pacific/Wallis"), | ||||
|                     ("Pacific/Yap", "Pacific/Yap"), | ||||
|                     ("Poland", "Poland"), | ||||
|                     ("Portugal", "Portugal"), | ||||
|                     ("ROC", "ROC"), | ||||
|                     ("ROK", "ROK"), | ||||
|                     ("Singapore", "Singapore"), | ||||
|                     ("Turkey", "Turkey"), | ||||
|                     ("UCT", "UCT"), | ||||
|                     ("US/Alaska", "US/Alaska"), | ||||
|                     ("US/Aleutian", "US/Aleutian"), | ||||
|                     ("US/Arizona", "US/Arizona"), | ||||
|                     ("US/Central", "US/Central"), | ||||
|                     ("US/East-Indiana", "US/East-Indiana"), | ||||
|                     ("US/Eastern", "US/Eastern"), | ||||
|                     ("US/Hawaii", "US/Hawaii"), | ||||
|                     ("US/Indiana-Starke", "US/Indiana-Starke"), | ||||
|                     ("US/Michigan", "US/Michigan"), | ||||
|                     ("US/Mountain", "US/Mountain"), | ||||
|                     ("US/Pacific", "US/Pacific"), | ||||
|                     ("US/Samoa", "US/Samoa"), | ||||
|                     ("UTC", "UTC"), | ||||
|                     ("Universal", "Universal"), | ||||
|                     ("W-SU", "W-SU"), | ||||
|                     ("WET", "WET"), | ||||
|                     ("Zulu", "Zulu"), | ||||
|                 ], | ||||
|                 max_length=255, | ||||
|                 null=True, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,17 @@ | ||||
| # Generated by Django 4.2.3 on 2023-07-18 01:15 | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("core", "0037_coresettings_open_ai_model_and_more"), | ||||
|         ("agents", "0056_alter_agent_time_zone"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterUniqueTogether( | ||||
|             name="agentcustomfield", | ||||
|             unique_together={("agent", "field")}, | ||||
|         ), | ||||
|     ] | ||||
							
								
								
									
										633
									
								
								api/tacticalrmm/agents/migrations/0058_alter_agent_time_zone.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										633
									
								
								api/tacticalrmm/agents/migrations/0058_alter_agent_time_zone.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,633 @@ | ||||
| # Generated by Django 4.2.7 on 2023-11-09 19:56 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|     dependencies = [ | ||||
|         ("agents", "0057_alter_agentcustomfield_unique_together"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="agent", | ||||
|             name="time_zone", | ||||
|             field=models.CharField( | ||||
|                 blank=True, | ||||
|                 choices=[ | ||||
|                     ("Africa/Abidjan", "Africa/Abidjan"), | ||||
|                     ("Africa/Accra", "Africa/Accra"), | ||||
|                     ("Africa/Addis_Ababa", "Africa/Addis_Ababa"), | ||||
|                     ("Africa/Algiers", "Africa/Algiers"), | ||||
|                     ("Africa/Asmara", "Africa/Asmara"), | ||||
|                     ("Africa/Asmera", "Africa/Asmera"), | ||||
|                     ("Africa/Bamako", "Africa/Bamako"), | ||||
|                     ("Africa/Bangui", "Africa/Bangui"), | ||||
|                     ("Africa/Banjul", "Africa/Banjul"), | ||||
|                     ("Africa/Bissau", "Africa/Bissau"), | ||||
|                     ("Africa/Blantyre", "Africa/Blantyre"), | ||||
|                     ("Africa/Brazzaville", "Africa/Brazzaville"), | ||||
|                     ("Africa/Bujumbura", "Africa/Bujumbura"), | ||||
|                     ("Africa/Cairo", "Africa/Cairo"), | ||||
|                     ("Africa/Casablanca", "Africa/Casablanca"), | ||||
|                     ("Africa/Ceuta", "Africa/Ceuta"), | ||||
|                     ("Africa/Conakry", "Africa/Conakry"), | ||||
|                     ("Africa/Dakar", "Africa/Dakar"), | ||||
|                     ("Africa/Dar_es_Salaam", "Africa/Dar_es_Salaam"), | ||||
|                     ("Africa/Djibouti", "Africa/Djibouti"), | ||||
|                     ("Africa/Douala", "Africa/Douala"), | ||||
|                     ("Africa/El_Aaiun", "Africa/El_Aaiun"), | ||||
|                     ("Africa/Freetown", "Africa/Freetown"), | ||||
|                     ("Africa/Gaborone", "Africa/Gaborone"), | ||||
|                     ("Africa/Harare", "Africa/Harare"), | ||||
|                     ("Africa/Johannesburg", "Africa/Johannesburg"), | ||||
|                     ("Africa/Juba", "Africa/Juba"), | ||||
|                     ("Africa/Kampala", "Africa/Kampala"), | ||||
|                     ("Africa/Khartoum", "Africa/Khartoum"), | ||||
|                     ("Africa/Kigali", "Africa/Kigali"), | ||||
|                     ("Africa/Kinshasa", "Africa/Kinshasa"), | ||||
|                     ("Africa/Lagos", "Africa/Lagos"), | ||||
|                     ("Africa/Libreville", "Africa/Libreville"), | ||||
|                     ("Africa/Lome", "Africa/Lome"), | ||||
|                     ("Africa/Luanda", "Africa/Luanda"), | ||||
|                     ("Africa/Lubumbashi", "Africa/Lubumbashi"), | ||||
|                     ("Africa/Lusaka", "Africa/Lusaka"), | ||||
|                     ("Africa/Malabo", "Africa/Malabo"), | ||||
|                     ("Africa/Maputo", "Africa/Maputo"), | ||||
|                     ("Africa/Maseru", "Africa/Maseru"), | ||||
|                     ("Africa/Mbabane", "Africa/Mbabane"), | ||||
|                     ("Africa/Mogadishu", "Africa/Mogadishu"), | ||||
|                     ("Africa/Monrovia", "Africa/Monrovia"), | ||||
|                     ("Africa/Nairobi", "Africa/Nairobi"), | ||||
|                     ("Africa/Ndjamena", "Africa/Ndjamena"), | ||||
|                     ("Africa/Niamey", "Africa/Niamey"), | ||||
|                     ("Africa/Nouakchott", "Africa/Nouakchott"), | ||||
|                     ("Africa/Ouagadougou", "Africa/Ouagadougou"), | ||||
|                     ("Africa/Porto-Novo", "Africa/Porto-Novo"), | ||||
|                     ("Africa/Sao_Tome", "Africa/Sao_Tome"), | ||||
|                     ("Africa/Timbuktu", "Africa/Timbuktu"), | ||||
|                     ("Africa/Tripoli", "Africa/Tripoli"), | ||||
|                     ("Africa/Tunis", "Africa/Tunis"), | ||||
|                     ("Africa/Windhoek", "Africa/Windhoek"), | ||||
|                     ("America/Adak", "America/Adak"), | ||||
|                     ("America/Anchorage", "America/Anchorage"), | ||||
|                     ("America/Anguilla", "America/Anguilla"), | ||||
|                     ("America/Antigua", "America/Antigua"), | ||||
|                     ("America/Araguaina", "America/Araguaina"), | ||||
|                     ( | ||||
|                         "America/Argentina/Buenos_Aires", | ||||
|                         "America/Argentina/Buenos_Aires", | ||||
|                     ), | ||||
|                     ("America/Argentina/Catamarca", "America/Argentina/Catamarca"), | ||||
|                     ( | ||||
|                         "America/Argentina/ComodRivadavia", | ||||
|                         "America/Argentina/ComodRivadavia", | ||||
|                     ), | ||||
|                     ("America/Argentina/Cordoba", "America/Argentina/Cordoba"), | ||||
|                     ("America/Argentina/Jujuy", "America/Argentina/Jujuy"), | ||||
|                     ("America/Argentina/La_Rioja", "America/Argentina/La_Rioja"), | ||||
|                     ("America/Argentina/Mendoza", "America/Argentina/Mendoza"), | ||||
|                     ( | ||||
|                         "America/Argentina/Rio_Gallegos", | ||||
|                         "America/Argentina/Rio_Gallegos", | ||||
|                     ), | ||||
|                     ("America/Argentina/Salta", "America/Argentina/Salta"), | ||||
|                     ("America/Argentina/San_Juan", "America/Argentina/San_Juan"), | ||||
|                     ("America/Argentina/San_Luis", "America/Argentina/San_Luis"), | ||||
|                     ("America/Argentina/Tucuman", "America/Argentina/Tucuman"), | ||||
|                     ("America/Argentina/Ushuaia", "America/Argentina/Ushuaia"), | ||||
|                     ("America/Aruba", "America/Aruba"), | ||||
|                     ("America/Asuncion", "America/Asuncion"), | ||||
|                     ("America/Atikokan", "America/Atikokan"), | ||||
|                     ("America/Atka", "America/Atka"), | ||||
|                     ("America/Bahia", "America/Bahia"), | ||||
|                     ("America/Bahia_Banderas", "America/Bahia_Banderas"), | ||||
|                     ("America/Barbados", "America/Barbados"), | ||||
|                     ("America/Belem", "America/Belem"), | ||||
|                     ("America/Belize", "America/Belize"), | ||||
|                     ("America/Blanc-Sablon", "America/Blanc-Sablon"), | ||||
|                     ("America/Boa_Vista", "America/Boa_Vista"), | ||||
|                     ("America/Bogota", "America/Bogota"), | ||||
|                     ("America/Boise", "America/Boise"), | ||||
|                     ("America/Buenos_Aires", "America/Buenos_Aires"), | ||||
|                     ("America/Cambridge_Bay", "America/Cambridge_Bay"), | ||||
|                     ("America/Campo_Grande", "America/Campo_Grande"), | ||||
|                     ("America/Cancun", "America/Cancun"), | ||||
|                     ("America/Caracas", "America/Caracas"), | ||||
|                     ("America/Catamarca", "America/Catamarca"), | ||||
|                     ("America/Cayenne", "America/Cayenne"), | ||||
|                     ("America/Cayman", "America/Cayman"), | ||||
|                     ("America/Chicago", "America/Chicago"), | ||||
|                     ("America/Chihuahua", "America/Chihuahua"), | ||||
|                     ("America/Ciudad_Juarez", "America/Ciudad_Juarez"), | ||||
|                     ("America/Coral_Harbour", "America/Coral_Harbour"), | ||||
|                     ("America/Cordoba", "America/Cordoba"), | ||||
|                     ("America/Costa_Rica", "America/Costa_Rica"), | ||||
|                     ("America/Creston", "America/Creston"), | ||||
|                     ("America/Cuiaba", "America/Cuiaba"), | ||||
|                     ("America/Curacao", "America/Curacao"), | ||||
|                     ("America/Danmarkshavn", "America/Danmarkshavn"), | ||||
|                     ("America/Dawson", "America/Dawson"), | ||||
|                     ("America/Dawson_Creek", "America/Dawson_Creek"), | ||||
|                     ("America/Denver", "America/Denver"), | ||||
|                     ("America/Detroit", "America/Detroit"), | ||||
|                     ("America/Dominica", "America/Dominica"), | ||||
|                     ("America/Edmonton", "America/Edmonton"), | ||||
|                     ("America/Eirunepe", "America/Eirunepe"), | ||||
|                     ("America/El_Salvador", "America/El_Salvador"), | ||||
|                     ("America/Ensenada", "America/Ensenada"), | ||||
|                     ("America/Fort_Nelson", "America/Fort_Nelson"), | ||||
|                     ("America/Fort_Wayne", "America/Fort_Wayne"), | ||||
|                     ("America/Fortaleza", "America/Fortaleza"), | ||||
|                     ("America/Glace_Bay", "America/Glace_Bay"), | ||||
|                     ("America/Godthab", "America/Godthab"), | ||||
|                     ("America/Goose_Bay", "America/Goose_Bay"), | ||||
|                     ("America/Grand_Turk", "America/Grand_Turk"), | ||||
|                     ("America/Grenada", "America/Grenada"), | ||||
|                     ("America/Guadeloupe", "America/Guadeloupe"), | ||||
|                     ("America/Guatemala", "America/Guatemala"), | ||||
|                     ("America/Guayaquil", "America/Guayaquil"), | ||||
|                     ("America/Guyana", "America/Guyana"), | ||||
|                     ("America/Halifax", "America/Halifax"), | ||||
|                     ("America/Havana", "America/Havana"), | ||||
|                     ("America/Hermosillo", "America/Hermosillo"), | ||||
|                     ("America/Indiana/Indianapolis", "America/Indiana/Indianapolis"), | ||||
|                     ("America/Indiana/Knox", "America/Indiana/Knox"), | ||||
|                     ("America/Indiana/Marengo", "America/Indiana/Marengo"), | ||||
|                     ("America/Indiana/Petersburg", "America/Indiana/Petersburg"), | ||||
|                     ("America/Indiana/Tell_City", "America/Indiana/Tell_City"), | ||||
|                     ("America/Indiana/Vevay", "America/Indiana/Vevay"), | ||||
|                     ("America/Indiana/Vincennes", "America/Indiana/Vincennes"), | ||||
|                     ("America/Indiana/Winamac", "America/Indiana/Winamac"), | ||||
|                     ("America/Indianapolis", "America/Indianapolis"), | ||||
|                     ("America/Inuvik", "America/Inuvik"), | ||||
|                     ("America/Iqaluit", "America/Iqaluit"), | ||||
|                     ("America/Jamaica", "America/Jamaica"), | ||||
|                     ("America/Jujuy", "America/Jujuy"), | ||||
|                     ("America/Juneau", "America/Juneau"), | ||||
|                     ("America/Kentucky/Louisville", "America/Kentucky/Louisville"), | ||||
|                     ("America/Kentucky/Monticello", "America/Kentucky/Monticello"), | ||||
|                     ("America/Knox_IN", "America/Knox_IN"), | ||||
|                     ("America/Kralendijk", "America/Kralendijk"), | ||||
|                     ("America/La_Paz", "America/La_Paz"), | ||||
|                     ("America/Lima", "America/Lima"), | ||||
|                     ("America/Los_Angeles", "America/Los_Angeles"), | ||||
|                     ("America/Louisville", "America/Louisville"), | ||||
|                     ("America/Lower_Princes", "America/Lower_Princes"), | ||||
|                     ("America/Maceio", "America/Maceio"), | ||||
|                     ("America/Managua", "America/Managua"), | ||||
|                     ("America/Manaus", "America/Manaus"), | ||||
|                     ("America/Marigot", "America/Marigot"), | ||||
|                     ("America/Martinique", "America/Martinique"), | ||||
|                     ("America/Matamoros", "America/Matamoros"), | ||||
|                     ("America/Mazatlan", "America/Mazatlan"), | ||||
|                     ("America/Mendoza", "America/Mendoza"), | ||||
|                     ("America/Menominee", "America/Menominee"), | ||||
|                     ("America/Merida", "America/Merida"), | ||||
|                     ("America/Metlakatla", "America/Metlakatla"), | ||||
|                     ("America/Mexico_City", "America/Mexico_City"), | ||||
|                     ("America/Miquelon", "America/Miquelon"), | ||||
|                     ("America/Moncton", "America/Moncton"), | ||||
|                     ("America/Monterrey", "America/Monterrey"), | ||||
|                     ("America/Montevideo", "America/Montevideo"), | ||||
|                     ("America/Montreal", "America/Montreal"), | ||||
|                     ("America/Montserrat", "America/Montserrat"), | ||||
|                     ("America/Nassau", "America/Nassau"), | ||||
|                     ("America/New_York", "America/New_York"), | ||||
|                     ("America/Nipigon", "America/Nipigon"), | ||||
|                     ("America/Nome", "America/Nome"), | ||||
|                     ("America/Noronha", "America/Noronha"), | ||||
|                     ("America/North_Dakota/Beulah", "America/North_Dakota/Beulah"), | ||||
|                     ("America/North_Dakota/Center", "America/North_Dakota/Center"), | ||||
|                     ( | ||||
|                         "America/North_Dakota/New_Salem", | ||||
|                         "America/North_Dakota/New_Salem", | ||||
|                     ), | ||||
|                     ("America/Nuuk", "America/Nuuk"), | ||||
|                     ("America/Ojinaga", "America/Ojinaga"), | ||||
|                     ("America/Panama", "America/Panama"), | ||||
|                     ("America/Pangnirtung", "America/Pangnirtung"), | ||||
|                     ("America/Paramaribo", "America/Paramaribo"), | ||||
|                     ("America/Phoenix", "America/Phoenix"), | ||||
|                     ("America/Port-au-Prince", "America/Port-au-Prince"), | ||||
|                     ("America/Port_of_Spain", "America/Port_of_Spain"), | ||||
|                     ("America/Porto_Acre", "America/Porto_Acre"), | ||||
|                     ("America/Porto_Velho", "America/Porto_Velho"), | ||||
|                     ("America/Puerto_Rico", "America/Puerto_Rico"), | ||||
|                     ("America/Punta_Arenas", "America/Punta_Arenas"), | ||||
|                     ("America/Rainy_River", "America/Rainy_River"), | ||||
|                     ("America/Rankin_Inlet", "America/Rankin_Inlet"), | ||||
|                     ("America/Recife", "America/Recife"), | ||||
|                     ("America/Regina", "America/Regina"), | ||||
|                     ("America/Resolute", "America/Resolute"), | ||||
|                     ("America/Rio_Branco", "America/Rio_Branco"), | ||||
|                     ("America/Rosario", "America/Rosario"), | ||||
|                     ("America/Santa_Isabel", "America/Santa_Isabel"), | ||||
|                     ("America/Santarem", "America/Santarem"), | ||||
|                     ("America/Santiago", "America/Santiago"), | ||||
|                     ("America/Santo_Domingo", "America/Santo_Domingo"), | ||||
|                     ("America/Sao_Paulo", "America/Sao_Paulo"), | ||||
|                     ("America/Scoresbysund", "America/Scoresbysund"), | ||||
|                     ("America/Shiprock", "America/Shiprock"), | ||||
|                     ("America/Sitka", "America/Sitka"), | ||||
|                     ("America/St_Barthelemy", "America/St_Barthelemy"), | ||||
|                     ("America/St_Johns", "America/St_Johns"), | ||||
|                     ("America/St_Kitts", "America/St_Kitts"), | ||||
|                     ("America/St_Lucia", "America/St_Lucia"), | ||||
|                     ("America/St_Thomas", "America/St_Thomas"), | ||||
|                     ("America/St_Vincent", "America/St_Vincent"), | ||||
|                     ("America/Swift_Current", "America/Swift_Current"), | ||||
|                     ("America/Tegucigalpa", "America/Tegucigalpa"), | ||||
|                     ("America/Thule", "America/Thule"), | ||||
|                     ("America/Thunder_Bay", "America/Thunder_Bay"), | ||||
|                     ("America/Tijuana", "America/Tijuana"), | ||||
|                     ("America/Toronto", "America/Toronto"), | ||||
|                     ("America/Tortola", "America/Tortola"), | ||||
|                     ("America/Vancouver", "America/Vancouver"), | ||||
|                     ("America/Virgin", "America/Virgin"), | ||||
|                     ("America/Whitehorse", "America/Whitehorse"), | ||||
|                     ("America/Winnipeg", "America/Winnipeg"), | ||||
|                     ("America/Yakutat", "America/Yakutat"), | ||||
|                     ("America/Yellowknife", "America/Yellowknife"), | ||||
|                     ("Antarctica/Casey", "Antarctica/Casey"), | ||||
|                     ("Antarctica/Davis", "Antarctica/Davis"), | ||||
|                     ("Antarctica/DumontDUrville", "Antarctica/DumontDUrville"), | ||||
|                     ("Antarctica/Macquarie", "Antarctica/Macquarie"), | ||||
|                     ("Antarctica/Mawson", "Antarctica/Mawson"), | ||||
|                     ("Antarctica/McMurdo", "Antarctica/McMurdo"), | ||||
|                     ("Antarctica/Palmer", "Antarctica/Palmer"), | ||||
|                     ("Antarctica/Rothera", "Antarctica/Rothera"), | ||||
|                     ("Antarctica/South_Pole", "Antarctica/South_Pole"), | ||||
|                     ("Antarctica/Syowa", "Antarctica/Syowa"), | ||||
|                     ("Antarctica/Troll", "Antarctica/Troll"), | ||||
|                     ("Antarctica/Vostok", "Antarctica/Vostok"), | ||||
|                     ("Arctic/Longyearbyen", "Arctic/Longyearbyen"), | ||||
|                     ("Asia/Aden", "Asia/Aden"), | ||||
|                     ("Asia/Almaty", "Asia/Almaty"), | ||||
|                     ("Asia/Amman", "Asia/Amman"), | ||||
|                     ("Asia/Anadyr", "Asia/Anadyr"), | ||||
|                     ("Asia/Aqtau", "Asia/Aqtau"), | ||||
|                     ("Asia/Aqtobe", "Asia/Aqtobe"), | ||||
|                     ("Asia/Ashgabat", "Asia/Ashgabat"), | ||||
|                     ("Asia/Ashkhabad", "Asia/Ashkhabad"), | ||||
|                     ("Asia/Atyrau", "Asia/Atyrau"), | ||||
|                     ("Asia/Baghdad", "Asia/Baghdad"), | ||||
|                     ("Asia/Bahrain", "Asia/Bahrain"), | ||||
|                     ("Asia/Baku", "Asia/Baku"), | ||||
|                     ("Asia/Bangkok", "Asia/Bangkok"), | ||||
|                     ("Asia/Barnaul", "Asia/Barnaul"), | ||||
|                     ("Asia/Beirut", "Asia/Beirut"), | ||||
|                     ("Asia/Bishkek", "Asia/Bishkek"), | ||||
|                     ("Asia/Brunei", "Asia/Brunei"), | ||||
|                     ("Asia/Calcutta", "Asia/Calcutta"), | ||||
|                     ("Asia/Chita", "Asia/Chita"), | ||||
|                     ("Asia/Choibalsan", "Asia/Choibalsan"), | ||||
|                     ("Asia/Chongqing", "Asia/Chongqing"), | ||||
|                     ("Asia/Chungking", "Asia/Chungking"), | ||||
|                     ("Asia/Colombo", "Asia/Colombo"), | ||||
|                     ("Asia/Dacca", "Asia/Dacca"), | ||||
|                     ("Asia/Damascus", "Asia/Damascus"), | ||||
|                     ("Asia/Dhaka", "Asia/Dhaka"), | ||||
|                     ("Asia/Dili", "Asia/Dili"), | ||||
|                     ("Asia/Dubai", "Asia/Dubai"), | ||||
|                     ("Asia/Dushanbe", "Asia/Dushanbe"), | ||||
|                     ("Asia/Famagusta", "Asia/Famagusta"), | ||||
|                     ("Asia/Gaza", "Asia/Gaza"), | ||||
|                     ("Asia/Harbin", "Asia/Harbin"), | ||||
|                     ("Asia/Hebron", "Asia/Hebron"), | ||||
|                     ("Asia/Ho_Chi_Minh", "Asia/Ho_Chi_Minh"), | ||||
|                     ("Asia/Hong_Kong", "Asia/Hong_Kong"), | ||||
|                     ("Asia/Hovd", "Asia/Hovd"), | ||||
|                     ("Asia/Irkutsk", "Asia/Irkutsk"), | ||||
|                     ("Asia/Istanbul", "Asia/Istanbul"), | ||||
|                     ("Asia/Jakarta", "Asia/Jakarta"), | ||||
|                     ("Asia/Jayapura", "Asia/Jayapura"), | ||||
|                     ("Asia/Jerusalem", "Asia/Jerusalem"), | ||||
|                     ("Asia/Kabul", "Asia/Kabul"), | ||||
|                     ("Asia/Kamchatka", "Asia/Kamchatka"), | ||||
|                     ("Asia/Karachi", "Asia/Karachi"), | ||||
|                     ("Asia/Kashgar", "Asia/Kashgar"), | ||||
|                     ("Asia/Kathmandu", "Asia/Kathmandu"), | ||||
|                     ("Asia/Katmandu", "Asia/Katmandu"), | ||||
|                     ("Asia/Khandyga", "Asia/Khandyga"), | ||||
|                     ("Asia/Kolkata", "Asia/Kolkata"), | ||||
|                     ("Asia/Krasnoyarsk", "Asia/Krasnoyarsk"), | ||||
|                     ("Asia/Kuala_Lumpur", "Asia/Kuala_Lumpur"), | ||||
|                     ("Asia/Kuching", "Asia/Kuching"), | ||||
|                     ("Asia/Kuwait", "Asia/Kuwait"), | ||||
|                     ("Asia/Macao", "Asia/Macao"), | ||||
|                     ("Asia/Macau", "Asia/Macau"), | ||||
|                     ("Asia/Magadan", "Asia/Magadan"), | ||||
|                     ("Asia/Makassar", "Asia/Makassar"), | ||||
|                     ("Asia/Manila", "Asia/Manila"), | ||||
|                     ("Asia/Muscat", "Asia/Muscat"), | ||||
|                     ("Asia/Nicosia", "Asia/Nicosia"), | ||||
|                     ("Asia/Novokuznetsk", "Asia/Novokuznetsk"), | ||||
|                     ("Asia/Novosibirsk", "Asia/Novosibirsk"), | ||||
|                     ("Asia/Omsk", "Asia/Omsk"), | ||||
|                     ("Asia/Oral", "Asia/Oral"), | ||||
|                     ("Asia/Phnom_Penh", "Asia/Phnom_Penh"), | ||||
|                     ("Asia/Pontianak", "Asia/Pontianak"), | ||||
|                     ("Asia/Pyongyang", "Asia/Pyongyang"), | ||||
|                     ("Asia/Qatar", "Asia/Qatar"), | ||||
|                     ("Asia/Qostanay", "Asia/Qostanay"), | ||||
|                     ("Asia/Qyzylorda", "Asia/Qyzylorda"), | ||||
|                     ("Asia/Rangoon", "Asia/Rangoon"), | ||||
|                     ("Asia/Riyadh", "Asia/Riyadh"), | ||||
|                     ("Asia/Saigon", "Asia/Saigon"), | ||||
|                     ("Asia/Sakhalin", "Asia/Sakhalin"), | ||||
|                     ("Asia/Samarkand", "Asia/Samarkand"), | ||||
|                     ("Asia/Seoul", "Asia/Seoul"), | ||||
|                     ("Asia/Shanghai", "Asia/Shanghai"), | ||||
|                     ("Asia/Singapore", "Asia/Singapore"), | ||||
|                     ("Asia/Srednekolymsk", "Asia/Srednekolymsk"), | ||||
|                     ("Asia/Taipei", "Asia/Taipei"), | ||||
|                     ("Asia/Tashkent", "Asia/Tashkent"), | ||||
|                     ("Asia/Tbilisi", "Asia/Tbilisi"), | ||||
|                     ("Asia/Tehran", "Asia/Tehran"), | ||||
|                     ("Asia/Tel_Aviv", "Asia/Tel_Aviv"), | ||||
|                     ("Asia/Thimbu", "Asia/Thimbu"), | ||||
|                     ("Asia/Thimphu", "Asia/Thimphu"), | ||||
|                     ("Asia/Tokyo", "Asia/Tokyo"), | ||||
|                     ("Asia/Tomsk", "Asia/Tomsk"), | ||||
|                     ("Asia/Ujung_Pandang", "Asia/Ujung_Pandang"), | ||||
|                     ("Asia/Ulaanbaatar", "Asia/Ulaanbaatar"), | ||||
|                     ("Asia/Ulan_Bator", "Asia/Ulan_Bator"), | ||||
|                     ("Asia/Urumqi", "Asia/Urumqi"), | ||||
|                     ("Asia/Ust-Nera", "Asia/Ust-Nera"), | ||||
|                     ("Asia/Vientiane", "Asia/Vientiane"), | ||||
|                     ("Asia/Vladivostok", "Asia/Vladivostok"), | ||||
|                     ("Asia/Yakutsk", "Asia/Yakutsk"), | ||||
|                     ("Asia/Yangon", "Asia/Yangon"), | ||||
|                     ("Asia/Yekaterinburg", "Asia/Yekaterinburg"), | ||||
|                     ("Asia/Yerevan", "Asia/Yerevan"), | ||||
|                     ("Atlantic/Azores", "Atlantic/Azores"), | ||||
|                     ("Atlantic/Bermuda", "Atlantic/Bermuda"), | ||||
|                     ("Atlantic/Canary", "Atlantic/Canary"), | ||||
|                     ("Atlantic/Cape_Verde", "Atlantic/Cape_Verde"), | ||||
|                     ("Atlantic/Faeroe", "Atlantic/Faeroe"), | ||||
|                     ("Atlantic/Faroe", "Atlantic/Faroe"), | ||||
|                     ("Atlantic/Jan_Mayen", "Atlantic/Jan_Mayen"), | ||||
|                     ("Atlantic/Madeira", "Atlantic/Madeira"), | ||||
|                     ("Atlantic/Reykjavik", "Atlantic/Reykjavik"), | ||||
|                     ("Atlantic/South_Georgia", "Atlantic/South_Georgia"), | ||||
|                     ("Atlantic/St_Helena", "Atlantic/St_Helena"), | ||||
|                     ("Atlantic/Stanley", "Atlantic/Stanley"), | ||||
|                     ("Australia/ACT", "Australia/ACT"), | ||||
|                     ("Australia/Adelaide", "Australia/Adelaide"), | ||||
|                     ("Australia/Brisbane", "Australia/Brisbane"), | ||||
|                     ("Australia/Broken_Hill", "Australia/Broken_Hill"), | ||||
|                     ("Australia/Canberra", "Australia/Canberra"), | ||||
|                     ("Australia/Currie", "Australia/Currie"), | ||||
|                     ("Australia/Darwin", "Australia/Darwin"), | ||||
|                     ("Australia/Eucla", "Australia/Eucla"), | ||||
|                     ("Australia/Hobart", "Australia/Hobart"), | ||||
|                     ("Australia/LHI", "Australia/LHI"), | ||||
|                     ("Australia/Lindeman", "Australia/Lindeman"), | ||||
|                     ("Australia/Lord_Howe", "Australia/Lord_Howe"), | ||||
|                     ("Australia/Melbourne", "Australia/Melbourne"), | ||||
|                     ("Australia/NSW", "Australia/NSW"), | ||||
|                     ("Australia/North", "Australia/North"), | ||||
|                     ("Australia/Perth", "Australia/Perth"), | ||||
|                     ("Australia/Queensland", "Australia/Queensland"), | ||||
|                     ("Australia/South", "Australia/South"), | ||||
|                     ("Australia/Sydney", "Australia/Sydney"), | ||||
|                     ("Australia/Tasmania", "Australia/Tasmania"), | ||||
|                     ("Australia/Victoria", "Australia/Victoria"), | ||||
|                     ("Australia/West", "Australia/West"), | ||||
|                     ("Australia/Yancowinna", "Australia/Yancowinna"), | ||||
|                     ("Brazil/Acre", "Brazil/Acre"), | ||||
|                     ("Brazil/DeNoronha", "Brazil/DeNoronha"), | ||||
|                     ("Brazil/East", "Brazil/East"), | ||||
|                     ("Brazil/West", "Brazil/West"), | ||||
|                     ("CET", "CET"), | ||||
|                     ("CST6CDT", "CST6CDT"), | ||||
|                     ("Canada/Atlantic", "Canada/Atlantic"), | ||||
|                     ("Canada/Central", "Canada/Central"), | ||||
|                     ("Canada/Eastern", "Canada/Eastern"), | ||||
|                     ("Canada/Mountain", "Canada/Mountain"), | ||||
|                     ("Canada/Newfoundland", "Canada/Newfoundland"), | ||||
|                     ("Canada/Pacific", "Canada/Pacific"), | ||||
|                     ("Canada/Saskatchewan", "Canada/Saskatchewan"), | ||||
|                     ("Canada/Yukon", "Canada/Yukon"), | ||||
|                     ("Chile/Continental", "Chile/Continental"), | ||||
|                     ("Chile/EasterIsland", "Chile/EasterIsland"), | ||||
|                     ("Cuba", "Cuba"), | ||||
|                     ("EET", "EET"), | ||||
|                     ("EST", "EST"), | ||||
|                     ("EST5EDT", "EST5EDT"), | ||||
|                     ("Egypt", "Egypt"), | ||||
|                     ("Eire", "Eire"), | ||||
|                     ("Etc/GMT", "Etc/GMT"), | ||||
|                     ("Etc/GMT+0", "Etc/GMT+0"), | ||||
|                     ("Etc/GMT+1", "Etc/GMT+1"), | ||||
|                     ("Etc/GMT+10", "Etc/GMT+10"), | ||||
|                     ("Etc/GMT+11", "Etc/GMT+11"), | ||||
|                     ("Etc/GMT+12", "Etc/GMT+12"), | ||||
|                     ("Etc/GMT+2", "Etc/GMT+2"), | ||||
|                     ("Etc/GMT+3", "Etc/GMT+3"), | ||||
|                     ("Etc/GMT+4", "Etc/GMT+4"), | ||||
|                     ("Etc/GMT+5", "Etc/GMT+5"), | ||||
|                     ("Etc/GMT+6", "Etc/GMT+6"), | ||||
|                     ("Etc/GMT+7", "Etc/GMT+7"), | ||||
|                     ("Etc/GMT+8", "Etc/GMT+8"), | ||||
|                     ("Etc/GMT+9", "Etc/GMT+9"), | ||||
|                     ("Etc/GMT-0", "Etc/GMT-0"), | ||||
|                     ("Etc/GMT-1", "Etc/GMT-1"), | ||||
|                     ("Etc/GMT-10", "Etc/GMT-10"), | ||||
|                     ("Etc/GMT-11", "Etc/GMT-11"), | ||||
|                     ("Etc/GMT-12", "Etc/GMT-12"), | ||||
|                     ("Etc/GMT-13", "Etc/GMT-13"), | ||||
|                     ("Etc/GMT-14", "Etc/GMT-14"), | ||||
|                     ("Etc/GMT-2", "Etc/GMT-2"), | ||||
|                     ("Etc/GMT-3", "Etc/GMT-3"), | ||||
|                     ("Etc/GMT-4", "Etc/GMT-4"), | ||||
|                     ("Etc/GMT-5", "Etc/GMT-5"), | ||||
|                     ("Etc/GMT-6", "Etc/GMT-6"), | ||||
|                     ("Etc/GMT-7", "Etc/GMT-7"), | ||||
|                     ("Etc/GMT-8", "Etc/GMT-8"), | ||||
|                     ("Etc/GMT-9", "Etc/GMT-9"), | ||||
|                     ("Etc/GMT0", "Etc/GMT0"), | ||||
|                     ("Etc/Greenwich", "Etc/Greenwich"), | ||||
|                     ("Etc/UCT", "Etc/UCT"), | ||||
|                     ("Etc/UTC", "Etc/UTC"), | ||||
|                     ("Etc/Universal", "Etc/Universal"), | ||||
|                     ("Etc/Zulu", "Etc/Zulu"), | ||||
|                     ("Europe/Amsterdam", "Europe/Amsterdam"), | ||||
|                     ("Europe/Andorra", "Europe/Andorra"), | ||||
|                     ("Europe/Astrakhan", "Europe/Astrakhan"), | ||||
|                     ("Europe/Athens", "Europe/Athens"), | ||||
|                     ("Europe/Belfast", "Europe/Belfast"), | ||||
|                     ("Europe/Belgrade", "Europe/Belgrade"), | ||||
|                     ("Europe/Berlin", "Europe/Berlin"), | ||||
|                     ("Europe/Bratislava", "Europe/Bratislava"), | ||||
|                     ("Europe/Brussels", "Europe/Brussels"), | ||||
|                     ("Europe/Bucharest", "Europe/Bucharest"), | ||||
|                     ("Europe/Budapest", "Europe/Budapest"), | ||||
|                     ("Europe/Busingen", "Europe/Busingen"), | ||||
|                     ("Europe/Chisinau", "Europe/Chisinau"), | ||||
|                     ("Europe/Copenhagen", "Europe/Copenhagen"), | ||||
|                     ("Europe/Dublin", "Europe/Dublin"), | ||||
|                     ("Europe/Gibraltar", "Europe/Gibraltar"), | ||||
|                     ("Europe/Guernsey", "Europe/Guernsey"), | ||||
|                     ("Europe/Helsinki", "Europe/Helsinki"), | ||||
|                     ("Europe/Isle_of_Man", "Europe/Isle_of_Man"), | ||||
|                     ("Europe/Istanbul", "Europe/Istanbul"), | ||||
|                     ("Europe/Jersey", "Europe/Jersey"), | ||||
|                     ("Europe/Kaliningrad", "Europe/Kaliningrad"), | ||||
|                     ("Europe/Kiev", "Europe/Kiev"), | ||||
|                     ("Europe/Kirov", "Europe/Kirov"), | ||||
|                     ("Europe/Kyiv", "Europe/Kyiv"), | ||||
|                     ("Europe/Lisbon", "Europe/Lisbon"), | ||||
|                     ("Europe/Ljubljana", "Europe/Ljubljana"), | ||||
|                     ("Europe/London", "Europe/London"), | ||||
|                     ("Europe/Luxembourg", "Europe/Luxembourg"), | ||||
|                     ("Europe/Madrid", "Europe/Madrid"), | ||||
|                     ("Europe/Malta", "Europe/Malta"), | ||||
|                     ("Europe/Mariehamn", "Europe/Mariehamn"), | ||||
|                     ("Europe/Minsk", "Europe/Minsk"), | ||||
|                     ("Europe/Monaco", "Europe/Monaco"), | ||||
|                     ("Europe/Moscow", "Europe/Moscow"), | ||||
|                     ("Europe/Nicosia", "Europe/Nicosia"), | ||||
|                     ("Europe/Oslo", "Europe/Oslo"), | ||||
|                     ("Europe/Paris", "Europe/Paris"), | ||||
|                     ("Europe/Podgorica", "Europe/Podgorica"), | ||||
|                     ("Europe/Prague", "Europe/Prague"), | ||||
|                     ("Europe/Riga", "Europe/Riga"), | ||||
|                     ("Europe/Rome", "Europe/Rome"), | ||||
|                     ("Europe/Samara", "Europe/Samara"), | ||||
|                     ("Europe/San_Marino", "Europe/San_Marino"), | ||||
|                     ("Europe/Sarajevo", "Europe/Sarajevo"), | ||||
|                     ("Europe/Saratov", "Europe/Saratov"), | ||||
|                     ("Europe/Simferopol", "Europe/Simferopol"), | ||||
|                     ("Europe/Skopje", "Europe/Skopje"), | ||||
|                     ("Europe/Sofia", "Europe/Sofia"), | ||||
|                     ("Europe/Stockholm", "Europe/Stockholm"), | ||||
|                     ("Europe/Tallinn", "Europe/Tallinn"), | ||||
|                     ("Europe/Tirane", "Europe/Tirane"), | ||||
|                     ("Europe/Tiraspol", "Europe/Tiraspol"), | ||||
|                     ("Europe/Ulyanovsk", "Europe/Ulyanovsk"), | ||||
|                     ("Europe/Uzhgorod", "Europe/Uzhgorod"), | ||||
|                     ("Europe/Vaduz", "Europe/Vaduz"), | ||||
|                     ("Europe/Vatican", "Europe/Vatican"), | ||||
|                     ("Europe/Vienna", "Europe/Vienna"), | ||||
|                     ("Europe/Vilnius", "Europe/Vilnius"), | ||||
|                     ("Europe/Volgograd", "Europe/Volgograd"), | ||||
|                     ("Europe/Warsaw", "Europe/Warsaw"), | ||||
|                     ("Europe/Zagreb", "Europe/Zagreb"), | ||||
|                     ("Europe/Zaporozhye", "Europe/Zaporozhye"), | ||||
|                     ("Europe/Zurich", "Europe/Zurich"), | ||||
|                     ("Factory", "Factory"), | ||||
|                     ("GB", "GB"), | ||||
|                     ("GB-Eire", "GB-Eire"), | ||||
|                     ("GMT", "GMT"), | ||||
|                     ("GMT+0", "GMT+0"), | ||||
|                     ("GMT-0", "GMT-0"), | ||||
|                     ("GMT0", "GMT0"), | ||||
|                     ("Greenwich", "Greenwich"), | ||||
|                     ("HST", "HST"), | ||||
|                     ("Hongkong", "Hongkong"), | ||||
|                     ("Iceland", "Iceland"), | ||||
|                     ("Indian/Antananarivo", "Indian/Antananarivo"), | ||||
|                     ("Indian/Chagos", "Indian/Chagos"), | ||||
|                     ("Indian/Christmas", "Indian/Christmas"), | ||||
|                     ("Indian/Cocos", "Indian/Cocos"), | ||||
|                     ("Indian/Comoro", "Indian/Comoro"), | ||||
|                     ("Indian/Kerguelen", "Indian/Kerguelen"), | ||||
|                     ("Indian/Mahe", "Indian/Mahe"), | ||||
|                     ("Indian/Maldives", "Indian/Maldives"), | ||||
|                     ("Indian/Mauritius", "Indian/Mauritius"), | ||||
|                     ("Indian/Mayotte", "Indian/Mayotte"), | ||||
|                     ("Indian/Reunion", "Indian/Reunion"), | ||||
|                     ("Iran", "Iran"), | ||||
|                     ("Israel", "Israel"), | ||||
|                     ("Jamaica", "Jamaica"), | ||||
|                     ("Japan", "Japan"), | ||||
|                     ("Kwajalein", "Kwajalein"), | ||||
|                     ("Libya", "Libya"), | ||||
|                     ("MET", "MET"), | ||||
|                     ("MST", "MST"), | ||||
|                     ("MST7MDT", "MST7MDT"), | ||||
|                     ("Mexico/BajaNorte", "Mexico/BajaNorte"), | ||||
|                     ("Mexico/BajaSur", "Mexico/BajaSur"), | ||||
|                     ("Mexico/General", "Mexico/General"), | ||||
|                     ("NZ", "NZ"), | ||||
|                     ("NZ-CHAT", "NZ-CHAT"), | ||||
|                     ("Navajo", "Navajo"), | ||||
|                     ("PRC", "PRC"), | ||||
|                     ("PST8PDT", "PST8PDT"), | ||||
|                     ("Pacific/Apia", "Pacific/Apia"), | ||||
|                     ("Pacific/Auckland", "Pacific/Auckland"), | ||||
|                     ("Pacific/Bougainville", "Pacific/Bougainville"), | ||||
|                     ("Pacific/Chatham", "Pacific/Chatham"), | ||||
|                     ("Pacific/Chuuk", "Pacific/Chuuk"), | ||||
|                     ("Pacific/Easter", "Pacific/Easter"), | ||||
|                     ("Pacific/Efate", "Pacific/Efate"), | ||||
|                     ("Pacific/Enderbury", "Pacific/Enderbury"), | ||||
|                     ("Pacific/Fakaofo", "Pacific/Fakaofo"), | ||||
|                     ("Pacific/Fiji", "Pacific/Fiji"), | ||||
|                     ("Pacific/Funafuti", "Pacific/Funafuti"), | ||||
|                     ("Pacific/Galapagos", "Pacific/Galapagos"), | ||||
|                     ("Pacific/Gambier", "Pacific/Gambier"), | ||||
|                     ("Pacific/Guadalcanal", "Pacific/Guadalcanal"), | ||||
|                     ("Pacific/Guam", "Pacific/Guam"), | ||||
|                     ("Pacific/Honolulu", "Pacific/Honolulu"), | ||||
|                     ("Pacific/Johnston", "Pacific/Johnston"), | ||||
|                     ("Pacific/Kanton", "Pacific/Kanton"), | ||||
|                     ("Pacific/Kiritimati", "Pacific/Kiritimati"), | ||||
|                     ("Pacific/Kosrae", "Pacific/Kosrae"), | ||||
|                     ("Pacific/Kwajalein", "Pacific/Kwajalein"), | ||||
|                     ("Pacific/Majuro", "Pacific/Majuro"), | ||||
|                     ("Pacific/Marquesas", "Pacific/Marquesas"), | ||||
|                     ("Pacific/Midway", "Pacific/Midway"), | ||||
|                     ("Pacific/Nauru", "Pacific/Nauru"), | ||||
|                     ("Pacific/Niue", "Pacific/Niue"), | ||||
|                     ("Pacific/Norfolk", "Pacific/Norfolk"), | ||||
|                     ("Pacific/Noumea", "Pacific/Noumea"), | ||||
|                     ("Pacific/Pago_Pago", "Pacific/Pago_Pago"), | ||||
|                     ("Pacific/Palau", "Pacific/Palau"), | ||||
|                     ("Pacific/Pitcairn", "Pacific/Pitcairn"), | ||||
|                     ("Pacific/Pohnpei", "Pacific/Pohnpei"), | ||||
|                     ("Pacific/Ponape", "Pacific/Ponape"), | ||||
|                     ("Pacific/Port_Moresby", "Pacific/Port_Moresby"), | ||||
|                     ("Pacific/Rarotonga", "Pacific/Rarotonga"), | ||||
|                     ("Pacific/Saipan", "Pacific/Saipan"), | ||||
|                     ("Pacific/Samoa", "Pacific/Samoa"), | ||||
|                     ("Pacific/Tahiti", "Pacific/Tahiti"), | ||||
|                     ("Pacific/Tarawa", "Pacific/Tarawa"), | ||||
|                     ("Pacific/Tongatapu", "Pacific/Tongatapu"), | ||||
|                     ("Pacific/Truk", "Pacific/Truk"), | ||||
|                     ("Pacific/Wake", "Pacific/Wake"), | ||||
|                     ("Pacific/Wallis", "Pacific/Wallis"), | ||||
|                     ("Pacific/Yap", "Pacific/Yap"), | ||||
|                     ("Poland", "Poland"), | ||||
|                     ("Portugal", "Portugal"), | ||||
|                     ("ROC", "ROC"), | ||||
|                     ("ROK", "ROK"), | ||||
|                     ("Singapore", "Singapore"), | ||||
|                     ("Turkey", "Turkey"), | ||||
|                     ("UCT", "UCT"), | ||||
|                     ("US/Alaska", "US/Alaska"), | ||||
|                     ("US/Aleutian", "US/Aleutian"), | ||||
|                     ("US/Arizona", "US/Arizona"), | ||||
|                     ("US/Central", "US/Central"), | ||||
|                     ("US/East-Indiana", "US/East-Indiana"), | ||||
|                     ("US/Eastern", "US/Eastern"), | ||||
|                     ("US/Hawaii", "US/Hawaii"), | ||||
|                     ("US/Indiana-Starke", "US/Indiana-Starke"), | ||||
|                     ("US/Michigan", "US/Michigan"), | ||||
|                     ("US/Mountain", "US/Mountain"), | ||||
|                     ("US/Pacific", "US/Pacific"), | ||||
|                     ("US/Samoa", "US/Samoa"), | ||||
|                     ("UTC", "UTC"), | ||||
|                     ("Universal", "Universal"), | ||||
|                     ("W-SU", "W-SU"), | ||||
|                     ("WET", "WET"), | ||||
|                     ("Zulu", "Zulu"), | ||||
|                     ("localtime", "localtime"), | ||||
|                 ], | ||||
|                 max_length=255, | ||||
|                 null=True, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 4.2.10 on 2024-02-19 05:57 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("agents", "0058_alter_agent_time_zone"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="agenthistory", | ||||
|             name="id", | ||||
|             field=models.BigAutoField(primary_key=True, serialize=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,36 @@ | ||||
| # Generated by Django 4.2.16 on 2024-10-05 20:39 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("core", "0047_alter_coresettings_notify_on_warning_alerts"), | ||||
|         ("agents", "0059_alter_agenthistory_id"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="agenthistory", | ||||
|             name="collector_all_output", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="agenthistory", | ||||
|             name="custom_field", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="history", | ||||
|                 to="core.customfield", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="agenthistory", | ||||
|             name="save_to_agent_note", | ||||
|             field=models.BooleanField(default=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,14 +1,13 @@ | ||||
| import asyncio | ||||
| import logging | ||||
| import re | ||||
| from collections import Counter | ||||
| from contextlib import suppress | ||||
| from distutils.version import LooseVersion | ||||
| from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union, cast | ||||
|  | ||||
| import msgpack | ||||
| import nats | ||||
| import validators | ||||
| from asgiref.sync import sync_to_async | ||||
| from django.conf import settings | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| from django.core.cache import cache | ||||
| @@ -16,15 +15,18 @@ from django.db import models | ||||
| from django.utils import timezone as djangotime | ||||
| from nats.errors import TimeoutError | ||||
| from packaging import version as pyver | ||||
| from packaging.version import Version as LooseVersion | ||||
|  | ||||
| from agents.utils import get_agent_url | ||||
| from checks.models import CheckResult | ||||
| from core.models import TZ_CHOICES | ||||
| from core.utils import get_core_settings, send_command_with_mesh | ||||
| from core.utils import _b64_to_hex, get_core_settings, send_command_with_mesh | ||||
| from logs.models import BaseAuditModel, DebugLog, PendingAction | ||||
| from tacticalrmm.constants import ( | ||||
|     AGENT_STATUS_OFFLINE, | ||||
|     AGENT_STATUS_ONLINE, | ||||
|     AGENT_STATUS_OVERDUE, | ||||
|     AGENT_TBL_PEND_ACTION_CNT_CACHE_PREFIX, | ||||
|     ONLINE_AGENTS, | ||||
|     AgentHistoryType, | ||||
|     AgentMonType, | ||||
| @@ -38,7 +40,7 @@ from tacticalrmm.constants import ( | ||||
|     PAAction, | ||||
|     PAStatus, | ||||
| ) | ||||
| from tacticalrmm.helpers import get_nats_ports | ||||
| from tacticalrmm.helpers import has_script_actions, has_webhook, setup_nats_options | ||||
| from tacticalrmm.models import PermissionQuerySet | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
| @@ -52,6 +54,8 @@ if TYPE_CHECKING: | ||||
| # type helpers | ||||
| Disk = Union[Dict[str, Any], str] | ||||
|  | ||||
| logger = logging.getLogger("trmm") | ||||
|  | ||||
|  | ||||
| class Agent(BaseAuditModel): | ||||
|     class Meta: | ||||
| @@ -122,6 +126,27 @@ class Agent(BaseAuditModel): | ||||
|     def __str__(self) -> str: | ||||
|         return self.hostname | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|         # prevent recursion since calling set_alert_template() also calls save() | ||||
|         if not hasattr(self, "_processing_set_alert_template"): | ||||
|             self._processing_set_alert_template = False | ||||
|  | ||||
|         if self.pk and not self._processing_set_alert_template: | ||||
|             orig = Agent.objects.get(pk=self.pk) | ||||
|             mon_type_changed = self.monitoring_type != orig.monitoring_type | ||||
|             site_changed = self.site_id != orig.site_id | ||||
|             policy_changed = self.policy != orig.policy | ||||
|             block_inherit = ( | ||||
|                 self.block_policy_inheritance != orig.block_policy_inheritance | ||||
|             ) | ||||
|  | ||||
|             if mon_type_changed or site_changed or policy_changed or block_inherit: | ||||
|                 self._processing_set_alert_template = True | ||||
|                 self.set_alert_template() | ||||
|                 self._processing_set_alert_template = False | ||||
|  | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def client(self) -> "Client": | ||||
|         return self.site.client | ||||
| @@ -199,8 +224,9 @@ class Agent(BaseAuditModel): | ||||
|  | ||||
|     @property | ||||
|     def status(self) -> str: | ||||
|         offline = djangotime.now() - djangotime.timedelta(minutes=self.offline_time) | ||||
|         overdue = djangotime.now() - djangotime.timedelta(minutes=self.overdue_time) | ||||
|         now = djangotime.now() | ||||
|         offline = now - djangotime.timedelta(minutes=self.offline_time) | ||||
|         overdue = now - djangotime.timedelta(minutes=self.overdue_time) | ||||
|  | ||||
|         if self.last_seen is not None: | ||||
|             if (self.last_seen < offline) and (self.last_seen > overdue): | ||||
| @@ -214,8 +240,6 @@ class Agent(BaseAuditModel): | ||||
|  | ||||
|     @property | ||||
|     def checks(self) -> Dict[str, Any]: | ||||
|         from checks.models import CheckResult | ||||
|  | ||||
|         total, passing, failing, warning, info = 0, 0, 0, 0, 0 | ||||
|  | ||||
|         for check in self.get_checks_with_policies(exclude_overridden=True): | ||||
| @@ -258,6 +282,15 @@ class Agent(BaseAuditModel): | ||||
|         } | ||||
|         return ret | ||||
|  | ||||
|     @property | ||||
|     def pending_actions_count(self) -> int: | ||||
|         ret = cache.get(f"{AGENT_TBL_PEND_ACTION_CNT_CACHE_PREFIX}{self.pk}") | ||||
|         if ret is None: | ||||
|             ret = self.pendingactions.filter(status=PAStatus.PENDING).count() | ||||
|             cache.set(f"{AGENT_TBL_PEND_ACTION_CNT_CACHE_PREFIX}{self.pk}", ret, 600) | ||||
|  | ||||
|         return ret | ||||
|  | ||||
|     @property | ||||
|     def cpu_model(self) -> List[str]: | ||||
|         if self.is_posix: | ||||
| @@ -270,7 +303,20 @@ class Agent(BaseAuditModel): | ||||
|         try: | ||||
|             cpus = self.wmi_detail["cpu"] | ||||
|             for cpu in cpus: | ||||
|                 ret.append([x["Name"] for x in cpu if "Name" in x][0]) | ||||
|                 name = [x["Name"] for x in cpu if "Name" in x][0] | ||||
|                 lp, nc = "", "" | ||||
|                 with suppress(Exception): | ||||
|                     lp = [ | ||||
|                         x["NumberOfLogicalProcessors"] | ||||
|                         for x in cpu | ||||
|                         if "NumberOfCores" in x | ||||
|                     ][0] | ||||
|                     nc = [x["NumberOfCores"] for x in cpu if "NumberOfCores" in x][0] | ||||
|                 if lp and nc: | ||||
|                     cpu_string = f"{name}, {nc}C/{lp}T" | ||||
|                 else: | ||||
|                     cpu_string = name | ||||
|                 ret.append(cpu_string) | ||||
|             return ret | ||||
|         except: | ||||
|             return ["unknown cpu model"] | ||||
| @@ -398,6 +444,23 @@ class Agent(BaseAuditModel): | ||||
|         except: | ||||
|             return ["unknown disk"] | ||||
|  | ||||
|     @property | ||||
|     def serial_number(self) -> str: | ||||
|         if self.is_posix: | ||||
|             try: | ||||
|                 return self.wmi_detail["serialnumber"] | ||||
|             except: | ||||
|                 return "" | ||||
|  | ||||
|         try: | ||||
|             return self.wmi_detail["bios"][0][0]["SerialNumber"] | ||||
|         except: | ||||
|             return "" | ||||
|  | ||||
|     @property | ||||
|     def hex_mesh_node_id(self) -> str: | ||||
|         return _b64_to_hex(self.mesh_node_id) | ||||
|  | ||||
|     @classmethod | ||||
|     def online_agents(cls, min_version: str = "") -> "List[Agent]": | ||||
|         if min_version: | ||||
| @@ -420,7 +483,6 @@ class Agent(BaseAuditModel): | ||||
|     def get_checks_with_policies( | ||||
|         self, exclude_overridden: bool = False | ||||
|     ) -> "List[Check]": | ||||
|  | ||||
|         if exclude_overridden: | ||||
|             checks = ( | ||||
|                 list( | ||||
| @@ -435,12 +497,10 @@ class Agent(BaseAuditModel): | ||||
|         return self.add_check_results(checks) | ||||
|  | ||||
|     def get_tasks_with_policies(self) -> "List[AutomatedTask]": | ||||
|  | ||||
|         tasks = list(self.autotasks.all()) + self.get_tasks_from_policies() | ||||
|         return self.add_task_results(tasks) | ||||
|  | ||||
|     def add_task_results(self, tasks: "List[AutomatedTask]") -> "List[AutomatedTask]": | ||||
|  | ||||
|         results = self.taskresults.all()  # type: ignore | ||||
|  | ||||
|         for task in tasks: | ||||
| @@ -452,7 +512,6 @@ class Agent(BaseAuditModel): | ||||
|         return tasks | ||||
|  | ||||
|     def add_check_results(self, checks: "List[Check]") -> "List[Check]": | ||||
|  | ||||
|         results = self.checkresults.all()  # type: ignore | ||||
|  | ||||
|         for check in checks: | ||||
| @@ -489,24 +548,32 @@ class Agent(BaseAuditModel): | ||||
|         ) | ||||
|  | ||||
|         return { | ||||
|             "agent_policy": self.policy | ||||
|             if self.policy and not self.policy.is_agent_excluded(self) | ||||
|             else None, | ||||
|             "site_policy": site_policy | ||||
|             if (site_policy and not site_policy.is_agent_excluded(self)) | ||||
|             and not self.block_policy_inheritance | ||||
|             else None, | ||||
|             "client_policy": client_policy | ||||
|             if (client_policy and not client_policy.is_agent_excluded(self)) | ||||
|             and not self.block_policy_inheritance | ||||
|             and not self.site.block_policy_inheritance | ||||
|             else None, | ||||
|             "default_policy": default_policy | ||||
|             if (default_policy and not default_policy.is_agent_excluded(self)) | ||||
|             and not self.block_policy_inheritance | ||||
|             and not self.site.block_policy_inheritance | ||||
|             and not self.client.block_policy_inheritance | ||||
|             else None, | ||||
|             "agent_policy": ( | ||||
|                 self.policy | ||||
|                 if self.policy and not self.policy.is_agent_excluded(self) | ||||
|                 else None | ||||
|             ), | ||||
|             "site_policy": ( | ||||
|                 site_policy | ||||
|                 if (site_policy and not site_policy.is_agent_excluded(self)) | ||||
|                 and not self.block_policy_inheritance | ||||
|                 else None | ||||
|             ), | ||||
|             "client_policy": ( | ||||
|                 client_policy | ||||
|                 if (client_policy and not client_policy.is_agent_excluded(self)) | ||||
|                 and not self.block_policy_inheritance | ||||
|                 and not self.site.block_policy_inheritance | ||||
|                 else None | ||||
|             ), | ||||
|             "default_policy": ( | ||||
|                 default_policy | ||||
|                 if (default_policy and not default_policy.is_agent_excluded(self)) | ||||
|                 and not self.block_policy_inheritance | ||||
|                 and not self.site.block_policy_inheritance | ||||
|                 and not self.client.block_policy_inheritance | ||||
|                 else None | ||||
|             ), | ||||
|         } | ||||
|  | ||||
|     def check_run_interval(self) -> int: | ||||
| @@ -514,7 +581,6 @@ class Agent(BaseAuditModel): | ||||
|         # determine if any agent checks have a custom interval and set the lowest interval | ||||
|         for check in self.get_checks_with_policies(): | ||||
|             if check.run_interval and check.run_interval < interval: | ||||
|  | ||||
|                 # don't allow check runs less than 15s | ||||
|                 interval = 15 if check.run_interval < 15 else check.run_interval | ||||
|  | ||||
| @@ -530,8 +596,8 @@ class Agent(BaseAuditModel): | ||||
|         run_on_any: bool = False, | ||||
|         history_pk: int = 0, | ||||
|         run_as_user: bool = False, | ||||
|         env_vars: list[str] = [], | ||||
|     ) -> Any: | ||||
|  | ||||
|         from scripts.models import Script | ||||
|  | ||||
|         script = Script.objects.get(pk=scriptpk) | ||||
| @@ -541,6 +607,7 @@ class Agent(BaseAuditModel): | ||||
|             run_as_user = True | ||||
|  | ||||
|         parsed_args = script.parse_script_args(self, script.shell, args) | ||||
|         parsed_env_vars = script.parse_script_env_vars(self, script.shell, env_vars) | ||||
|  | ||||
|         data = { | ||||
|             "func": "runscriptfull" if full else "runscript", | ||||
| @@ -551,6 +618,9 @@ class Agent(BaseAuditModel): | ||||
|                 "shell": script.shell, | ||||
|             }, | ||||
|             "run_as_user": run_as_user, | ||||
|             "env_vars": parsed_env_vars, | ||||
|             "nushell_enable_config": settings.NUSHELL_ENABLE_CONFIG, | ||||
|             "deno_default_permissions": settings.DENO_DEFAULT_PERMISSIONS, | ||||
|         } | ||||
|  | ||||
|         if history_pk != 0: | ||||
| @@ -781,23 +851,12 @@ class Agent(BaseAuditModel): | ||||
|             cache.set(cache_key, tasks, 600) | ||||
|             return tasks | ||||
|  | ||||
|     def _do_nats_debug(self, agent: "Agent", message: str) -> None: | ||||
|         DebugLog.error(agent=agent, log_type=DebugLogType.AGENT_ISSUES, message=message) | ||||
|  | ||||
|     async def nats_cmd( | ||||
|         self, data: Dict[Any, Any], timeout: int = 30, wait: bool = True | ||||
|     ) -> Any: | ||||
|         nats_std_port, _ = get_nats_ports() | ||||
|         options = { | ||||
|             "servers": f"tls://{settings.ALLOWED_HOSTS[0]}:{nats_std_port}", | ||||
|             "user": "tacticalrmm", | ||||
|             "password": settings.SECRET_KEY, | ||||
|             "connect_timeout": 3, | ||||
|             "max_reconnect_attempts": 2, | ||||
|         } | ||||
|  | ||||
|         opts = setup_nats_options() | ||||
|         try: | ||||
|             nc = await nats.connect(**options) | ||||
|             nc = await nats.connect(**opts) | ||||
|         except: | ||||
|             return "natsdown" | ||||
|  | ||||
| @@ -813,9 +872,7 @@ class Agent(BaseAuditModel): | ||||
|                     ret = msgpack.loads(msg.data) | ||||
|                 except Exception as e: | ||||
|                     ret = str(e) | ||||
|                     await sync_to_async(self._do_nats_debug, thread_sensitive=False)( | ||||
|                         agent=self, message=ret | ||||
|                     ) | ||||
|                     logger.error(e) | ||||
|  | ||||
|             await nc.close() | ||||
|             return ret | ||||
| @@ -878,8 +935,10 @@ class Agent(BaseAuditModel): | ||||
|                 # extract the version from the title and sort from oldest to newest | ||||
|                 # skip if no version info is available therefore nothing to parse | ||||
|                 try: | ||||
|                     matches = r"(Version|Versão)" | ||||
|                     pattern = r"\(" + matches + r"(.*?)\)" | ||||
|                     vers = [ | ||||
|                         re.search(r"\(Version(.*?)\)", i).group(1).strip() | ||||
|                         re.search(pattern, i, flags=re.IGNORECASE).group(2).strip() | ||||
|                         for i in titles | ||||
|                     ] | ||||
|                     sorted_vers = sorted(vers, key=LooseVersion) | ||||
| @@ -896,18 +955,22 @@ class Agent(BaseAuditModel): | ||||
|     def should_create_alert( | ||||
|         self, alert_template: "Optional[AlertTemplate]" = None | ||||
|     ) -> bool: | ||||
|         return bool( | ||||
|         has_agent_notification = ( | ||||
|             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 | ||||
|                 ) | ||||
|             ) | ||||
|         ) | ||||
|         has_alert_template_notification = alert_template and ( | ||||
|             alert_template.agent_always_alert | ||||
|             or alert_template.agent_always_email | ||||
|             or alert_template.agent_always_text | ||||
|         ) | ||||
|  | ||||
|         return bool( | ||||
|             has_agent_notification | ||||
|             or has_alert_template_notification | ||||
|             or has_webhook(alert_template, "agent") | ||||
|             or has_script_actions(alert_template, "agent") | ||||
|         ) | ||||
|  | ||||
|     def send_outage_email(self) -> None: | ||||
| @@ -1001,6 +1064,9 @@ class AgentCustomField(models.Model): | ||||
|         default=list, | ||||
|     ) | ||||
|  | ||||
|     class Meta: | ||||
|         unique_together = (("agent", "field"),) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.field.name | ||||
|  | ||||
| @@ -1033,6 +1099,7 @@ class AgentCustomField(models.Model): | ||||
| class AgentHistory(models.Model): | ||||
|     objects = PermissionQuerySet.as_manager() | ||||
|  | ||||
|     id = models.BigAutoField(primary_key=True) | ||||
|     agent = models.ForeignKey( | ||||
|         Agent, | ||||
|         related_name="history", | ||||
| @@ -1055,6 +1122,15 @@ class AgentHistory(models.Model): | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     script_results = models.JSONField(null=True, blank=True) | ||||
|     custom_field = models.ForeignKey( | ||||
|         "core.CustomField", | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         related_name="history", | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     collector_all_output = models.BooleanField(default=False) | ||||
|     save_to_agent_note = models.BooleanField(default=False) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return f"{self.agent.hostname} - {self.type}" | ||||
|   | ||||
| @@ -47,13 +47,6 @@ class UpdateAgentPerms(permissions.BasePermission): | ||||
|         return _has_perm(r, "can_update_agents") | ||||
|  | ||||
|  | ||||
| class PingAgentPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         return _has_perm(r, "can_ping_agents") and _has_perm_on_agent( | ||||
|             r.user, view.kwargs["agent_id"] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ManageProcPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         return _has_perm(r, "can_manage_procs") and _has_perm_on_agent( | ||||
| @@ -96,10 +89,8 @@ class RunScriptPerms(permissions.BasePermission): | ||||
|  | ||||
| class AgentNotesPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|  | ||||
|         # permissions for GET /agents/notes/ endpoint | ||||
|         if r.method == "GET": | ||||
|  | ||||
|             # permissions for /agents/<agent_id>/notes endpoint | ||||
|             if "agent_id" in view.kwargs.keys(): | ||||
|                 return _has_perm(r, "can_list_notes") and _has_perm_on_agent( | ||||
| @@ -124,3 +115,13 @@ class AgentHistoryPerms(permissions.BasePermission): | ||||
|             ) | ||||
|  | ||||
|         return _has_perm(r, "can_list_agent_history") | ||||
|  | ||||
|  | ||||
| class AgentWOLPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if "agent_id" in view.kwargs.keys(): | ||||
|             return _has_perm(r, "can_send_wol") and _has_perm_on_agent( | ||||
|                 r.user, view.kwargs["agent_id"] | ||||
|             ) | ||||
|  | ||||
|         return _has_perm(r, "can_send_wol") | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import pytz | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from tacticalrmm.constants import AGENT_STATUS_ONLINE | ||||
| from tacticalrmm.constants import AGENT_STATUS_ONLINE, ALL_TIMEZONES | ||||
| from winupdate.serializers import WinUpdatePolicySerializer | ||||
|  | ||||
| from .models import Agent, AgentCustomField, AgentHistory, Note | ||||
| @@ -71,7 +70,7 @@ class AgentSerializer(serializers.ModelSerializer): | ||||
|         return policies | ||||
|  | ||||
|     def get_all_timezones(self, obj): | ||||
|         return pytz.all_timezones | ||||
|         return ALL_TIMEZONES | ||||
|  | ||||
|     class Meta: | ||||
|         model = Agent | ||||
| @@ -95,9 +94,10 @@ class AgentTableSerializer(serializers.ModelSerializer): | ||||
|     local_ips = serializers.ReadOnlyField() | ||||
|     make_model = serializers.ReadOnlyField() | ||||
|     physical_disks = serializers.ReadOnlyField() | ||||
|     serial_number = serializers.ReadOnlyField() | ||||
|     custom_fields = AgentCustomFieldSerializer(many=True, read_only=True) | ||||
|  | ||||
|     def get_alert_template(self, obj): | ||||
|  | ||||
|         if not obj.alert_template: | ||||
|             return None | ||||
|  | ||||
| @@ -154,6 +154,8 @@ class AgentTableSerializer(serializers.ModelSerializer): | ||||
|             "local_ips", | ||||
|             "make_model", | ||||
|             "physical_disks", | ||||
|             "custom_fields", | ||||
|             "serial_number", | ||||
|         ] | ||||
|         depth = 2 | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import datetime as dt | ||||
| import random | ||||
| from time import sleep | ||||
| from typing import TYPE_CHECKING, Optional | ||||
|  | ||||
| @@ -13,10 +12,13 @@ from scripts.models import Script | ||||
| from tacticalrmm.celery import app | ||||
| from tacticalrmm.constants import ( | ||||
|     AGENT_DEFER, | ||||
|     AGENT_OUTAGES_LOCK, | ||||
|     AGENT_STATUS_OVERDUE, | ||||
|     CheckStatus, | ||||
|     DebugLogType, | ||||
| ) | ||||
| from tacticalrmm.helpers import rand_range | ||||
| from tacticalrmm.utils import redis_lock | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from django.db.models.query import QuerySet | ||||
| @@ -46,7 +48,7 @@ def agent_outage_email_task(pk: int, alert_interval: Optional[float] = None) -> | ||||
|         return "alert not found" | ||||
|  | ||||
|     if not alert.email_sent: | ||||
|         sleep(random.randint(1, 5)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         alert.agent.send_outage_email() | ||||
|         alert.email_sent = djangotime.now() | ||||
|         alert.save(update_fields=["email_sent"]) | ||||
| @@ -55,7 +57,7 @@ def agent_outage_email_task(pk: int, alert_interval: Optional[float] = None) -> | ||||
|             # 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, 5)) | ||||
|                 sleep(rand_range(100, 1500)) | ||||
|                 alert.agent.send_outage_email() | ||||
|                 alert.email_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["email_sent"]) | ||||
| @@ -67,7 +69,7 @@ def agent_outage_email_task(pk: int, alert_interval: Optional[float] = None) -> | ||||
| def agent_recovery_email_task(pk: int) -> str: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|     sleep(random.randint(1, 5)) | ||||
|     sleep(rand_range(100, 1500)) | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
| @@ -91,7 +93,7 @@ def agent_outage_sms_task(pk: int, alert_interval: Optional[float] = None) -> st | ||||
|         return "alert not found" | ||||
|  | ||||
|     if not alert.sms_sent: | ||||
|         sleep(random.randint(1, 3)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         alert.agent.send_outage_sms() | ||||
|         alert.sms_sent = djangotime.now() | ||||
|         alert.save(update_fields=["sms_sent"]) | ||||
| @@ -100,7 +102,7 @@ def agent_outage_sms_task(pk: int, alert_interval: Optional[float] = None) -> st | ||||
|             # 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, 3)) | ||||
|                 sleep(rand_range(100, 1500)) | ||||
|                 alert.agent.send_outage_sms() | ||||
|                 alert.sms_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["sms_sent"]) | ||||
| @@ -112,7 +114,7 @@ def agent_outage_sms_task(pk: int, alert_interval: Optional[float] = None) -> st | ||||
| def agent_recovery_sms_task(pk: int) -> str: | ||||
|     from alerts.models import Alert | ||||
|  | ||||
|     sleep(random.randint(1, 3)) | ||||
|     sleep(rand_range(100, 1500)) | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -125,24 +127,20 @@ def agent_recovery_sms_task(pk: int) -> str: | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def agent_outages_task() -> None: | ||||
|     from alerts.models import Alert | ||||
| @app.task(bind=True) | ||||
| def agent_outages_task(self) -> str: | ||||
|     with redis_lock(AGENT_OUTAGES_LOCK, self.app.oid) as acquired: | ||||
|         if not acquired: | ||||
|             return f"{self.app.oid} still running" | ||||
|  | ||||
|     agents = Agent.objects.only( | ||||
|         "pk", | ||||
|         "agent_id", | ||||
|         "last_seen", | ||||
|         "offline_time", | ||||
|         "overdue_time", | ||||
|         "overdue_email_alert", | ||||
|         "overdue_text_alert", | ||||
|         "overdue_dashboard_alert", | ||||
|     ) | ||||
|         from alerts.models import Alert | ||||
|         from core.tasks import _get_agent_qs | ||||
|  | ||||
|     for agent in agents: | ||||
|         if agent.status == AGENT_STATUS_OVERDUE: | ||||
|             Alert.handle_alert_failure(agent) | ||||
|         for agent in _get_agent_qs(): | ||||
|             if agent.status == AGENT_STATUS_OVERDUE: | ||||
|                 Alert.handle_alert_failure(agent) | ||||
|  | ||||
|         return "completed" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| @@ -154,6 +152,7 @@ def run_script_email_results_task( | ||||
|     args: list[str] = [], | ||||
|     history_pk: int = 0, | ||||
|     run_as_user: bool = False, | ||||
|     env_vars: list[str] = [], | ||||
| ): | ||||
|     agent = Agent.objects.get(pk=agentpk) | ||||
|     script = Script.objects.get(pk=scriptpk) | ||||
| @@ -165,6 +164,7 @@ def run_script_email_results_task( | ||||
|         wait=True, | ||||
|         history_pk=history_pk, | ||||
|         run_as_user=run_as_user, | ||||
|         env_vars=env_vars, | ||||
|     ) | ||||
|     if r == "timeout": | ||||
|         DebugLog.error( | ||||
| @@ -175,7 +175,7 @@ def run_script_email_results_task( | ||||
|         return | ||||
|  | ||||
|     CORE = get_core_settings() | ||||
|     subject = f"{agent.hostname} {script.name} Results" | ||||
|     subject = f"{agent.client.name}, {agent.site.name}, {agent.hostname} {script.name} Results" | ||||
|     exec_time = "{:.4f}".format(r["execution_time"]) | ||||
|     body = ( | ||||
|         subject | ||||
|   | ||||
							
								
								
									
										61
									
								
								api/tacticalrmm/agents/tests/test_agent_save.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										61
									
								
								api/tacticalrmm/agents/tests/test_agent_save.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,61 @@ | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from model_bakery import baker | ||||
|  | ||||
| from agents.models import Agent | ||||
| from tacticalrmm.constants import AgentMonType | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
|  | ||||
| class AgentSaveTestCase(TacticalTestCase): | ||||
|     def setUp(self): | ||||
|         self.client1 = baker.make("clients.Client") | ||||
|         self.client2 = baker.make("clients.Client") | ||||
|         self.site1 = baker.make("clients.Site", client=self.client1) | ||||
|         self.site2 = baker.make("clients.Site", client=self.client2) | ||||
|         self.site3 = baker.make("clients.Site", client=self.client2) | ||||
|         self.agent = baker.make( | ||||
|             "agents.Agent", | ||||
|             site=self.site1, | ||||
|             monitoring_type=AgentMonType.SERVER, | ||||
|         ) | ||||
|  | ||||
|     @patch.object(Agent, "set_alert_template") | ||||
|     def test_set_alert_template_called_on_mon_type_change( | ||||
|         self, mock_set_alert_template | ||||
|     ): | ||||
|         self.agent.monitoring_type = AgentMonType.WORKSTATION | ||||
|         self.agent.save() | ||||
|         mock_set_alert_template.assert_called_once() | ||||
|  | ||||
|     @patch.object(Agent, "set_alert_template") | ||||
|     def test_set_alert_template_called_on_site_change(self, mock_set_alert_template): | ||||
|         self.agent.site = self.site2 | ||||
|         self.agent.save() | ||||
|         mock_set_alert_template.assert_called_once() | ||||
|  | ||||
|     @patch.object(Agent, "set_alert_template") | ||||
|     def test_set_alert_template_called_on_site_and_montype_change( | ||||
|         self, mock_set_alert_template | ||||
|     ): | ||||
|         print(f"before: {self.agent.monitoring_type} site: {self.agent.site_id}") | ||||
|         self.agent.site = self.site3 | ||||
|         self.agent.monitoring_type = AgentMonType.WORKSTATION | ||||
|         self.agent.save() | ||||
|         mock_set_alert_template.assert_called_once() | ||||
|         print(f"after: {self.agent.monitoring_type} site: {self.agent.site_id}") | ||||
|  | ||||
|     @patch.object(Agent, "set_alert_template") | ||||
|     def test_set_alert_template_not_called_without_changes( | ||||
|         self, mock_set_alert_template | ||||
|     ): | ||||
|         self.agent.save() | ||||
|         mock_set_alert_template.assert_not_called() | ||||
|  | ||||
|     @patch.object(Agent, "set_alert_template") | ||||
|     def test_set_alert_template_not_called_on_non_relevant_field_change( | ||||
|         self, mock_set_alert_template | ||||
|     ): | ||||
|         self.agent.hostname = "abc123" | ||||
|         self.agent.save() | ||||
|         mock_set_alert_template.assert_not_called() | ||||
| @@ -264,7 +264,7 @@ class TestAgentUpdate(TacticalTestCase): | ||||
|         agents = baker.make_recipe("agents.agent", _quantity=5) | ||||
|         other_agents = baker.make_recipe("agents.agent", _quantity=7) | ||||
|  | ||||
|         url = f"/agents/update/" | ||||
|         url = "/agents/update/" | ||||
|  | ||||
|         data = { | ||||
|             "agent_ids": [agent.agent_id for agent in agents] | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| from unittest.mock import patch, AsyncMock | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from django.conf import settings | ||||
| from rest_framework.response import Response | ||||
|  | ||||
| from agents.utils import generate_linux_install, get_agent_url | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
|   | ||||
| @@ -2,9 +2,9 @@ import json | ||||
| import os | ||||
| from itertools import cycle | ||||
| from typing import TYPE_CHECKING | ||||
| from unittest.mock import patch | ||||
| from unittest.mock import PropertyMock, patch | ||||
| from zoneinfo import ZoneInfo | ||||
|  | ||||
| import pytz | ||||
| from django.conf import settings | ||||
| from django.utils import timezone as djangotime | ||||
| from model_bakery import baker | ||||
| @@ -540,6 +540,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "args": [], | ||||
|             "timeout": 15, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|  | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -555,6 +556,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             wait=True, | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         run_script.reset_mock() | ||||
|  | ||||
| @@ -567,16 +569,20 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "emailMode": "default", | ||||
|             "emails": ["admin@example.com", "bob@example.com"], | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|         r = self.client.post(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         hist = AgentHistory.objects.filter(agent=self.agent, script=script).last() | ||||
|         email_task.assert_called_with( | ||||
|             agentpk=self.agent.pk, | ||||
|             scriptpk=script.pk, | ||||
|             nats_timeout=18, | ||||
|             emails=[], | ||||
|             args=["abc", "123"], | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         email_task.reset_mock() | ||||
|  | ||||
| @@ -584,13 +590,16 @@ class TestAgentViews(TacticalTestCase): | ||||
|         data["emailMode"] = "custom" | ||||
|         r = self.client.post(url, data, format="json") | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         hist = AgentHistory.objects.filter(agent=self.agent, script=script).last() | ||||
|         email_task.assert_called_with( | ||||
|             agentpk=self.agent.pk, | ||||
|             scriptpk=script.pk, | ||||
|             nats_timeout=18, | ||||
|             emails=["admin@example.com", "bob@example.com"], | ||||
|             args=["abc", "123"], | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|  | ||||
|         # test fire and forget | ||||
| @@ -600,6 +609,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "args": ["hello", "world"], | ||||
|             "timeout": 22, | ||||
|             "run_as_user": True, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|  | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -614,6 +624,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             timeout=25, | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=True, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         run_script.reset_mock() | ||||
|  | ||||
| @@ -629,6 +640,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "custom_field": custom_field.pk, | ||||
|             "save_all_output": True, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|  | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -644,6 +656,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             wait=True, | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         run_script.reset_mock() | ||||
|  | ||||
| @@ -662,6 +675,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "custom_field": custom_field.pk, | ||||
|             "save_all_output": False, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|  | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -677,6 +691,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             wait=True, | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         run_script.reset_mock() | ||||
|  | ||||
| @@ -697,6 +712,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "custom_field": custom_field.pk, | ||||
|             "save_all_output": False, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|  | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -712,6 +728,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             wait=True, | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         run_script.reset_mock() | ||||
|  | ||||
| @@ -729,6 +746,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|             "args": ["hello", "world"], | ||||
|             "timeout": 22, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|         } | ||||
|  | ||||
|         r = self.client.post(url, data, format="json") | ||||
| @@ -744,11 +762,73 @@ class TestAgentViews(TacticalTestCase): | ||||
|             wait=True, | ||||
|             history_pk=hist.pk, | ||||
|             run_as_user=False, | ||||
|             env_vars=["hello=world", "foo=bar"], | ||||
|         ) | ||||
|         run_script.reset_mock() | ||||
|  | ||||
|         self.assertEqual(Note.objects.get(agent=self.agent).note, "ok") | ||||
|  | ||||
|         # test run on server | ||||
|         with patch("core.utils.run_server_script") as mock_run_server_script: | ||||
|             mock_run_server_script.return_value = ("output", "error", 1.23456789, 0) | ||||
|             data = { | ||||
|                 "script": script.pk, | ||||
|                 "output": "wait", | ||||
|                 "args": ["arg1", "arg2"], | ||||
|                 "timeout": 15, | ||||
|                 "run_as_user": False, | ||||
|                 "env_vars": ["key1=val1", "key2=val2"], | ||||
|                 "run_on_server": True, | ||||
|             } | ||||
|  | ||||
|             r = self.client.post(url, data, format="json") | ||||
|             self.assertEqual(r.status_code, 200) | ||||
|             hist = AgentHistory.objects.filter(agent=self.agent, script=script).last() | ||||
|             if not hist: | ||||
|                 raise AgentHistory.DoesNotExist | ||||
|  | ||||
|             mock_run_server_script.assert_called_with( | ||||
|                 body=script.script_body, | ||||
|                 args=script.parse_script_args(self.agent, script.shell, data["args"]), | ||||
|                 env_vars=script.parse_script_env_vars( | ||||
|                     self.agent, script.shell, data["env_vars"] | ||||
|                 ), | ||||
|                 shell=script.shell, | ||||
|                 timeout=18, | ||||
|             ) | ||||
|  | ||||
|             expected_ret = { | ||||
|                 "stdout": "output", | ||||
|                 "stderr": "error", | ||||
|                 "execution_time": "1.2346", | ||||
|                 "retcode": 0, | ||||
|             } | ||||
|  | ||||
|             self.assertEqual(r.data, expected_ret) | ||||
|  | ||||
|             hist.refresh_from_db() | ||||
|             expected_script_results = {**expected_ret, "id": hist.pk} | ||||
|             self.assertEqual(hist.script_results, expected_script_results) | ||||
|  | ||||
|             # test run on server with server scripts disabled | ||||
|             with patch( | ||||
|                 "core.models.CoreSettings.server_scripts_enabled", | ||||
|                 new_callable=PropertyMock, | ||||
|             ) as server_scripts_enabled: | ||||
|                 server_scripts_enabled.return_value = False | ||||
|  | ||||
|                 data = { | ||||
|                     "script": script.pk, | ||||
|                     "output": "wait", | ||||
|                     "args": ["arg1", "arg2"], | ||||
|                     "timeout": 15, | ||||
|                     "run_as_user": False, | ||||
|                     "env_vars": ["key1=val1", "key2=val2"], | ||||
|                     "run_on_server": True, | ||||
|                 } | ||||
|                 r = self.client.post(url, data, format="json") | ||||
|                 self.assertEqual(r.status_code, 400) | ||||
|  | ||||
|     def test_get_notes(self): | ||||
|         url = f"{base_url}/notes/" | ||||
|  | ||||
| @@ -836,7 +916,6 @@ class TestAgentViews(TacticalTestCase): | ||||
|         self.check_not_authenticated("delete", url) | ||||
|  | ||||
|     def test_get_agent_history(self): | ||||
|  | ||||
|         # setup data | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         history = baker.make("agents.AgentHistory", agent=agent, _quantity=30) | ||||
| @@ -848,7 +927,7 @@ class TestAgentViews(TacticalTestCase): | ||||
|  | ||||
|         # test pulling data | ||||
|         r = self.client.get(url, format="json") | ||||
|         ctx = {"default_tz": pytz.timezone("America/Los_Angeles")} | ||||
|         ctx = {"default_tz": ZoneInfo("America/Los_Angeles")} | ||||
|         data = AgentHistorySerializer(history, many=True, context=ctx).data | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|         self.assertEqual(r.data, data)  # type:ignore | ||||
| @@ -992,7 +1071,6 @@ class TestAgentPermissions(TacticalTestCase): | ||||
|     @patch("time.sleep") | ||||
|     @patch("agents.models.Agent.nats_cmd", return_value="ok") | ||||
|     def test_agent_actions_permissions(self, nats_cmd, sleep): | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         unauthorized_agent = baker.make_recipe("agents.agent") | ||||
|  | ||||
| @@ -1003,7 +1081,6 @@ class TestAgentPermissions(TacticalTestCase): | ||||
|             {"method": "post", "action": "recover", "role": "can_recover_agents"}, | ||||
|             {"method": "post", "action": "reboot", "role": "can_reboot_agents"}, | ||||
|             {"method": "patch", "action": "reboot", "role": "can_reboot_agents"}, | ||||
|             {"method": "get", "action": "ping", "role": "can_ping_agents"}, | ||||
|             {"method": "get", "action": "meshcentral", "role": "can_use_mesh"}, | ||||
|             {"method": "post", "action": "meshcentral/recover", "role": "can_use_mesh"}, | ||||
|             {"method": "get", "action": "processes", "role": "can_manage_procs"}, | ||||
| @@ -1120,7 +1197,6 @@ class TestAgentPermissions(TacticalTestCase): | ||||
|         self.assertEqual(len(response.data["agents"]), 7) | ||||
|  | ||||
|     def test_generating_agent_installer_permissions(self): | ||||
|  | ||||
|         client = baker.make("clients.Client") | ||||
|         client_site = baker.make("clients.Site", client=client) | ||||
|         site = baker.make("clients.Site") | ||||
| @@ -1183,7 +1259,6 @@ class TestAgentPermissions(TacticalTestCase): | ||||
|         self.check_not_authorized("post", url, data) | ||||
|  | ||||
|     def test_agent_notes_permissions(self): | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         notes = baker.make("agents.Note", agent=agent, _quantity=5) | ||||
|  | ||||
| @@ -1272,9 +1347,9 @@ class TestAgentPermissions(TacticalTestCase): | ||||
|  | ||||
|         sites = baker.make("clients.Site", _quantity=2) | ||||
|         agent = baker.make_recipe("agents.agent", site=sites[0]) | ||||
|         history = baker.make("agents.AgentHistory", agent=agent, _quantity=5) | ||||
|         history = baker.make("agents.AgentHistory", agent=agent, _quantity=5)  # noqa | ||||
|         unauthorized_agent = baker.make_recipe("agents.agent", site=sites[1]) | ||||
|         unauthorized_history = baker.make( | ||||
|         unauthorized_history = baker.make(  # noqa | ||||
|             "agents.AgentHistory", agent=unauthorized_agent, _quantity=6 | ||||
|         ) | ||||
|  | ||||
|   | ||||
| @@ -15,6 +15,7 @@ urlpatterns = [ | ||||
|     path("<agent:agent_id>/wmi/", views.WMI.as_view()), | ||||
|     path("<agent:agent_id>/recover/", views.recover), | ||||
|     path("<agent:agent_id>/reboot/", views.Reboot.as_view()), | ||||
|     path("<agent:agent_id>/shutdown/", views.Shutdown.as_view()), | ||||
|     path("<agent:agent_id>/ping/", views.ping), | ||||
|     # alias for checks get view | ||||
|     path("<agent:agent_id>/checks/", GetAddChecks.as_view()), | ||||
| @@ -42,4 +43,6 @@ urlpatterns = [ | ||||
|     path("update/", views.update_agents), | ||||
|     path("installer/", views.install_agent), | ||||
|     path("bulkrecovery/", views.bulk_agent_recovery), | ||||
|     path("scripthistory/", views.ScriptRunHistory.as_view()), | ||||
|     path("<agent:agent_id>/wol/", views.wol), | ||||
| ] | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import asyncio | ||||
| import tempfile | ||||
| import urllib.parse | ||||
| from io import StringIO | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
| @@ -34,7 +34,6 @@ def generate_linux_install( | ||||
|     api: str, | ||||
|     download_url: str, | ||||
| ) -> FileResponse: | ||||
|  | ||||
|     match arch: | ||||
|         case "amd64": | ||||
|             arch_id = MeshAgentIdent.LINUX64 | ||||
| @@ -70,11 +69,8 @@ def generate_linux_install( | ||||
|     for i, j in replace.items(): | ||||
|         text = text.replace(i, j) | ||||
|  | ||||
|     with tempfile.NamedTemporaryFile() as fp: | ||||
|         with open(fp.name, "w") as f: | ||||
|             f.write(text) | ||||
|             f.write("\n") | ||||
|  | ||||
|     text += "\n" | ||||
|     with StringIO(text) as fp: | ||||
|         return FileResponse( | ||||
|             open(fp.name, "rb"), as_attachment=True, filename="linux_agent_install.sh" | ||||
|             fp.read(), as_attachment=True, filename="linux_agent_install.sh" | ||||
|         ) | ||||
|   | ||||
| @@ -1,16 +1,17 @@ | ||||
| import asyncio | ||||
| import datetime as dt | ||||
| import os | ||||
| import random | ||||
| import string | ||||
| import time | ||||
| from io import StringIO | ||||
| from pathlib import Path | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db.models import Count, Exists, OuterRef, Prefetch, Q | ||||
| from django.db.models import Exists, OuterRef, Prefetch, Q | ||||
| from django.http import HttpResponse | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from django.utils import timezone as djangotime | ||||
| from django.utils.dateparse import parse_datetime | ||||
| from meshctrl.utils import get_login_token | ||||
| from packaging import version as pyver | ||||
| from rest_framework import serializers | ||||
| @@ -20,15 +21,17 @@ from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from core.tasks import sync_mesh_perms_task | ||||
| from core.utils import ( | ||||
|     get_core_settings, | ||||
|     get_mesh_ws_url, | ||||
|     remove_mesh_agent, | ||||
|     token_is_valid, | ||||
|     wake_on_lan, | ||||
| ) | ||||
| from logs.models import AuditLog, DebugLog, PendingAction | ||||
| from scripts.models import Script | ||||
| from scripts.tasks import handle_bulk_command_task, handle_bulk_script_task | ||||
| from scripts.tasks import bulk_command_task, bulk_script_task | ||||
| from tacticalrmm.constants import ( | ||||
|     AGENT_DEFER, | ||||
|     AGENT_STATUS_OFFLINE, | ||||
| @@ -41,7 +44,6 @@ from tacticalrmm.constants import ( | ||||
|     DebugLogType, | ||||
|     EvtLogNames, | ||||
|     PAAction, | ||||
|     PAStatus, | ||||
| ) | ||||
| from tacticalrmm.helpers import date_is_in_past, notify_error | ||||
| from tacticalrmm.permissions import ( | ||||
| @@ -50,7 +52,7 @@ from tacticalrmm.permissions import ( | ||||
|     _has_perm_on_site, | ||||
| ) | ||||
| from tacticalrmm.utils import get_default_timezone, reload_nats | ||||
| from winupdate.models import WinUpdate | ||||
| from winupdate.models import WinUpdate, WinUpdatePolicy | ||||
| from winupdate.serializers import WinUpdatePolicySerializer | ||||
| from winupdate.tasks import bulk_check_for_updates_task, bulk_install_updates_task | ||||
|  | ||||
| @@ -59,11 +61,11 @@ from .permissions import ( | ||||
|     AgentHistoryPerms, | ||||
|     AgentNotesPerms, | ||||
|     AgentPerms, | ||||
|     AgentWOLPerms, | ||||
|     EvtLogPerms, | ||||
|     InstallAgentPerms, | ||||
|     ManageProcPerms, | ||||
|     MeshPerms, | ||||
|     PingAgentPerms, | ||||
|     RebootAgentPerms, | ||||
|     RecoverAgentPerms, | ||||
|     RunBulkPerms, | ||||
| @@ -135,19 +137,17 @@ class GetAgents(APIView): | ||||
|                         "checkresults", | ||||
|                         queryset=CheckResult.objects.select_related("assigned_check"), | ||||
|                     ), | ||||
|                 ) | ||||
|                 .annotate( | ||||
|                     pending_actions_count=Count( | ||||
|                         "pendingactions", | ||||
|                         filter=Q(pendingactions__status=PAStatus.PENDING), | ||||
|                     ) | ||||
|                     Prefetch( | ||||
|                         "custom_fields", | ||||
|                         queryset=AgentCustomField.objects.select_related("field"), | ||||
|                     ), | ||||
|                 ) | ||||
|                 .annotate( | ||||
|                     has_patches_pending=Exists( | ||||
|                         WinUpdate.objects.filter( | ||||
|                             agent_id=OuterRef("pk"), action="approve", installed=False | ||||
|                         ) | ||||
|                     ) | ||||
|                     ), | ||||
|                 ) | ||||
|             ) | ||||
|             serializer = AgentTableSerializer(agents, many=True) | ||||
| @@ -190,7 +190,36 @@ class GetUpdateDeleteAgent(APIView): | ||||
|  | ||||
|     # get agent details | ||||
|     def get(self, request, agent_id): | ||||
|         agent = get_object_or_404(Agent, agent_id=agent_id) | ||||
|         from checks.models import Check, CheckResult | ||||
|  | ||||
|         agent = get_object_or_404( | ||||
|             Agent.objects.select_related( | ||||
|                 "site__server_policy", | ||||
|                 "site__workstation_policy", | ||||
|                 "site__client__server_policy", | ||||
|                 "site__client__workstation_policy", | ||||
|                 "policy", | ||||
|                 "alert_template", | ||||
|             ).prefetch_related( | ||||
|                 Prefetch( | ||||
|                     "agentchecks", | ||||
|                     queryset=Check.objects.select_related("script"), | ||||
|                 ), | ||||
|                 Prefetch( | ||||
|                     "checkresults", | ||||
|                     queryset=CheckResult.objects.select_related("assigned_check"), | ||||
|                 ), | ||||
|                 Prefetch( | ||||
|                     "custom_fields", | ||||
|                     queryset=AgentCustomField.objects.select_related("field"), | ||||
|                 ), | ||||
|                 Prefetch( | ||||
|                     "winupdatepolicy", | ||||
|                     queryset=WinUpdatePolicy.objects.select_related("agent", "policy"), | ||||
|                 ), | ||||
|             ), | ||||
|             agent_id=agent_id, | ||||
|         ) | ||||
|         return Response(AgentSerializer(agent).data) | ||||
|  | ||||
|     # edit agent | ||||
| @@ -210,9 +239,7 @@ class GetUpdateDeleteAgent(APIView): | ||||
|             p_serializer.save() | ||||
|  | ||||
|         if "custom_fields" in request.data.keys(): | ||||
|  | ||||
|             for field in request.data["custom_fields"]: | ||||
|  | ||||
|                 custom_field = field | ||||
|                 custom_field["agent"] = agent.pk | ||||
|  | ||||
| @@ -232,6 +259,7 @@ class GetUpdateDeleteAgent(APIView): | ||||
|                     serializer.is_valid(raise_exception=True) | ||||
|                     serializer.save() | ||||
|  | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return Response("The agent was updated successfully") | ||||
|  | ||||
|     # uninstall agent | ||||
| @@ -257,6 +285,7 @@ class GetUpdateDeleteAgent(APIView): | ||||
|                 message=f"Unable to remove agent {name} from meshcentral database: {e}", | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|             ) | ||||
|         sync_mesh_perms_task.delay() | ||||
|         return Response(f"{name} will now be uninstalled.") | ||||
|  | ||||
|  | ||||
| @@ -299,13 +328,13 @@ class AgentMeshCentral(APIView): | ||||
|         agent = get_object_or_404(Agent, agent_id=agent_id) | ||||
|         core = get_core_settings() | ||||
|  | ||||
|         if not core.mesh_disable_auto_login: | ||||
|             token = get_login_token( | ||||
|                 key=core.mesh_token, user=f"user//{core.mesh_username}" | ||||
|             ) | ||||
|             token_param = f"login={token}&" | ||||
|         else: | ||||
|             token_param = "" | ||||
|         user = ( | ||||
|             request.user.mesh_user_id | ||||
|             if core.sync_mesh_with_trmm | ||||
|             else f"user//{core.mesh_api_superuser}" | ||||
|         ) | ||||
|         token = get_login_token(key=core.mesh_token, user=user) | ||||
|         token_param = f"login={token}&" | ||||
|  | ||||
|         control = f"{core.mesh_site}/?{token_param}gotonode={agent.mesh_node_id}&viewmode=11&hide=31" | ||||
|         terminal = f"{core.mesh_site}/?{token_param}gotonode={agent.mesh_node_id}&viewmode=12&hide=31" | ||||
| @@ -375,7 +404,7 @@ def update_agents(request): | ||||
|  | ||||
|  | ||||
| @api_view(["GET"]) | ||||
| @permission_classes([IsAuthenticated, PingAgentPerms]) | ||||
| @permission_classes([IsAuthenticated, AgentPerms]) | ||||
| def ping(request, agent_id): | ||||
|     agent = get_object_or_404(Agent, agent_id=agent_id) | ||||
|     status = AGENT_STATUS_OFFLINE | ||||
| @@ -465,8 +494,22 @@ def send_raw_cmd(request, agent_id): | ||||
|     return Response(r) | ||||
|  | ||||
|  | ||||
| class Shutdown(APIView): | ||||
|     permission_classes = [IsAuthenticated, RebootAgentPerms] | ||||
|  | ||||
|     # shutdown | ||||
|     def post(self, request, agent_id): | ||||
|         agent = get_object_or_404(Agent, agent_id=agent_id) | ||||
|         r = asyncio.run(agent.nats_cmd({"func": "shutdown"}, timeout=10)) | ||||
|         if r != "ok": | ||||
|             return notify_error("Unable to contact the agent") | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class Reboot(APIView): | ||||
|     permission_classes = [IsAuthenticated, RebootAgentPerms] | ||||
|  | ||||
|     # reboot now | ||||
|     def post(self, request, agent_id): | ||||
|         agent = get_object_or_404(Agent, agent_id=agent_id) | ||||
| @@ -542,6 +585,20 @@ def install_agent(request): | ||||
|     from agents.utils import get_agent_url | ||||
|     from core.utils import token_is_valid | ||||
|  | ||||
|     insecure = getattr(settings, "TRMM_INSECURE", False) | ||||
|  | ||||
|     if insecure and request.data["installMethod"] in {"exe", "powershell"}: | ||||
|         return notify_error( | ||||
|             "Not available in insecure mode. Please use the 'Manual' method." | ||||
|         ) | ||||
|  | ||||
|     # TODO rework this ghetto validation hack | ||||
|     # https://github.com/amidaware/tacticalrmm/issues/1461 | ||||
|     try: | ||||
|         int(request.data["expires"]) | ||||
|     except ValueError: | ||||
|         return notify_error("Please enter a valid number of hours") | ||||
|  | ||||
|     client_id = request.data["client"] | ||||
|     site_id = request.data["site"] | ||||
|     version = settings.LATEST_AGENT_VER | ||||
| @@ -555,7 +612,7 @@ def install_agent(request): | ||||
|  | ||||
|     if request.data["installMethod"] in {"bash", "mac"} and not is_valid: | ||||
|         return notify_error( | ||||
|             "Missing code signing token, or token is no longer valid. Please read the docs for more info." | ||||
|             "Linux/Mac agents require code signing. Please see https://docs.tacticalrmm.com/code_signing/ for more info." | ||||
|         ) | ||||
|  | ||||
|     inno = f"tacticalagent-v{version}-{plat}-{goarch}" | ||||
| @@ -567,7 +624,7 @@ def install_agent(request): | ||||
|     installer_user = User.objects.filter(is_installer_user=True).first() | ||||
|  | ||||
|     _, token = AuthToken.objects.create( | ||||
|         user=installer_user, expiry=dt.timedelta(hours=request.data["expires"]) | ||||
|         user=installer_user, expiry=dt.timedelta(hours=int(request.data["expires"])) | ||||
|     ) | ||||
|  | ||||
|     install_flags = [ | ||||
| @@ -602,7 +659,6 @@ def install_agent(request): | ||||
|         ) | ||||
|  | ||||
|     elif request.data["installMethod"] == "bash": | ||||
|  | ||||
|         from agents.utils import generate_linux_install | ||||
|  | ||||
|         return generate_linux_install( | ||||
| @@ -638,6 +694,9 @@ def install_agent(request): | ||||
|             if int(request.data["power"]): | ||||
|                 cmd.append("--power") | ||||
|  | ||||
|             if insecure: | ||||
|                 cmd.append("--insecure") | ||||
|  | ||||
|             resp["cmd"] = " ".join(str(i) for i in cmd) | ||||
|         else: | ||||
|             install_flags.insert(0, f"sudo ./{inno}") | ||||
| @@ -646,13 +705,14 @@ def install_agent(request): | ||||
|             resp["cmd"] = ( | ||||
|                 dl + f" && chmod +x {inno} && " + " ".join(str(i) for i in cmd) | ||||
|             ) | ||||
|             if insecure: | ||||
|                 resp["cmd"] += " --insecure" | ||||
|  | ||||
|         resp["url"] = download_url | ||||
|  | ||||
|         return Response(resp) | ||||
|  | ||||
|     elif request.data["installMethod"] == "powershell": | ||||
|  | ||||
|         text = Path(settings.BASE_DIR / "core" / "installer.ps1").read_text() | ||||
|  | ||||
|         replace_dict = { | ||||
| @@ -671,26 +731,9 @@ def install_agent(request): | ||||
|         for i, j in replace_dict.items(): | ||||
|             text = text.replace(i, j) | ||||
|  | ||||
|         file_name = "rmm-installer.ps1" | ||||
|         ps1 = os.path.join(settings.EXE_DIR, file_name) | ||||
|  | ||||
|         if os.path.exists(ps1): | ||||
|             try: | ||||
|                 os.remove(ps1) | ||||
|             except Exception as e: | ||||
|                 DebugLog.error(message=str(e)) | ||||
|  | ||||
|         Path(ps1).write_text(text) | ||||
|  | ||||
|         if settings.DEBUG: | ||||
|             with open(ps1, "r") as f: | ||||
|                 response = HttpResponse(f.read(), content_type="text/plain") | ||||
|                 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}" | ||||
|         with StringIO(text) as fp: | ||||
|             response = HttpResponse(fp.read(), content_type="text/plain") | ||||
|             response["Content-Disposition"] = "attachment; filename=rmm-installer.ps1" | ||||
|             return response | ||||
|  | ||||
|  | ||||
| @@ -723,7 +766,12 @@ def run_script(request, agent_id): | ||||
|     output = request.data["output"] | ||||
|     args = request.data["args"] | ||||
|     run_as_user: bool = request.data["run_as_user"] | ||||
|     env_vars: list[str] = request.data["env_vars"] | ||||
|     req_timeout = int(request.data["timeout"]) + 3 | ||||
|     run_on_server: bool | None = request.data.get("run_on_server") | ||||
|  | ||||
|     if run_on_server and not get_core_settings().server_scripts_enabled: | ||||
|         return notify_error("This feature is disabled.") | ||||
|  | ||||
|     AuditLog.audit_script_run( | ||||
|         username=request.user.username, | ||||
| @@ -740,6 +788,29 @@ def run_script(request, agent_id): | ||||
|     ) | ||||
|     history_pk = hist.pk | ||||
|  | ||||
|     if run_on_server: | ||||
|         from core.utils import run_server_script | ||||
|  | ||||
|         r = run_server_script( | ||||
|             body=script.script_body, | ||||
|             args=script.parse_script_args(agent, script.shell, args), | ||||
|             env_vars=script.parse_script_env_vars(agent, script.shell, env_vars), | ||||
|             shell=script.shell, | ||||
|             timeout=req_timeout, | ||||
|         ) | ||||
|  | ||||
|         ret = { | ||||
|             "stdout": r[0], | ||||
|             "stderr": r[1], | ||||
|             "execution_time": "{:.4f}".format(r[2]), | ||||
|             "retcode": r[3], | ||||
|         } | ||||
|  | ||||
|         hist.script_results = {**ret, "id": history_pk} | ||||
|         hist.save(update_fields=["script_results"]) | ||||
|  | ||||
|         return Response(ret) | ||||
|  | ||||
|     if output == "wait": | ||||
|         r = agent.run_script( | ||||
|             scriptpk=script.pk, | ||||
| @@ -748,6 +819,7 @@ def run_script(request, agent_id): | ||||
|             wait=True, | ||||
|             history_pk=history_pk, | ||||
|             run_as_user=run_as_user, | ||||
|             env_vars=env_vars, | ||||
|         ) | ||||
|         return Response(r) | ||||
|  | ||||
| @@ -761,7 +833,9 @@ def run_script(request, agent_id): | ||||
|             nats_timeout=req_timeout, | ||||
|             emails=emails, | ||||
|             args=args, | ||||
|             history_pk=history_pk, | ||||
|             run_as_user=run_as_user, | ||||
|             env_vars=env_vars, | ||||
|         ) | ||||
|     elif output == "collector": | ||||
|         from core.models import CustomField | ||||
| @@ -773,6 +847,7 @@ def run_script(request, agent_id): | ||||
|             wait=True, | ||||
|             history_pk=history_pk, | ||||
|             run_as_user=run_as_user, | ||||
|             env_vars=env_vars, | ||||
|         ) | ||||
|  | ||||
|         custom_field = CustomField.objects.get(pk=request.data["custom_field"]) | ||||
| @@ -802,6 +877,7 @@ def run_script(request, agent_id): | ||||
|             wait=True, | ||||
|             history_pk=history_pk, | ||||
|             run_as_user=run_as_user, | ||||
|             env_vars=env_vars, | ||||
|         ) | ||||
|  | ||||
|         Note.objects.create(agent=agent, user=request.user, note=r) | ||||
| @@ -813,6 +889,7 @@ def run_script(request, agent_id): | ||||
|             timeout=req_timeout, | ||||
|             history_pk=history_pk, | ||||
|             run_as_user=run_as_user, | ||||
|             env_vars=env_vars, | ||||
|         ) | ||||
|  | ||||
|     return Response(f"{script.name} will now be run on {agent.hostname}") | ||||
| @@ -928,7 +1005,7 @@ def bulk(request): | ||||
|     agents: list[int] = [agent.pk for agent in q] | ||||
|  | ||||
|     if not agents: | ||||
|         return notify_error("No agents where found meeting the selected criteria") | ||||
|         return notify_error("No agents were found meeting the selected criteria") | ||||
|  | ||||
|     AuditLog.audit_bulk_action( | ||||
|         request.user, | ||||
| @@ -937,36 +1014,53 @@ def bulk(request): | ||||
|         debug_info={"ip": request._client_ip}, | ||||
|     ) | ||||
|  | ||||
|     ht = "Check the History tab on the agent to view the results." | ||||
|  | ||||
|     if request.data["mode"] == "command": | ||||
|         if request.data["shell"] == "custom" and request.data["custom_shell"]: | ||||
|             shell = request.data["custom_shell"] | ||||
|         else: | ||||
|             shell = request.data["shell"] | ||||
|  | ||||
|         handle_bulk_command_task.delay( | ||||
|             agents, | ||||
|             request.data["cmd"], | ||||
|             shell, | ||||
|             request.data["timeout"], | ||||
|             request.user.username[:50], | ||||
|             request.data["run_as_user"], | ||||
|         bulk_command_task.delay( | ||||
|             agent_pks=agents, | ||||
|             cmd=request.data["cmd"], | ||||
|             shell=shell, | ||||
|             timeout=request.data["timeout"], | ||||
|             username=request.user.username[:50], | ||||
|             run_as_user=request.data["run_as_user"], | ||||
|         ) | ||||
|         return Response(f"Command will now be run on {len(agents)} agents") | ||||
|         return Response(f"Command will now be run on {len(agents)} agents. {ht}") | ||||
|  | ||||
|     elif request.data["mode"] == "script": | ||||
|         script = get_object_or_404(Script, pk=request.data["script"]) | ||||
|         handle_bulk_script_task.delay( | ||||
|             script.pk, | ||||
|             agents, | ||||
|             request.data["args"], | ||||
|             request.data["timeout"], | ||||
|             request.user.username[:50], | ||||
|             request.data["run_as_user"], | ||||
|  | ||||
|         # prevent API from breaking for those who haven't updated payload | ||||
|         try: | ||||
|             custom_field_pk = request.data["custom_field"] | ||||
|             collector_all_output = request.data["collector_all_output"] | ||||
|             save_to_agent_note = request.data["save_to_agent_note"] | ||||
|         except KeyError: | ||||
|             custom_field_pk = None | ||||
|             collector_all_output = False | ||||
|             save_to_agent_note = False | ||||
|  | ||||
|         bulk_script_task.delay( | ||||
|             script_pk=script.pk, | ||||
|             agent_pks=agents, | ||||
|             args=request.data["args"], | ||||
|             timeout=request.data["timeout"], | ||||
|             username=request.user.username[:50], | ||||
|             run_as_user=request.data["run_as_user"], | ||||
|             env_vars=request.data["env_vars"], | ||||
|             custom_field_pk=custom_field_pk, | ||||
|             collector_all_output=collector_all_output, | ||||
|             save_to_agent_note=save_to_agent_note, | ||||
|         ) | ||||
|         return Response(f"{script.name} will now be run on {len(agents)} agents") | ||||
|  | ||||
|         return Response(f"{script.name} will now be run on {len(agents)} agents. {ht}") | ||||
|  | ||||
|     elif request.data["mode"] == "patch": | ||||
|  | ||||
|         if request.data["patchMode"] == "install": | ||||
|             bulk_install_updates_task.delay(agents) | ||||
|             return Response( | ||||
| @@ -982,7 +1076,6 @@ def bulk(request): | ||||
| @api_view(["POST"]) | ||||
| @permission_classes([IsAuthenticated, AgentPerms]) | ||||
| def agent_maintenance(request): | ||||
|  | ||||
|     if request.data["type"] == "Client": | ||||
|         if not _has_perm_on_client(request.user, request.data["id"]): | ||||
|             raise PermissionDenied() | ||||
| @@ -1011,7 +1104,7 @@ def agent_maintenance(request): | ||||
|         return Response(f"Maintenance mode has been {action} on {count} agents") | ||||
|  | ||||
|     return Response( | ||||
|         f"No agents have been put in maintenance mode. You might not have permissions to the resources." | ||||
|         "No agents have been put in maintenance mode. You might not have permissions to the resources." | ||||
|     ) | ||||
|  | ||||
|  | ||||
| @@ -1044,3 +1137,113 @@ class AgentHistoryView(APIView): | ||||
|             history = AgentHistory.objects.filter_by_role(request.user)  # type: ignore | ||||
|         ctx = {"default_tz": get_default_timezone()} | ||||
|         return Response(AgentHistorySerializer(history, many=True, context=ctx).data) | ||||
|  | ||||
|  | ||||
| class ScriptRunHistory(APIView): | ||||
|     permission_classes = [IsAuthenticated, AgentHistoryPerms] | ||||
|  | ||||
|     class OutputSerializer(serializers.ModelSerializer): | ||||
|         script_name = serializers.ReadOnlyField(source="script.name") | ||||
|         agent_id = serializers.ReadOnlyField(source="agent.agent_id") | ||||
|  | ||||
|         class Meta: | ||||
|             model = AgentHistory | ||||
|             fields = ( | ||||
|                 "id", | ||||
|                 "time", | ||||
|                 "username", | ||||
|                 "script", | ||||
|                 "script_results", | ||||
|                 "agent", | ||||
|                 "script_name", | ||||
|                 "agent_id", | ||||
|             ) | ||||
|             read_only_fields = fields | ||||
|  | ||||
|     def get(self, request): | ||||
|         date_range_filter = Q() | ||||
|         script_name_filter = Q() | ||||
|  | ||||
|         start = request.query_params.get("start", None) | ||||
|         end = request.query_params.get("end", None) | ||||
|         limit = request.query_params.get("limit", None) | ||||
|         script_name = request.query_params.get("scriptname", None) | ||||
|         if start and end: | ||||
|             start_dt = parse_datetime(start) | ||||
|             end_dt = parse_datetime(end) + djangotime.timedelta(days=1) | ||||
|             date_range_filter = Q(time__range=[start_dt, end_dt]) | ||||
|  | ||||
|         if script_name: | ||||
|             script_name_filter = Q(script__name=script_name) | ||||
|  | ||||
|         AGENT_R_DEFER = ( | ||||
|             "agent__wmi_detail", | ||||
|             "agent__services", | ||||
|             "agent__created_by", | ||||
|             "agent__created_time", | ||||
|             "agent__modified_by", | ||||
|             "agent__modified_time", | ||||
|             "agent__disks", | ||||
|             "agent__operating_system", | ||||
|             "agent__mesh_node_id", | ||||
|             "agent__description", | ||||
|             "agent__patches_last_installed", | ||||
|             "agent__time_zone", | ||||
|             "agent__alert_template_id", | ||||
|             "agent__policy_id", | ||||
|             "agent__site_id", | ||||
|             "agent__version", | ||||
|             "agent__plat", | ||||
|             "agent__goarch", | ||||
|             "agent__hostname", | ||||
|             "agent__last_seen", | ||||
|             "agent__public_ip", | ||||
|             "agent__total_ram", | ||||
|             "agent__boot_time", | ||||
|             "agent__logged_in_username", | ||||
|             "agent__last_logged_in_user", | ||||
|             "agent__monitoring_type", | ||||
|             "agent__overdue_email_alert", | ||||
|             "agent__overdue_text_alert", | ||||
|             "agent__overdue_dashboard_alert", | ||||
|             "agent__offline_time", | ||||
|             "agent__overdue_time", | ||||
|             "agent__check_interval", | ||||
|             "agent__needs_reboot", | ||||
|             "agent__choco_installed", | ||||
|             "agent__maintenance_mode", | ||||
|             "agent__block_policy_inheritance", | ||||
|         ) | ||||
|         hists = ( | ||||
|             AgentHistory.objects.filter(type=AgentHistoryType.SCRIPT_RUN) | ||||
|             .select_related("agent") | ||||
|             .select_related("script") | ||||
|             .defer(*AGENT_R_DEFER) | ||||
|             .filter(date_range_filter) | ||||
|             .filter(script_name_filter) | ||||
|             .order_by("-time") | ||||
|         ) | ||||
|         if limit: | ||||
|             try: | ||||
|                 lim = int(limit) | ||||
|             except KeyError: | ||||
|                 return notify_error("Invalid limit") | ||||
|             hists = hists[:lim] | ||||
|  | ||||
|         ret = self.OutputSerializer(hists, many=True).data | ||||
|         return Response(ret) | ||||
|  | ||||
|  | ||||
| @api_view(["POST"]) | ||||
| @permission_classes([IsAuthenticated, AgentWOLPerms]) | ||||
| def wol(request, agent_id): | ||||
|     agent = get_object_or_404( | ||||
|         Agent.objects.defer(*AGENT_DEFER), | ||||
|         agent_id=agent_id, | ||||
|     ) | ||||
|     try: | ||||
|         uri = get_mesh_ws_url() | ||||
|         asyncio.run(wake_on_lan(uri=uri, mesh_node_id=agent.mesh_node_id)) | ||||
|     except Exception as e: | ||||
|         return notify_error(str(e)) | ||||
|     return Response(f"Wake-on-LAN sent to {agent.hostname}") | ||||
|   | ||||
| @@ -0,0 +1,36 @@ | ||||
| # Generated by Django 4.1.3 on 2022-11-26 20:22 | ||||
|  | ||||
| import django.contrib.postgres.fields | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("alerts", "0012_alter_alert_action_retcode_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="alerttemplate", | ||||
|             name="action_env_vars", | ||||
|             field=django.contrib.postgres.fields.ArrayField( | ||||
|                 base_field=models.TextField(blank=True, null=True), | ||||
|                 blank=True, | ||||
|                 default=list, | ||||
|                 null=True, | ||||
|                 size=None, | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="alerttemplate", | ||||
|             name="resolved_action_env_vars", | ||||
|             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,55 @@ | ||||
| # Generated by Django 4.2.13 on 2024-06-28 20:21 | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("core", "0045_coresettings_enable_server_scripts_and_more"), | ||||
|         ("alerts", "0013_alerttemplate_action_env_vars_and_more"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="alerttemplate", | ||||
|             name="action_rest", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="url_action_alert_template", | ||||
|                 to="core.urlaction", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="alerttemplate", | ||||
|             name="action_type", | ||||
|             field=models.CharField( | ||||
|                 choices=[("script", "Script"), ("server", "Server"), ("rest", "Rest")], | ||||
|                 default="script", | ||||
|                 max_length=10, | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="alerttemplate", | ||||
|             name="resolved_action_rest", | ||||
|             field=models.ForeignKey( | ||||
|                 blank=True, | ||||
|                 null=True, | ||||
|                 on_delete=django.db.models.deletion.SET_NULL, | ||||
|                 related_name="resolved_url_action_alert_template", | ||||
|                 to="core.urlaction", | ||||
|             ), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name="alerttemplate", | ||||
|             name="resolved_action_type", | ||||
|             field=models.CharField( | ||||
|                 choices=[("script", "Script"), ("server", "Server"), ("rest", "Rest")], | ||||
|                 default="script", | ||||
|                 max_length=10, | ||||
|             ), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,6 +1,5 @@ | ||||
| from __future__ import annotations | ||||
|  | ||||
| import re | ||||
| from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast | ||||
|  | ||||
| from django.contrib.postgres.fields import ArrayField | ||||
| @@ -8,15 +7,20 @@ from django.db import models | ||||
| from django.db.models.fields import BooleanField, PositiveIntegerField | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from core.utils import run_server_script, run_url_rest_action | ||||
| from logs.models import BaseAuditModel, DebugLog | ||||
| from tacticalrmm.constants import ( | ||||
|     AgentHistoryType, | ||||
|     AgentMonType, | ||||
|     AlertSeverity, | ||||
|     AlertTemplateActionType, | ||||
|     AlertType, | ||||
|     CheckType, | ||||
|     DebugLogType, | ||||
| ) | ||||
| from tacticalrmm.logger import logger | ||||
| from tacticalrmm.models import PermissionQuerySet | ||||
| from tacticalrmm.utils import RE_DB_VALUE, get_db_value | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from agents.models import Agent | ||||
| @@ -94,6 +98,15 @@ class Alert(models.Model): | ||||
|     def client(self) -> "Client": | ||||
|         return self.agent.client | ||||
|  | ||||
|     @property | ||||
|     def get_result(self): | ||||
|         if self.alert_type == AlertType.CHECK: | ||||
|             return self.assigned_check.checkresults.get(agent=self.agent) | ||||
|         elif self.alert_type == AlertType.TASK: | ||||
|             return self.assigned_task.taskresults.get(agent=self.agent) | ||||
|  | ||||
|         return None | ||||
|  | ||||
|     def resolve(self) -> None: | ||||
|         self.resolved = True | ||||
|         self.resolved_on = djangotime.now() | ||||
| @@ -105,6 +118,9 @@ class Alert(models.Model): | ||||
|     def create_or_return_availability_alert( | ||||
|         cls, agent: Agent, skip_create: bool = False | ||||
|     ) -> Optional[Alert]: | ||||
|         if agent.maintenance_mode: | ||||
|             return None | ||||
|  | ||||
|         if not cls.objects.filter( | ||||
|             agent=agent, alert_type=AlertType.AVAILABILITY, resolved=False | ||||
|         ).exists(): | ||||
| @@ -117,7 +133,7 @@ class Alert(models.Model): | ||||
|                     agent=agent, | ||||
|                     alert_type=AlertType.AVAILABILITY, | ||||
|                     severity=AlertSeverity.ERROR, | ||||
|                     message=f"{agent.hostname} in {agent.client.name}\\{agent.site.name} is overdue.", | ||||
|                     message=f"{agent.hostname} in {agent.client.name}, {agent.site.name} is overdue.", | ||||
|                     hidden=True, | ||||
|                 ), | ||||
|             ) | ||||
| @@ -153,6 +169,8 @@ class Alert(models.Model): | ||||
|         alert_severity: Optional[str] = None, | ||||
|         skip_create: bool = False, | ||||
|     ) -> "Optional[Alert]": | ||||
|         if agent.maintenance_mode: | ||||
|             return None | ||||
|  | ||||
|         # need to pass agent if the check is a policy | ||||
|         if not cls.objects.filter( | ||||
| @@ -169,15 +187,17 @@ class Alert(models.Model): | ||||
|                     assigned_check=check, | ||||
|                     agent=agent, | ||||
|                     alert_type=AlertType.CHECK, | ||||
|                     severity=check.alert_severity | ||||
|                     if check.check_type | ||||
|                     not in [ | ||||
|                         CheckType.MEMORY, | ||||
|                         CheckType.CPU_LOAD, | ||||
|                         CheckType.DISK_SPACE, | ||||
|                         CheckType.SCRIPT, | ||||
|                     ] | ||||
|                     else alert_severity, | ||||
|                     severity=( | ||||
|                         check.alert_severity | ||||
|                         if check.check_type | ||||
|                         not in { | ||||
|                             CheckType.MEMORY, | ||||
|                             CheckType.CPU_LOAD, | ||||
|                             CheckType.DISK_SPACE, | ||||
|                             CheckType.SCRIPT, | ||||
|                         } | ||||
|                         else alert_severity | ||||
|                     ), | ||||
|                     message=f"{agent.hostname} has a {check.check_type} check: {check.readable_desc} that failed.", | ||||
|                     hidden=True, | ||||
|                 ), | ||||
| @@ -216,6 +236,8 @@ class Alert(models.Model): | ||||
|         agent: "Agent", | ||||
|         skip_create: bool = False, | ||||
|     ) -> "Optional[Alert]": | ||||
|         if agent.maintenance_mode: | ||||
|             return None | ||||
|  | ||||
|         if not cls.objects.filter( | ||||
|             assigned_task=task, | ||||
| @@ -268,10 +290,12 @@ class Alert(models.Model): | ||||
|     def handle_alert_failure( | ||||
|         cls, instance: Union[Agent, TaskResult, CheckResult] | ||||
|     ) -> None: | ||||
|         from agents.models import Agent | ||||
|         from agents.models import Agent, AgentHistory | ||||
|         from autotasks.models import TaskResult | ||||
|         from checks.models import CheckResult | ||||
|         from core.models import CoreSettings | ||||
|  | ||||
|         core = CoreSettings.objects.first() | ||||
|         # set variables | ||||
|         dashboard_severities = None | ||||
|         email_severities = None | ||||
| @@ -282,7 +306,7 @@ class Alert(models.Model): | ||||
|         alert_interval = None | ||||
|         email_task = None | ||||
|         text_task = None | ||||
|         run_script_action = None | ||||
|         should_run_script_or_webhook = False | ||||
|  | ||||
|         # check what the instance passed is | ||||
|         if isinstance(instance, Agent): | ||||
| @@ -308,7 +332,7 @@ class Alert(models.Model): | ||||
|                 always_email = alert_template.agent_always_email | ||||
|                 always_text = alert_template.agent_always_text | ||||
|                 alert_interval = alert_template.agent_periodic_alert_days | ||||
|                 run_script_action = alert_template.agent_script_actions | ||||
|                 should_run_script_or_webhook = alert_template.agent_script_actions | ||||
|  | ||||
|         elif isinstance(instance, CheckResult): | ||||
|             from checks.tasks import ( | ||||
| @@ -327,12 +351,12 @@ class Alert(models.Model): | ||||
|             alert_severity = ( | ||||
|                 instance.assigned_check.alert_severity | ||||
|                 if instance.assigned_check.check_type | ||||
|                 not in [ | ||||
|                 not in { | ||||
|                     CheckType.MEMORY, | ||||
|                     CheckType.CPU_LOAD, | ||||
|                     CheckType.DISK_SPACE, | ||||
|                     CheckType.SCRIPT, | ||||
|                 ] | ||||
|                 } | ||||
|                 else instance.alert_severity | ||||
|             ) | ||||
|             agent = instance.agent | ||||
| @@ -341,28 +365,25 @@ class Alert(models.Model): | ||||
|             if alert_template: | ||||
|                 dashboard_severities = ( | ||||
|                     alert_template.check_dashboard_alert_severity | ||||
|                     if alert_template.check_dashboard_alert_severity | ||||
|                     else [ | ||||
|                     or [ | ||||
|                         AlertSeverity.ERROR, | ||||
|                         AlertSeverity.WARNING, | ||||
|                         AlertSeverity.INFO, | ||||
|                     ] | ||||
|                 ) | ||||
|                 email_severities = ( | ||||
|                     alert_template.check_email_alert_severity | ||||
|                     if alert_template.check_email_alert_severity | ||||
|                     else [AlertSeverity.ERROR, AlertSeverity.WARNING] | ||||
|                 ) | ||||
|                 text_severities = ( | ||||
|                     alert_template.check_text_alert_severity | ||||
|                     if alert_template.check_text_alert_severity | ||||
|                     else [AlertSeverity.ERROR, AlertSeverity.WARNING] | ||||
|                 ) | ||||
|                 email_severities = alert_template.check_email_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 text_severities = alert_template.check_text_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 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 | ||||
|                 run_script_action = alert_template.check_script_actions | ||||
|                 should_run_script_or_webhook = alert_template.check_script_actions | ||||
|  | ||||
|         elif isinstance(instance, TaskResult): | ||||
|             from autotasks.tasks import handle_task_email_alert, handle_task_sms_alert | ||||
| @@ -380,26 +401,23 @@ class Alert(models.Model): | ||||
|  | ||||
|             # set alert_template settings | ||||
|             if alert_template: | ||||
|                 dashboard_severities = ( | ||||
|                     alert_template.task_dashboard_alert_severity | ||||
|                     if alert_template.task_dashboard_alert_severity | ||||
|                     else [AlertSeverity.ERROR, AlertSeverity.WARNING] | ||||
|                 ) | ||||
|                 email_severities = ( | ||||
|                     alert_template.task_email_alert_severity | ||||
|                     if alert_template.task_email_alert_severity | ||||
|                     else [AlertSeverity.ERROR, AlertSeverity.WARNING] | ||||
|                 ) | ||||
|                 text_severities = ( | ||||
|                     alert_template.task_text_alert_severity | ||||
|                     if alert_template.task_text_alert_severity | ||||
|                     else [AlertSeverity.ERROR, AlertSeverity.WARNING] | ||||
|                 ) | ||||
|                 dashboard_severities = alert_template.task_dashboard_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 email_severities = alert_template.task_email_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 text_severities = alert_template.task_text_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 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 | ||||
|                 run_script_action = alert_template.task_script_actions | ||||
|                 should_run_script_or_webhook = alert_template.task_script_actions | ||||
|  | ||||
|         else: | ||||
|             return | ||||
| @@ -417,7 +435,6 @@ class Alert(models.Model): | ||||
|  | ||||
|         # create alert in dashboard if enabled | ||||
|         if dashboard_alert or always_dashboard: | ||||
|  | ||||
|             # check if alert template is set and specific severities are configured | ||||
|             if ( | ||||
|                 not alert_template | ||||
| @@ -428,13 +445,23 @@ class Alert(models.Model): | ||||
|                 alert.hidden = False | ||||
|                 alert.save(update_fields=["hidden"]) | ||||
|  | ||||
|         # TODO rework this | ||||
|         if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: | ||||
|             email_alert = False | ||||
|             always_email = False | ||||
|  | ||||
|         elif ( | ||||
|             alert.severity == AlertSeverity.WARNING | ||||
|             and not core.notify_on_warning_alerts | ||||
|         ): | ||||
|             email_alert = False | ||||
|             always_email = False | ||||
|  | ||||
|         # send email if enabled | ||||
|         if email_alert or always_email: | ||||
|  | ||||
|             # check if alert template is set and specific severities are configured | ||||
|             if ( | ||||
|                 not alert_template | ||||
|                 or alert_template | ||||
|             if not alert_template or ( | ||||
|                 alert_template | ||||
|                 and email_severities | ||||
|                 and alert.severity in email_severities | ||||
|             ): | ||||
| @@ -443,34 +470,89 @@ class Alert(models.Model): | ||||
|                     alert_interval=alert_interval, | ||||
|                 ) | ||||
|  | ||||
|         # TODO rework this | ||||
|         if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: | ||||
|             text_alert = False | ||||
|             always_text = False | ||||
|         elif ( | ||||
|             alert.severity == AlertSeverity.WARNING | ||||
|             and not core.notify_on_warning_alerts | ||||
|         ): | ||||
|             text_alert = False | ||||
|             always_text = False | ||||
|  | ||||
|         # send text if enabled | ||||
|         if text_alert or always_text: | ||||
|  | ||||
|             # check if alert template is set and specific severities are configured | ||||
|             if ( | ||||
|                 not alert_template | ||||
|                 or alert_template | ||||
|                 and text_severities | ||||
|                 and alert.severity in text_severities | ||||
|             if not alert_template or ( | ||||
|                 alert_template and text_severities and alert.severity in text_severities | ||||
|             ): | ||||
|                 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 run_script_action | ||||
|             and not alert.action_run | ||||
|         ): | ||||
|             r = agent.run_script( | ||||
|                 scriptpk=alert_template.action.pk, | ||||
|                 args=alert.parse_script_args(alert_template.action_args), | ||||
|                 timeout=alert_template.action_timeout, | ||||
|                 wait=True, | ||||
|                 full=True, | ||||
|                 run_on_any=True, | ||||
|                 run_as_user=False, | ||||
|             ) | ||||
|         # check if any scripts/webhooks should be run | ||||
|         if alert_template and not alert.action_run and should_run_script_or_webhook: | ||||
|             if ( | ||||
|                 alert_template.action_type == AlertTemplateActionType.SCRIPT | ||||
|                 and alert_template.action | ||||
|             ): | ||||
|                 hist = AgentHistory.objects.create( | ||||
|                     agent=agent, | ||||
|                     type=AgentHistoryType.SCRIPT_RUN, | ||||
|                     script=alert_template.action, | ||||
|                     username="alert-action-failure", | ||||
|                 ) | ||||
|                 r = agent.run_script( | ||||
|                     scriptpk=alert_template.action.pk, | ||||
|                     args=alert.parse_script_args(alert_template.action_args), | ||||
|                     timeout=alert_template.action_timeout, | ||||
|                     wait=True, | ||||
|                     history_pk=hist.pk, | ||||
|                     full=True, | ||||
|                     run_on_any=True, | ||||
|                     run_as_user=False, | ||||
|                     env_vars=alert.parse_script_args(alert_template.action_env_vars), | ||||
|                 ) | ||||
|             elif ( | ||||
|                 alert_template.action_type == AlertTemplateActionType.SERVER | ||||
|                 and alert_template.action | ||||
|             ): | ||||
|                 stdout, stderr, execution_time, retcode = run_server_script( | ||||
|                     body=alert_template.action.script_body, | ||||
|                     args=alert.parse_script_args(alert_template.action_args), | ||||
|                     timeout=alert_template.action_timeout, | ||||
|                     env_vars=alert.parse_script_args(alert_template.action_env_vars), | ||||
|                     shell=alert_template.action.shell, | ||||
|                 ) | ||||
|  | ||||
|                 r = { | ||||
|                     "retcode": retcode, | ||||
|                     "stdout": stdout, | ||||
|                     "stderr": stderr, | ||||
|                     "execution_time": execution_time, | ||||
|                 } | ||||
|  | ||||
|             elif alert_template.action_type == AlertTemplateActionType.REST: | ||||
|                 if ( | ||||
|                     alert.severity == AlertSeverity.INFO | ||||
|                     and not core.notify_on_info_alerts | ||||
|                     or alert.severity == AlertSeverity.WARNING | ||||
|                     and not core.notify_on_warning_alerts | ||||
|                 ): | ||||
|                     return | ||||
|                 else: | ||||
|                     output, status = run_url_rest_action( | ||||
|                         action_id=alert_template.action_rest.id, instance=alert | ||||
|                     ) | ||||
|                     logger.debug(f"{output=} {status=}") | ||||
|  | ||||
|                     r = { | ||||
|                         "stdout": output, | ||||
|                         "stderr": "", | ||||
|                         "execution_time": 0, | ||||
|                         "retcode": status, | ||||
|                     } | ||||
|             else: | ||||
|                 return | ||||
|  | ||||
|             # command was successful | ||||
|             if isinstance(r, dict): | ||||
| @@ -481,26 +563,37 @@ class Alert(models.Model): | ||||
|                 alert.action_run = djangotime.now() | ||||
|                 alert.save() | ||||
|             else: | ||||
|                 DebugLog.error( | ||||
|                     agent=agent, | ||||
|                     log_type=DebugLogType.SCRIPTING, | ||||
|                     message=f"Failure action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) failure alert", | ||||
|                 ) | ||||
|                 if alert_template.action_type == AlertTemplateActionType.SCRIPT: | ||||
|                     DebugLog.error( | ||||
|                         agent=agent, | ||||
|                         log_type=DebugLogType.SCRIPTING, | ||||
|                         message=f"Failure action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) failure alert", | ||||
|                     ) | ||||
|                 else: | ||||
|                     DebugLog.error( | ||||
|                         log_type=DebugLogType.SCRIPTING, | ||||
|                         message=f"Failure action: {alert_template.action.name} failed to run on server for failure alert", | ||||
|                     ) | ||||
|  | ||||
|     @classmethod | ||||
|     def handle_alert_resolve( | ||||
|         cls, instance: Union[Agent, TaskResult, CheckResult] | ||||
|     ) -> None: | ||||
|         from agents.models import Agent | ||||
|         from agents.models import Agent, AgentHistory | ||||
|         from autotasks.models import TaskResult | ||||
|         from checks.models import CheckResult | ||||
|         from core.models import CoreSettings | ||||
|  | ||||
|         core = CoreSettings.objects.first() | ||||
|  | ||||
|         # set variables | ||||
|         email_severities = None | ||||
|         text_severities = None | ||||
|         email_on_resolved = False | ||||
|         text_on_resolved = False | ||||
|         resolved_email_task = None | ||||
|         resolved_text_task = None | ||||
|         run_script_action = None | ||||
|         should_run_script_or_webhook = False | ||||
|  | ||||
|         # check what the instance passed is | ||||
|         if isinstance(instance, Agent): | ||||
| @@ -516,7 +609,9 @@ class Alert(models.Model): | ||||
|             if alert_template: | ||||
|                 email_on_resolved = alert_template.agent_email_on_resolved | ||||
|                 text_on_resolved = alert_template.agent_text_on_resolved | ||||
|                 run_script_action = alert_template.agent_script_actions | ||||
|                 should_run_script_or_webhook = alert_template.agent_script_actions | ||||
|                 email_severities = [AlertSeverity.ERROR] | ||||
|                 text_severities = [AlertSeverity.ERROR] | ||||
|  | ||||
|             if agent.overdue_email_alert: | ||||
|                 email_on_resolved = True | ||||
| @@ -539,7 +634,15 @@ class Alert(models.Model): | ||||
|             if alert_template: | ||||
|                 email_on_resolved = alert_template.check_email_on_resolved | ||||
|                 text_on_resolved = alert_template.check_text_on_resolved | ||||
|                 run_script_action = alert_template.check_script_actions | ||||
|                 should_run_script_or_webhook = alert_template.check_script_actions | ||||
|                 email_severities = alert_template.check_email_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 text_severities = alert_template.check_text_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|  | ||||
|         elif isinstance(instance, TaskResult): | ||||
|             from autotasks.tasks import ( | ||||
| @@ -557,7 +660,15 @@ class Alert(models.Model): | ||||
|             if alert_template: | ||||
|                 email_on_resolved = alert_template.task_email_on_resolved | ||||
|                 text_on_resolved = alert_template.task_text_on_resolved | ||||
|                 run_script_action = alert_template.task_script_actions | ||||
|                 should_run_script_or_webhook = alert_template.task_script_actions | ||||
|                 email_severities = alert_template.task_email_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|                 text_severities = alert_template.task_text_alert_severity or [ | ||||
|                     AlertSeverity.ERROR, | ||||
|                     AlertSeverity.WARNING, | ||||
|                 ] | ||||
|  | ||||
|         else: | ||||
|             return | ||||
| @@ -572,28 +683,103 @@ class Alert(models.Model): | ||||
|  | ||||
|         # 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) | ||||
|             if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: | ||||
|                 pass | ||||
|  | ||||
|             elif ( | ||||
|                 alert.severity == AlertSeverity.WARNING | ||||
|                 and not core.notify_on_warning_alerts | ||||
|             ): | ||||
|                 pass | ||||
|             elif email_severities and alert.severity not in email_severities: | ||||
|                 pass | ||||
|             else: | ||||
|                 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) | ||||
|             if alert.severity == AlertSeverity.INFO and not core.notify_on_info_alerts: | ||||
|                 pass | ||||
|  | ||||
|         # check if resolved script should be run | ||||
|             elif ( | ||||
|                 alert.severity == AlertSeverity.WARNING | ||||
|                 and not core.notify_on_warning_alerts | ||||
|             ): | ||||
|                 pass | ||||
|             elif text_severities and alert.severity not in text_severities: | ||||
|                 pass | ||||
|             else: | ||||
|                 resolved_text_task.delay(pk=alert.pk) | ||||
|  | ||||
|         # check if resolved script/webhook should be run | ||||
|         if ( | ||||
|             alert_template | ||||
|             and alert_template.resolved_action | ||||
|             and run_script_action | ||||
|             and not alert.resolved_action_run | ||||
|             and should_run_script_or_webhook | ||||
|         ): | ||||
|             r = agent.run_script( | ||||
|                 scriptpk=alert_template.resolved_action.pk, | ||||
|                 args=alert.parse_script_args(alert_template.resolved_action_args), | ||||
|                 timeout=alert_template.resolved_action_timeout, | ||||
|                 wait=True, | ||||
|                 full=True, | ||||
|                 run_on_any=True, | ||||
|                 run_as_user=False, | ||||
|             ) | ||||
|             if ( | ||||
|                 alert_template.resolved_action_type == AlertTemplateActionType.SCRIPT | ||||
|                 and alert_template.resolved_action | ||||
|             ): | ||||
|                 hist = AgentHistory.objects.create( | ||||
|                     agent=agent, | ||||
|                     type=AgentHistoryType.SCRIPT_RUN, | ||||
|                     script=alert_template.resolved_action, | ||||
|                     username="alert-action-resolved", | ||||
|                 ) | ||||
|                 r = agent.run_script( | ||||
|                     scriptpk=alert_template.resolved_action.pk, | ||||
|                     args=alert.parse_script_args(alert_template.resolved_action_args), | ||||
|                     timeout=alert_template.resolved_action_timeout, | ||||
|                     wait=True, | ||||
|                     history_pk=hist.pk, | ||||
|                     full=True, | ||||
|                     run_on_any=True, | ||||
|                     run_as_user=False, | ||||
|                     env_vars=alert_template.resolved_action_env_vars, | ||||
|                 ) | ||||
|             elif ( | ||||
|                 alert_template.resolved_action_type == AlertTemplateActionType.SERVER | ||||
|                 and alert_template.resolved_action | ||||
|             ): | ||||
|                 stdout, stderr, execution_time, retcode = run_server_script( | ||||
|                     body=alert_template.resolved_action.script_body, | ||||
|                     args=alert.parse_script_args(alert_template.resolved_action_args), | ||||
|                     timeout=alert_template.resolved_action_timeout, | ||||
|                     env_vars=alert.parse_script_args( | ||||
|                         alert_template.resolved_action_env_vars | ||||
|                     ), | ||||
|                     shell=alert_template.resolved_action.shell, | ||||
|                 ) | ||||
|                 r = { | ||||
|                     "stdout": stdout, | ||||
|                     "stderr": stderr, | ||||
|                     "execution_time": execution_time, | ||||
|                     "retcode": retcode, | ||||
|                 } | ||||
|  | ||||
|             elif alert_template.action_type == AlertTemplateActionType.REST: | ||||
|                 if ( | ||||
|                     alert.severity == AlertSeverity.INFO | ||||
|                     and not core.notify_on_info_alerts | ||||
|                     or alert.severity == AlertSeverity.WARNING | ||||
|                     and not core.notify_on_warning_alerts | ||||
|                 ): | ||||
|                     return | ||||
|                 else: | ||||
|                     output, status = run_url_rest_action( | ||||
|                         action_id=alert_template.resolved_action_rest.id, instance=alert | ||||
|                     ) | ||||
|                     logger.debug(f"{output=} {status=}") | ||||
|  | ||||
|                     r = { | ||||
|                         "stdout": output, | ||||
|                         "stderr": "", | ||||
|                         "execution_time": 0, | ||||
|                         "retcode": status, | ||||
|                     } | ||||
|             else: | ||||
|                 return | ||||
|  | ||||
|             # command was successful | ||||
|             if isinstance(r, dict): | ||||
| @@ -606,40 +792,36 @@ class Alert(models.Model): | ||||
|                 alert.resolved_action_run = djangotime.now() | ||||
|                 alert.save() | ||||
|             else: | ||||
|                 DebugLog.error( | ||||
|                     agent=agent, | ||||
|                     log_type=DebugLogType.SCRIPTING, | ||||
|                     message=f"Resolved action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) resolved alert", | ||||
|                 ) | ||||
|                 if ( | ||||
|                     alert_template.resolved_action_type | ||||
|                     == AlertTemplateActionType.SCRIPT | ||||
|                 ): | ||||
|                     DebugLog.error( | ||||
|                         agent=agent, | ||||
|                         log_type=DebugLogType.SCRIPTING, | ||||
|                         message=f"Resolved action: {alert_template.action.name} failed to run on any agent for {agent.hostname}({agent.pk}) resolved alert", | ||||
|                     ) | ||||
|                 else: | ||||
|                     DebugLog.error( | ||||
|                         log_type=DebugLogType.SCRIPTING, | ||||
|                         message=f"Resolved action: {alert_template.action.name} failed to run on server for resolved alert", | ||||
|                     ) | ||||
|  | ||||
|     def parse_script_args(self, args: List[str]) -> List[str]: | ||||
|  | ||||
|         if not args: | ||||
|             return [] | ||||
|  | ||||
|         temp_args = [] | ||||
|         # pattern to match for injection | ||||
|         pattern = re.compile(".*\\{\\{alert\\.(.*)\\}\\}.*") | ||||
|  | ||||
|         for arg in args: | ||||
|             match = pattern.match(arg) | ||||
|             if match: | ||||
|                 name = match.group(1) | ||||
|             temp_arg = arg | ||||
|             for string, model, prop in RE_DB_VALUE.findall(arg): | ||||
|                 value = get_db_value(string=f"{model}.{prop}", instance=self) | ||||
|  | ||||
|                 # check if attr exists and isn't a function | ||||
|                 if hasattr(self, name) and not callable(getattr(self, name)): | ||||
|                     value = f"'{getattr(self, name)}'" | ||||
|                 else: | ||||
|                     continue | ||||
|                 if value is not None: | ||||
|                     temp_arg = temp_arg.replace(string, f"'{str(value)}'") | ||||
|  | ||||
|                 try: | ||||
|                     temp_args.append(re.sub("\\{\\{.*\\}\\}", value, arg)) | ||||
|                 except Exception as e: | ||||
|                     DebugLog.error(log_type=DebugLogType.SCRIPTING, message=str(e)) | ||||
|                     continue | ||||
|  | ||||
|             else: | ||||
|                 temp_args.append(arg) | ||||
|             temp_args.append(temp_arg) | ||||
|  | ||||
|         return temp_args | ||||
|  | ||||
| @@ -648,6 +830,11 @@ class AlertTemplate(BaseAuditModel): | ||||
|     name = models.CharField(max_length=100) | ||||
|     is_active = models.BooleanField(default=True) | ||||
|  | ||||
|     action_type = models.CharField( | ||||
|         max_length=10, | ||||
|         choices=AlertTemplateActionType.choices, | ||||
|         default=AlertTemplateActionType.SCRIPT, | ||||
|     ) | ||||
|     action = models.ForeignKey( | ||||
|         "scripts.Script", | ||||
|         related_name="alert_template", | ||||
| @@ -655,13 +842,31 @@ class AlertTemplate(BaseAuditModel): | ||||
|         null=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     action_rest = models.ForeignKey( | ||||
|         "core.URLAction", | ||||
|         related_name="url_action_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_env_vars = ArrayField( | ||||
|         models.TextField(null=True, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     action_timeout = models.PositiveIntegerField(default=15) | ||||
|     resolved_action_type = models.CharField( | ||||
|         max_length=10, | ||||
|         choices=AlertTemplateActionType.choices, | ||||
|         default=AlertTemplateActionType.SCRIPT, | ||||
|     ) | ||||
|     resolved_action = models.ForeignKey( | ||||
|         "scripts.Script", | ||||
|         related_name="resolved_alert_template", | ||||
| @@ -669,12 +874,25 @@ class AlertTemplate(BaseAuditModel): | ||||
|         null=True, | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|     resolved_action_rest = models.ForeignKey( | ||||
|         "core.URLAction", | ||||
|         related_name="resolved_url_action_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_env_vars = ArrayField( | ||||
|         models.TextField(null=True, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     resolved_action_timeout = models.PositiveIntegerField(default=15) | ||||
|  | ||||
|     # overrides the global recipients | ||||
| @@ -701,7 +919,8 @@ class AlertTemplate(BaseAuditModel): | ||||
|     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) | ||||
|     agent_script_actions = BooleanField(null=True, blank=True, default=True) | ||||
|     # fmt: off | ||||
|     agent_script_actions = BooleanField(null=True, blank=True, default=True)  # should be renamed because also deals with webhooks | ||||
|  | ||||
|     # check alert settings | ||||
|     check_email_alert_severity = ArrayField( | ||||
| @@ -725,7 +944,8 @@ class AlertTemplate(BaseAuditModel): | ||||
|     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) | ||||
|     check_script_actions = BooleanField(null=True, blank=True, default=True) | ||||
|     # fmt: off | ||||
|     check_script_actions = BooleanField(null=True, blank=True, default=True)  # should be renamed because also deals with webhooks | ||||
|  | ||||
|     # task alert settings | ||||
|     task_email_alert_severity = ArrayField( | ||||
| @@ -749,7 +969,8 @@ class AlertTemplate(BaseAuditModel): | ||||
|     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) | ||||
|     task_script_actions = BooleanField(null=True, blank=True, default=True) | ||||
|     # fmt: off | ||||
|     task_script_actions = BooleanField(null=True, blank=True, default=True)  # should be renamed because also deals with webhooks | ||||
|  | ||||
|     # exclusion settings | ||||
|     exclude_workstations = BooleanField(null=True, blank=True, default=False) | ||||
|   | ||||
| @@ -3,6 +3,7 @@ from typing import TYPE_CHECKING | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from rest_framework import permissions | ||||
|  | ||||
| from tacticalrmm.constants import AlertTemplateActionType | ||||
| from tacticalrmm.permissions import _has_perm, _has_perm_on_agent | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
| @@ -53,4 +54,17 @@ class AlertTemplatePerms(permissions.BasePermission): | ||||
|         if r.method == "GET": | ||||
|             return _has_perm(r, "can_list_alerttemplates") | ||||
|  | ||||
|         if r.method in ("POST", "PUT", "PATCH"): | ||||
|             # ensure only users with explicit run server script perms can add/modify alert templates | ||||
|             # while also still requiring the manage alert template perm | ||||
|             if isinstance(r.data, dict): | ||||
|                 if ( | ||||
|                     r.data.get("action_type") == AlertTemplateActionType.SERVER | ||||
|                     or r.data.get("resolved_action_type") | ||||
|                     == AlertTemplateActionType.SERVER | ||||
|                 ): | ||||
|                     return _has_perm(r, "can_run_server_scripts") and _has_perm( | ||||
|                         r, "can_manage_alerttemplates" | ||||
|                     ) | ||||
|  | ||||
|         return _has_perm(r, "can_manage_alerttemplates") | ||||
|   | ||||
| @@ -3,12 +3,12 @@ from rest_framework.serializers import ModelSerializer, ReadOnlyField | ||||
|  | ||||
| from automation.serializers import PolicySerializer | ||||
| from clients.serializers import ClientMinimumSerializer, SiteMinimumSerializer | ||||
| from tacticalrmm.constants import AlertTemplateActionType | ||||
|  | ||||
| from .models import Alert, AlertTemplate | ||||
|  | ||||
|  | ||||
| class AlertSerializer(ModelSerializer): | ||||
|  | ||||
|     hostname = ReadOnlyField(source="assigned_agent.hostname") | ||||
|     agent_id = ReadOnlyField(source="assigned_agent.agent_id") | ||||
|     client = ReadOnlyField(source="client.name") | ||||
| @@ -26,14 +26,29 @@ class AlertTemplateSerializer(ModelSerializer): | ||||
|     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") | ||||
|     action_name = SerializerMethodField() | ||||
|     resolved_action_name = SerializerMethodField() | ||||
|     applied_count = SerializerMethodField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = AlertTemplate | ||||
|         fields = "__all__" | ||||
|  | ||||
|     def get_action_name(self, obj): | ||||
|         if obj.action_type == AlertTemplateActionType.REST and obj.action_rest: | ||||
|             return obj.action_rest.name | ||||
|  | ||||
|         return obj.action.name if obj.action else "" | ||||
|  | ||||
|     def get_resolved_action_name(self, obj): | ||||
|         if ( | ||||
|             obj.resolved_action_type == AlertTemplateActionType.REST | ||||
|             and obj.resolved_action_rest | ||||
|         ): | ||||
|             return obj.resolved_action_rest.name | ||||
|  | ||||
|         return obj.resolved_action.name if obj.resolved_action else "" | ||||
|  | ||||
|     def get_applied_count(self, instance): | ||||
|         return ( | ||||
|             instance.policies.count() | ||||
|   | ||||
| @@ -1,16 +1,21 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from datetime import timedelta | ||||
| from itertools import cycle | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from alerts.tasks import cache_agents_alert_template | ||||
| from autotasks.models import TaskResult | ||||
| from core.tasks import cache_db_fields_task, resolve_alerts_task | ||||
| from core.utils import get_core_settings | ||||
| from django.conf import settings | ||||
| from django.utils import timezone as djangotime | ||||
| from model_bakery import baker, seq | ||||
|  | ||||
| from alerts.tasks import cache_agents_alert_template | ||||
| from autotasks.models import TaskResult | ||||
| from core.tasks import cache_db_fields_task, handle_resolved_stuff | ||||
| from core.utils import get_core_settings | ||||
| from tacticalrmm.constants import AgentMonType, AlertSeverity, AlertType, CheckStatus | ||||
| from tacticalrmm.constants import ( | ||||
|     AgentMonType, | ||||
|     AlertSeverity, | ||||
|     AlertType, | ||||
|     CheckStatus, | ||||
|     URLActionType, | ||||
| ) | ||||
| from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
| from .models import Alert, AlertTemplate | ||||
| @@ -28,6 +33,7 @@ class TestAlertsViews(TacticalTestCase): | ||||
|         self.authenticate() | ||||
|         self.setup_coresettings() | ||||
|  | ||||
|     """ | ||||
|     def test_get_alerts(self): | ||||
|         url = "/alerts/" | ||||
|  | ||||
| @@ -39,14 +45,14 @@ class TestAlertsViews(TacticalTestCase): | ||||
|         alerts = baker.make( | ||||
|             "alerts.Alert", | ||||
|             agent=agent, | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             severity=AlertSeverity.WARNING, | ||||
|             _quantity=3, | ||||
|         ) | ||||
|         baker.make( | ||||
|             "alerts.Alert", | ||||
|             assigned_check=check, | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             severity=AlertSeverity.ERROR, | ||||
|             _quantity=7, | ||||
|         ) | ||||
| @@ -55,7 +61,7 @@ class TestAlertsViews(TacticalTestCase): | ||||
|             assigned_task=task, | ||||
|             snoozed=True, | ||||
|             snooze_until=djangotime.now(), | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             _quantity=2, | ||||
|         ) | ||||
|         baker.make( | ||||
| @@ -63,7 +69,7 @@ class TestAlertsViews(TacticalTestCase): | ||||
|             agent=agent, | ||||
|             resolved=True, | ||||
|             resolved_on=djangotime.now(), | ||||
|             alert_time=seq(datetime.now(), timedelta(days=15)), | ||||
|             alert_time=seq(djangotime.now(), timedelta(days=15)), | ||||
|             _quantity=9, | ||||
|         ) | ||||
|  | ||||
| @@ -120,13 +126,14 @@ class TestAlertsViews(TacticalTestCase): | ||||
|             self.assertEqual(len(resp.data), req["count"]) | ||||
|  | ||||
|         self.check_not_authenticated("patch", url) | ||||
|     """ | ||||
|  | ||||
|     def test_add_alert(self): | ||||
|         url = "/alerts/" | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         data = { | ||||
|             "alert_time": datetime.now(), | ||||
|             "alert_time": djangotime.now(), | ||||
|             "agent": agent.id, | ||||
|             "severity": "warning", | ||||
|             "alert_type": "availability", | ||||
| @@ -275,12 +282,32 @@ class TestAlertsViews(TacticalTestCase): | ||||
|         resp = self.client.get("/alerts/templates/500/", format="json") | ||||
|         self.assertEqual(resp.status_code, 404) | ||||
|  | ||||
|         alert_template = baker.make("alerts.AlertTemplate") | ||||
|         url = f"/alerts/templates/{alert_template.pk}/" | ||||
|         agent_script = baker.make("scripts.Script") | ||||
|         server_script = baker.make("scripts.Script") | ||||
|         webhook = baker.make("core.URLAction", action_type=URLActionType.REST) | ||||
|  | ||||
|         alert_template_agent_script = baker.make( | ||||
|             "alerts.AlertTemplate", action=agent_script | ||||
|         ) | ||||
|         url = f"/alerts/templates/{alert_template_agent_script.pk}/" | ||||
|         resp = self.client.get(url, format="json") | ||||
|         serializer = AlertTemplateSerializer(alert_template) | ||||
|         serializer = AlertTemplateSerializer(alert_template_agent_script) | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|         self.assertEqual(resp.data, serializer.data) | ||||
|  | ||||
|         alert_template_server_script = baker.make( | ||||
|             "alerts.AlertTemplate", action=server_script | ||||
|         ) | ||||
|         url = f"/alerts/templates/{alert_template_server_script.pk}/" | ||||
|         resp = self.client.get(url, format="json") | ||||
|         serializer = AlertTemplateSerializer(alert_template_server_script) | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|         self.assertEqual(resp.data, serializer.data) | ||||
|  | ||||
|         alert_template_webhook = baker.make("alerts.AlertTemplate", action_rest=webhook) | ||||
|         url = f"/alerts/templates/{alert_template_webhook.pk}/" | ||||
|         resp = self.client.get(url, format="json") | ||||
|         serializer = AlertTemplateSerializer(alert_template_webhook) | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|         self.assertEqual(resp.data, serializer.data) | ||||
|  | ||||
| @@ -363,7 +390,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         not_snoozed = baker.make( | ||||
|             "alerts.Alert", | ||||
|             snoozed=True, | ||||
|             snooze_until=seq(datetime.now(), timedelta(days=15)), | ||||
|             snooze_until=seq(djangotime.now(), timedelta(days=15)), | ||||
|             _quantity=5, | ||||
|         ) | ||||
|  | ||||
| @@ -371,7 +398,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         snoozed = baker.make( | ||||
|             "alerts.Alert", | ||||
|             snoozed=True, | ||||
|             snooze_until=seq(datetime.now(), timedelta(days=-15)), | ||||
|             snooze_until=seq(djangotime.now(), timedelta(days=-15)), | ||||
|             _quantity=5, | ||||
|         ) | ||||
|  | ||||
| @@ -389,7 +416,6 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         ) | ||||
|  | ||||
|     def test_agent_gets_correct_alert_template(self): | ||||
|  | ||||
|         core = get_core_settings() | ||||
|         # setup data | ||||
|         workstation = baker.make_recipe( | ||||
| @@ -677,8 +703,6 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         agent_template_email = Agent.objects.get(pk=agent_template_email.pk) | ||||
|  | ||||
|         # have the two agents checkin | ||||
|         url = "/api/v3/checkin/" | ||||
|  | ||||
|         agent_template_text.version = settings.LATEST_AGENT_VER | ||||
|         agent_template_text.last_seen = djangotime.now() | ||||
|         agent_template_text.save() | ||||
| @@ -688,7 +712,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         agent_template_email.save() | ||||
|  | ||||
|         cache_db_fields_task() | ||||
|         handle_resolved_stuff() | ||||
|         resolve_alerts_task() | ||||
|  | ||||
|         recovery_sms.assert_called_with( | ||||
|             pk=Alert.objects.get(agent=agent_template_text).pk | ||||
| @@ -1373,7 +1397,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|     def test_alert_actions( | ||||
|         self, recovery_sms, recovery_email, outage_email, outage_sms, nats_cmd | ||||
|     ): | ||||
|  | ||||
|         from agents.models import AgentHistory | ||||
|         from agents.tasks import agent_outages_task | ||||
|  | ||||
|         # Setup cmd mock | ||||
| @@ -1399,9 +1423,12 @@ class TestAlertTasks(TacticalTestCase): | ||||
|             agent_script_actions=False, | ||||
|             action=failure_action, | ||||
|             action_timeout=30, | ||||
|             action_args=["hello", "world"], | ||||
|             action_env_vars=["hello=world", "foo=bar"], | ||||
|             resolved_action=resolved_action, | ||||
|             resolved_action_timeout=35, | ||||
|             resolved_action_args=["nice_arg"], | ||||
|             resolved_action_env_vars=["resolved=action", "env=vars"], | ||||
|         ) | ||||
|         agent.client.alert_template = alert_template | ||||
|         agent.client.save() | ||||
| @@ -1422,9 +1449,13 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         data = { | ||||
|             "func": "runscriptfull", | ||||
|             "timeout": 30, | ||||
|             "script_args": [], | ||||
|             "script_args": ["hello", "world"], | ||||
|             "payload": {"code": failure_action.code, "shell": failure_action.shell}, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["hello=world", "foo=bar"], | ||||
|             "id": AgentHistory.objects.last().pk,  # type: ignore | ||||
|             "nushell_enable_config": settings.NUSHELL_ENABLE_CONFIG, | ||||
|             "deno_default_permissions": settings.DENO_DEFAULT_PERMISSIONS, | ||||
|         } | ||||
|  | ||||
|         nats_cmd.assert_called_with(data, timeout=30, wait=True) | ||||
| @@ -1445,7 +1476,7 @@ class TestAlertTasks(TacticalTestCase): | ||||
|         agent.save() | ||||
|  | ||||
|         cache_db_fields_task() | ||||
|         handle_resolved_stuff() | ||||
|         resolve_alerts_task() | ||||
|  | ||||
|         # this is what data should be | ||||
|         data = { | ||||
| @@ -1454,6 +1485,10 @@ class TestAlertTasks(TacticalTestCase): | ||||
|             "script_args": ["nice_arg"], | ||||
|             "payload": {"code": resolved_action.code, "shell": resolved_action.shell}, | ||||
|             "run_as_user": False, | ||||
|             "env_vars": ["resolved=action", "env=vars"], | ||||
|             "id": AgentHistory.objects.last().pk,  # type: ignore | ||||
|             "nushell_enable_config": settings.NUSHELL_ENABLE_CONFIG, | ||||
|             "deno_default_permissions": settings.DENO_DEFAULT_PERMISSIONS, | ||||
|         } | ||||
|  | ||||
|         nats_cmd.assert_called_with(data, timeout=35, wait=True) | ||||
| @@ -1627,8 +1662,7 @@ class TestAlertPermissions(TacticalTestCase): | ||||
|             unauthorized_task_url, | ||||
|         ] | ||||
|  | ||||
|         for method in ["get", "put", "delete"]: | ||||
|  | ||||
|         for method in ("get", "put", "delete"): | ||||
|             # test superuser access | ||||
|             for url in authorized_urls: | ||||
|                 self.check_authorized_superuser(method, url) | ||||
|   | ||||
| @@ -23,15 +23,18 @@ class GetAddAlerts(APIView): | ||||
|     permission_classes = [IsAuthenticated, AlertPerms] | ||||
|  | ||||
|     def patch(self, request): | ||||
|  | ||||
|         # 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() | ||||
|             alerts = ( | ||||
|                 Alert.objects.filter_by_role(request.user)  # type: ignore | ||||
|                 .filter(resolved=False, snoozed=False, hidden=False) | ||||
|                 .order_by("alert_time")[: int(request.data["top"])] | ||||
|             ) | ||||
|             count = ( | ||||
|                 Alert.objects.filter_by_role(request.user)  # type: ignore | ||||
|                 .filter(resolved=False, snoozed=False, hidden=False) | ||||
|                 .count() | ||||
|             ) | ||||
|             return Response( | ||||
|                 { | ||||
|                     "alerts_count": count, | ||||
|   | ||||
| @@ -77,9 +77,7 @@ class TestAPIv3(TacticalTestCase): | ||||
|         ) | ||||
|  | ||||
|         # add check to agent with check interval set | ||||
|         check = baker.make_recipe( | ||||
|             "checks.ping_check", agent=self.agent, run_interval=30 | ||||
|         ) | ||||
|         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) | ||||
| @@ -89,7 +87,7 @@ class TestAPIv3(TacticalTestCase): | ||||
|         ) | ||||
|  | ||||
|         # minimum check run interval is 15 seconds | ||||
|         check = baker.make_recipe("checks.ping_check", agent=self.agent, run_interval=5) | ||||
|         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) | ||||
| @@ -129,8 +127,15 @@ class TestAPIv3(TacticalTestCase): | ||||
|                 "script": script.id, | ||||
|                 "script_args": ["test"], | ||||
|                 "timeout": 30, | ||||
|                 "env_vars": ["hello=world", "foo=bar"], | ||||
|             }, | ||||
|             { | ||||
|                 "type": "script", | ||||
|                 "script": 3, | ||||
|                 "script_args": [], | ||||
|                 "timeout": 30, | ||||
|                 "env_vars": ["hello=world", "foo=bar"], | ||||
|             }, | ||||
|             {"type": "script", "script": 3, "script_args": [], "timeout": 30}, | ||||
|         ] | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
| @@ -296,3 +301,9 @@ class TestAPIv3(TacticalTestCase): | ||||
|             AgentCustomField.objects.get(field=multiple, agent=task.agent).value, | ||||
|             ["this"], | ||||
|         ) | ||||
|  | ||||
|     def test_get_agent_config(self): | ||||
|         agent = baker.make_recipe("agents.online_agent") | ||||
|         url = f"/api/v3/{agent.agent_id}/config/" | ||||
|         r = self.client.get(url) | ||||
|         self.assertEqual(r.status_code, 200) | ||||
|   | ||||
| @@ -19,4 +19,5 @@ urlpatterns = [ | ||||
|     path("superseded/", views.SupersededWinUpdate.as_view()), | ||||
|     path("<int:pk>/chocoresult/", views.ChocoResult.as_view()), | ||||
|     path("<int:pk>/<str:agentid>/histresult/", views.AgentHistoryResult.as_view()), | ||||
|     path("<str:agentid>/config/", views.AgentConfig.as_view()), | ||||
| ] | ||||
|   | ||||
							
								
								
									
										33
									
								
								api/tacticalrmm/apiv3/utils.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								api/tacticalrmm/apiv3/utils.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,33 @@ | ||||
| import random | ||||
|  | ||||
| from django.conf import settings | ||||
|  | ||||
| from tacticalrmm.structs import AgentCheckInConfig | ||||
|  | ||||
|  | ||||
| def get_agent_config() -> AgentCheckInConfig: | ||||
|     return AgentCheckInConfig( | ||||
|         checkin_hello=random.randint(*getattr(settings, "CHECKIN_HELLO", (30, 60))), | ||||
|         checkin_agentinfo=random.randint( | ||||
|             *getattr(settings, "CHECKIN_AGENTINFO", (200, 400)) | ||||
|         ), | ||||
|         checkin_winsvc=random.randint( | ||||
|             *getattr(settings, "CHECKIN_WINSVC", (2400, 3000)) | ||||
|         ), | ||||
|         checkin_pubip=random.randint(*getattr(settings, "CHECKIN_PUBIP", (300, 500))), | ||||
|         checkin_disks=random.randint(*getattr(settings, "CHECKIN_DISKS", (1000, 2000))), | ||||
|         checkin_sw=random.randint(*getattr(settings, "CHECKIN_SW", (2800, 3500))), | ||||
|         checkin_wmi=random.randint(*getattr(settings, "CHECKIN_WMI", (3000, 4000))), | ||||
|         checkin_syncmesh=random.randint( | ||||
|             *getattr(settings, "CHECKIN_SYNCMESH", (800, 1200)) | ||||
|         ), | ||||
|         limit_data=getattr(settings, "LIMIT_DATA", False), | ||||
|         install_nushell=getattr(settings, "INSTALL_NUSHELL", False), | ||||
|         install_nushell_version=getattr(settings, "INSTALL_NUSHELL_VERSION", ""), | ||||
|         install_nushell_url=getattr(settings, "INSTALL_NUSHELL_URL", ""), | ||||
|         nushell_enable_config=getattr(settings, "NUSHELL_ENABLE_CONFIG", False), | ||||
|         install_deno=getattr(settings, "INSTALL_DENO", False), | ||||
|         install_deno_version=getattr(settings, "INSTALL_DENO_VERSION", ""), | ||||
|         install_deno_url=getattr(settings, "INSTALL_DENO_URL", ""), | ||||
|         deno_default_permissions=getattr(settings, "DENO_DEFAULT_PERMISSIONS", ""), | ||||
|     ) | ||||
| @@ -12,13 +12,16 @@ from rest_framework.response import Response | ||||
| from rest_framework.views import APIView | ||||
|  | ||||
| from accounts.models import User | ||||
| from agents.models import Agent, AgentHistory | ||||
| from agents.models import Agent, AgentHistory, Note | ||||
| from agents.serializers import AgentHistorySerializer | ||||
| from alerts.tasks import cache_agents_alert_template | ||||
| from apiv3.utils import get_agent_config | ||||
| from autotasks.models import AutomatedTask, TaskResult | ||||
| from autotasks.serializers import TaskGOGetSerializer, TaskResultSerializer | ||||
| from checks.constants import CHECK_DEFER, CHECK_RESULT_DEFER | ||||
| from checks.models import Check, CheckResult | ||||
| from checks.serializers import CheckRunnerGetSerializer | ||||
| from core.tasks import sync_mesh_perms_task | ||||
| from core.utils import ( | ||||
|     download_mesh_agent, | ||||
|     get_core_settings, | ||||
| @@ -30,23 +33,25 @@ from logs.models import DebugLog, PendingAction | ||||
| from software.models import InstalledSoftware | ||||
| from tacticalrmm.constants import ( | ||||
|     AGENT_DEFER, | ||||
|     TRMM_MAX_REQUEST_SIZE, | ||||
|     AgentHistoryType, | ||||
|     AgentMonType, | ||||
|     AgentPlat, | ||||
|     AuditActionType, | ||||
|     AuditObjType, | ||||
|     CheckStatus, | ||||
|     CustomFieldModel, | ||||
|     DebugLogType, | ||||
|     GoArch, | ||||
|     MeshAgentIdent, | ||||
|     PAStatus, | ||||
| ) | ||||
| from tacticalrmm.helpers import notify_error | ||||
| from tacticalrmm.helpers import make_random_password, notify_error | ||||
| from tacticalrmm.utils import reload_nats | ||||
| from winupdate.models import WinUpdate, WinUpdatePolicy | ||||
|  | ||||
|  | ||||
| class CheckIn(APIView): | ||||
|  | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
| @@ -254,9 +259,7 @@ class CheckRunner(APIView): | ||||
|                 check.check_result.last_run | ||||
|                 < djangotime.now() | ||||
|                 - djangotime.timedelta( | ||||
|                     seconds=check.run_interval | ||||
|                     if check.run_interval | ||||
|                     else agent.check_interval | ||||
|                     seconds=check.run_interval or agent.check_interval | ||||
|                 ) | ||||
|             ) | ||||
|         ] | ||||
| @@ -340,6 +343,12 @@ class TaskRunner(APIView): | ||||
|             AutomatedTask.objects.select_related("custom_field"), pk=pk | ||||
|         ) | ||||
|  | ||||
|         content_length = request.META.get("CONTENT_LENGTH") | ||||
|         if content_length and int(content_length) > TRMM_MAX_REQUEST_SIZE: | ||||
|             request.data["stdout"] = "" | ||||
|             request.data["stderr"] = "Content truncated due to excessive request size." | ||||
|             request.data["retcode"] = 1 | ||||
|  | ||||
|         # get task result or create if doesn't exist | ||||
|         try: | ||||
|             task_result = ( | ||||
| @@ -358,7 +367,7 @@ class TaskRunner(APIView): | ||||
|  | ||||
|         AgentHistory.objects.create( | ||||
|             agent=agent, | ||||
|             type=AuditActionType.TASK_RUN, | ||||
|             type=AgentHistoryType.TASK_RUN, | ||||
|             command=task.name, | ||||
|             script_results=request.data, | ||||
|         ) | ||||
| @@ -366,7 +375,6 @@ class TaskRunner(APIView): | ||||
|         # check if task is a collector and update the custom field | ||||
|         if task.custom_field: | ||||
|             if not task_result.stderr: | ||||
|  | ||||
|                 task_result.save_collector_results() | ||||
|  | ||||
|                 status = CheckStatus.PASSING | ||||
| @@ -429,8 +437,8 @@ class MeshExe(APIView): | ||||
|  | ||||
|         try: | ||||
|             return download_mesh_agent(dl_url) | ||||
|         except: | ||||
|             return notify_error("Unable to download mesh agent exe") | ||||
|         except Exception as e: | ||||
|             return notify_error(f"Unable to download mesh agent: {e}") | ||||
|  | ||||
|  | ||||
| class NewAgent(APIView): | ||||
| @@ -460,7 +468,7 @@ class NewAgent(APIView): | ||||
|         user = User.objects.create_user(  # type: ignore | ||||
|             username=request.data["agent_id"], | ||||
|             agent=agent, | ||||
|             password=User.objects.make_random_password(60),  # type: ignore | ||||
|             password=make_random_password(len=60), | ||||
|         ) | ||||
|  | ||||
|         token = Token.objects.create(user=user) | ||||
| @@ -484,6 +492,8 @@ class NewAgent(APIView): | ||||
|         ) | ||||
|  | ||||
|         ret = {"pk": agent.pk, "token": token.key} | ||||
|         sync_mesh_perms_task.delay() | ||||
|         cache_agents_alert_template.delay() | ||||
|         return Response(ret) | ||||
|  | ||||
|  | ||||
| @@ -516,7 +526,7 @@ class Installer(APIView): | ||||
|         ver = request.data["version"] | ||||
|         if ( | ||||
|             pyver.parse(ver) < pyver.parse(settings.LATEST_AGENT_VER) | ||||
|             and not "-dev" in settings.LATEST_AGENT_VER | ||||
|             and "-dev" not in 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" | ||||
| @@ -562,10 +572,56 @@ class AgentHistoryResult(APIView): | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def patch(self, request, agentid, pk): | ||||
|         content_length = request.META.get("CONTENT_LENGTH") | ||||
|         if content_length and int(content_length) > TRMM_MAX_REQUEST_SIZE: | ||||
|  | ||||
|             request.data["script_results"]["stdout"] = "" | ||||
|             request.data["script_results"][ | ||||
|                 "stderr" | ||||
|             ] = "Content truncated due to excessive request size." | ||||
|             request.data["script_results"]["retcode"] = 1 | ||||
|  | ||||
|         hist = get_object_or_404( | ||||
|             AgentHistory.objects.filter(agent__agent_id=agentid), pk=pk | ||||
|             AgentHistory.objects.select_related("custom_field").filter( | ||||
|                 agent__agent_id=agentid | ||||
|             ), | ||||
|             pk=pk, | ||||
|         ) | ||||
|         s = AgentHistorySerializer(instance=hist, data=request.data, partial=True) | ||||
|         s.is_valid(raise_exception=True) | ||||
|         s.save() | ||||
|  | ||||
|         if hist.custom_field: | ||||
|             if hist.custom_field.model == CustomFieldModel.AGENT: | ||||
|                 field = hist.custom_field.get_or_create_field_value(hist.agent) | ||||
|             elif hist.custom_field.model == CustomFieldModel.CLIENT: | ||||
|                 field = hist.custom_field.get_or_create_field_value(hist.agent.client) | ||||
|             elif hist.custom_field.model == CustomFieldModel.SITE: | ||||
|                 field = hist.custom_field.get_or_create_field_value(hist.agent.site) | ||||
|  | ||||
|             r = request.data["script_results"]["stdout"] | ||||
|             value = ( | ||||
|                 r.strip() | ||||
|                 if hist.collector_all_output | ||||
|                 else r.strip().split("\n")[-1].strip() | ||||
|             ) | ||||
|  | ||||
|             field.save_to_field(value) | ||||
|  | ||||
|         if hist.save_to_agent_note: | ||||
|             Note.objects.create( | ||||
|                 agent=hist.agent, | ||||
|                 user=request.user, | ||||
|                 note=request.data["script_results"]["stdout"], | ||||
|             ) | ||||
|  | ||||
|         return Response("ok") | ||||
|  | ||||
|  | ||||
| class AgentConfig(APIView): | ||||
|     authentication_classes = [TokenAuthentication] | ||||
|     permission_classes = [IsAuthenticated] | ||||
|  | ||||
|     def get(self, request, agentid): | ||||
|         ret = get_agent_config() | ||||
|         return Response(ret._to_dict()) | ||||
|   | ||||
| @@ -47,7 +47,7 @@ class Policy(BaseAuditModel): | ||||
|         old_policy: Optional[Policy] = ( | ||||
|             type(self).objects.get(pk=self.pk) if self.pk else None | ||||
|         ) | ||||
|         super(Policy, self).save(old_model=old_policy, *args, **kwargs) | ||||
|         super().save(old_model=old_policy, *args, **kwargs) | ||||
|  | ||||
|         # check if alert template was changes and cache on agents | ||||
|         if old_policy: | ||||
| @@ -68,10 +68,7 @@ class Policy(BaseAuditModel): | ||||
|         cache.delete_many_pattern("site_server_*") | ||||
|         cache.delete_many_pattern("agent_*") | ||||
|  | ||||
|         super(Policy, self).delete( | ||||
|             *args, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return self.name | ||||
| @@ -216,7 +213,6 @@ class Policy(BaseAuditModel): | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_policy_tasks(agent: "Agent") -> "List[AutomatedTask]": | ||||
|  | ||||
|         # List of all tasks to be applied | ||||
|         tasks = [] | ||||
|  | ||||
| @@ -225,7 +221,7 @@ class Policy(BaseAuditModel): | ||||
|  | ||||
|         processed_policies = [] | ||||
|  | ||||
|         for _, policy in policies.items(): | ||||
|         for policy in policies.values(): | ||||
|             if policy and policy.active and policy.pk not in processed_policies: | ||||
|                 processed_policies.append(policy.pk) | ||||
|                 for task in policy.autotasks.all(): | ||||
| @@ -235,7 +231,6 @@ class Policy(BaseAuditModel): | ||||
|  | ||||
|     @staticmethod | ||||
|     def get_policy_checks(agent: "Agent") -> "List[Check]": | ||||
|  | ||||
|         # Get checks added to agent directly | ||||
|         agent_checks = list(agent.agentchecks.all()) | ||||
|  | ||||
| @@ -249,7 +244,7 @@ class Policy(BaseAuditModel): | ||||
|  | ||||
|         processed_policies = [] | ||||
|  | ||||
|         for _, policy in policies.items(): | ||||
|         for policy in policies.values(): | ||||
|             if policy and policy.active and policy.pk not in processed_policies: | ||||
|                 processed_policies.append(policy.pk) | ||||
|                 if policy.enforced: | ||||
|   | ||||
| @@ -87,7 +87,7 @@ class TestPolicyViews(TacticalTestCase): | ||||
|             "copyId": policy.pk, | ||||
|         } | ||||
|  | ||||
|         resp = self.client.post(f"/automation/policies/", data, format="json") | ||||
|         resp = self.client.post("/automation/policies/", data, format="json") | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|  | ||||
|         copied_policy = Policy.objects.get(name=data["name"]) | ||||
| @@ -126,7 +126,7 @@ class TestPolicyViews(TacticalTestCase): | ||||
|         resp = self.client.put(url, data, format="json") | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|  | ||||
|         cache_alert_template.called_once() | ||||
|         cache_alert_template.assert_called_once() | ||||
|  | ||||
|         self.check_not_authenticated("put", url) | ||||
|  | ||||
| @@ -221,7 +221,6 @@ class TestPolicyViews(TacticalTestCase): | ||||
|         self.check_not_authenticated("get", url) | ||||
|  | ||||
|     def test_get_policy_task_status(self): | ||||
|  | ||||
|         # policy with a task | ||||
|         policy = baker.make("automation.Policy") | ||||
|         agent = baker.make_recipe("agents.agent", policy=policy) | ||||
| @@ -240,7 +239,6 @@ class TestPolicyViews(TacticalTestCase): | ||||
|  | ||||
|     @patch("automation.tasks.run_win_policy_autotasks_task.delay") | ||||
|     def test_run_win_task(self, mock_task): | ||||
|  | ||||
|         policy = baker.make("automation.Policy") | ||||
|         # create managed policy tasks | ||||
|         task = baker.make_recipe("autotasks.task", policy=policy) | ||||
| @@ -283,7 +281,6 @@ class TestPolicyViews(TacticalTestCase): | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     def test_update_patch_policy(self): | ||||
|  | ||||
|         # test policy doesn't exist | ||||
|         resp = self.client.put("/automation/patchpolicy/500/", format="json") | ||||
|         self.assertEqual(resp.status_code, 404) | ||||
| @@ -396,7 +393,6 @@ class TestPolicyTasks(TacticalTestCase): | ||||
|         self.setup_coresettings() | ||||
|  | ||||
|     def test_policy_related(self): | ||||
|  | ||||
|         # Get Site and Client from an agent in list | ||||
|         clients = baker.make("clients.Client", _quantity=5) | ||||
|         sites = baker.make("clients.Site", client=cycle(clients), _quantity=25) | ||||
| @@ -447,7 +443,6 @@ class TestPolicyTasks(TacticalTestCase): | ||||
|         self.assertEqual(len(resp.data["agents"]), 2) | ||||
|  | ||||
|     def test_getting_agent_policy_checks(self): | ||||
|  | ||||
|         # setup data | ||||
|         policy = baker.make("automation.Policy", active=True) | ||||
|         self.create_checks(parent=policy) | ||||
| @@ -536,7 +531,6 @@ class TestPolicyTasks(TacticalTestCase): | ||||
|         ) | ||||
|  | ||||
|     def test_policy_exclusions(self): | ||||
|  | ||||
|         # setup data | ||||
|         policy = baker.make("automation.Policy", active=True) | ||||
|         baker.make_recipe("checks.memory_check", policy=policy) | ||||
|   | ||||
| @@ -84,7 +84,6 @@ class GetUpdateDeletePolicy(APIView): | ||||
|  | ||||
|  | ||||
| class PolicyAutoTask(APIView): | ||||
|  | ||||
|     # get status of all tasks | ||||
|     def get(self, request, task): | ||||
|         tasks = TaskResult.objects.filter(task=task) | ||||
| @@ -108,7 +107,6 @@ class PolicyCheck(APIView): | ||||
|  | ||||
| class OverviewPolicy(APIView): | ||||
|     def get(self, request): | ||||
|  | ||||
|         clients = ( | ||||
|             Client.objects.filter_by_role(request.user) | ||||
|             .select_related("workstation_policy", "server_policy") | ||||
| @@ -127,7 +125,6 @@ class OverviewPolicy(APIView): | ||||
|  | ||||
| class GetRelated(APIView): | ||||
|     def get(self, request, pk): | ||||
|  | ||||
|         policy = ( | ||||
|             Policy.objects.filter(pk=pk) | ||||
|             .prefetch_related( | ||||
| @@ -146,6 +143,7 @@ class GetRelated(APIView): | ||||
|  | ||||
| class UpdatePatchPolicy(APIView): | ||||
|     permission_classes = [IsAuthenticated, AutomationPolicyPerms] | ||||
|  | ||||
|     # create new patch policy | ||||
|     def post(self, request): | ||||
|         policy = get_object_or_404(Policy, pk=request.data["policy"]) | ||||
| @@ -179,7 +177,6 @@ class UpdatePatchPolicy(APIView): | ||||
| class ResetPatchPolicy(APIView): | ||||
|     # bulk reset agent patch policy | ||||
|     def post(self, request): | ||||
|  | ||||
|         if "client" in request.data: | ||||
|             if not _has_perm_on_client(request.user, request.data["client"]): | ||||
|                 raise PermissionDenied() | ||||
|   | ||||
| @@ -7,10 +7,4 @@ class Command(BaseCommand): | ||||
|     help = "Checks for orphaned tasks on all agents and removes them" | ||||
|  | ||||
|     def handle(self, *args, **kwargs): | ||||
|         remove_orphaned_win_tasks.s() | ||||
|  | ||||
|         self.stdout.write( | ||||
|             self.style.SUCCESS( | ||||
|                 "The task has been initiated. Check the Debug Log in the UI for progress." | ||||
|             ) | ||||
|         ) | ||||
|         remove_orphaned_win_tasks() | ||||
|   | ||||
| @@ -0,0 +1,33 @@ | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| def migrate_env_vars(apps, schema_editor): | ||||
|     AutomatedTask = apps.get_model("autotasks", "AutomatedTask") | ||||
|     for task in AutomatedTask.objects.iterator(chunk_size=30): | ||||
|         try: | ||||
|             tmp = [] | ||||
|             if isinstance(task.actions, list) and task.actions: | ||||
|                 for t in task.actions: | ||||
|                     if isinstance(t, dict): | ||||
|                         if t["type"] == "script": | ||||
|                             try: | ||||
|                                 t["env_vars"] | ||||
|                             except KeyError: | ||||
|                                 t["env_vars"] = [] | ||||
|                         tmp.append(t) | ||||
|             if tmp: | ||||
|                 task.actions = tmp | ||||
|                 task.save(update_fields=["actions"]) | ||||
|         except Exception as e: | ||||
|             print(f"ERROR: {e}") | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("autotasks", "0037_alter_taskresult_retcode"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RunPython(migrate_env_vars), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 4.2.7 on 2023-11-23 04:39 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('autotasks', '0038_add_missing_env_vars'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='automatedtask', | ||||
|             name='task_type', | ||||
|             field=models.CharField(choices=[('daily', 'Daily'), ('weekly', 'Weekly'), ('monthly', 'Monthly'), ('monthlydow', 'Monthly Day of Week'), ('checkfailure', 'On Check Failure'), ('manual', 'Manual'), ('runonce', 'Run Once'), ('onboarding', 'Onboarding'), ('scheduled', 'Scheduled')], default='manual', max_length=100), | ||||
|         ), | ||||
|     ] | ||||
| @@ -0,0 +1,18 @@ | ||||
| # Generated by Django 4.2.10 on 2024-02-19 05:57 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("autotasks", "0039_alter_automatedtask_task_type"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="taskresult", | ||||
|             name="id", | ||||
|             field=models.BigAutoField(primary_key=True, serialize=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,10 +1,10 @@ | ||||
| import asyncio | ||||
| import logging | ||||
| import random | ||||
| import string | ||||
| from contextlib import suppress | ||||
| from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union | ||||
|  | ||||
| import pytz | ||||
| from django.core.cache import cache | ||||
| from django.core.validators import MaxValueValidator, MinValueValidator | ||||
| from django.db import models | ||||
| @@ -14,12 +14,11 @@ from django.db.utils import DatabaseError | ||||
| from django.utils import timezone as djangotime | ||||
|  | ||||
| from core.utils import get_core_settings | ||||
| from logs.models import BaseAuditModel, DebugLog | ||||
| from logs.models import BaseAuditModel | ||||
| from tacticalrmm.constants import ( | ||||
|     FIELDS_TRIGGER_TASK_UPDATE_AGENT, | ||||
|     POLICY_TASK_FIELDS_TO_COPY, | ||||
|     AlertSeverity, | ||||
|     DebugLogType, | ||||
|     TaskStatus, | ||||
|     TaskSyncStatus, | ||||
|     TaskType, | ||||
| @@ -31,6 +30,7 @@ if TYPE_CHECKING: | ||||
|     from agents.models import Agent | ||||
|     from checks.models import Check | ||||
|  | ||||
| from tacticalrmm.helpers import has_script_actions, has_webhook | ||||
| from tacticalrmm.models import PermissionQuerySet | ||||
| from tacticalrmm.utils import ( | ||||
|     bitdays_to_string, | ||||
| @@ -46,6 +46,9 @@ def generate_task_name() -> str: | ||||
|     return "TacticalRMM_" + "".join(random.choice(chars) for i in range(35)) | ||||
|  | ||||
|  | ||||
| logger = logging.getLogger("trmm") | ||||
|  | ||||
|  | ||||
| class AutomatedTask(BaseAuditModel): | ||||
|     objects = PermissionQuerySet.as_manager() | ||||
|  | ||||
| @@ -71,7 +74,7 @@ class AutomatedTask(BaseAuditModel): | ||||
|         on_delete=models.SET_NULL, | ||||
|     ) | ||||
|  | ||||
|     # format -> [{"type": "script", "script": 1, "name": "Script Name", "timeout": 90, "script_args": []}, {"type": "cmd", "command": "whoami", "timeout": 90}] | ||||
|     # format -> [{"type": "script", "script": 1, "name": "Script Name", "timeout": 90, "script_args": [], "env_vars": []}, {"type": "cmd", "command": "whoami", "timeout": 90}] | ||||
|     actions = JSONField(default=list) | ||||
|     assigned_check = models.ForeignKey( | ||||
|         "checks.Check", | ||||
| @@ -142,7 +145,6 @@ class AutomatedTask(BaseAuditModel): | ||||
|         return self.name | ||||
|  | ||||
|     def save(self, *args, **kwargs) -> None: | ||||
|  | ||||
|         # if task is a policy task clear cache on everything | ||||
|         if self.policy: | ||||
|             cache.delete_many_pattern("site_*_tasks") | ||||
| @@ -150,7 +152,7 @@ class AutomatedTask(BaseAuditModel): | ||||
|  | ||||
|         # get old task if exists | ||||
|         old_task = AutomatedTask.objects.get(pk=self.pk) if self.pk else None | ||||
|         super(AutomatedTask, self).save(old_model=old_task, *args, **kwargs) | ||||
|         super().save(old_model=old_task, *args, **kwargs) | ||||
|  | ||||
|         # check if fields were updated that require a sync to the agent and set status to notsynced | ||||
|         if old_task: | ||||
| @@ -168,16 +170,12 @@ class AutomatedTask(BaseAuditModel): | ||||
|                         ) | ||||
|  | ||||
|     def delete(self, *args, **kwargs): | ||||
|  | ||||
|         # if task is a policy task clear cache on everything | ||||
|         if self.policy: | ||||
|             cache.delete_many_pattern("site_*_tasks") | ||||
|             cache.delete_many_pattern("agent_*_tasks") | ||||
|  | ||||
|         super(AutomatedTask, self).delete( | ||||
|             *args, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def schedule(self) -> Optional[str]: | ||||
| @@ -211,6 +209,9 @@ class AutomatedTask(BaseAuditModel): | ||||
|             weeks = bitweeks_to_string(self.monthly_weeks_of_month) | ||||
|             days = bitdays_to_string(self.run_time_bit_weekdays) | ||||
|             return f"Runs on {months} on {weeks} on {days} at {run_time_nice}" | ||||
|         elif self.task_type == TaskType.ONBOARDING: | ||||
|             return "Onboarding: Runs once on task creation." | ||||
|         return None | ||||
|  | ||||
|     @property | ||||
|     def fields_that_trigger_task_update_on_agent(self) -> List[str]: | ||||
| @@ -226,80 +227,68 @@ class AutomatedTask(BaseAuditModel): | ||||
|     def create_policy_task( | ||||
|         self, policy: "Policy", assigned_check: "Optional[Check]" = None | ||||
|     ) -> None: | ||||
|         ### Copies certain properties on this task (self) to a new task and sets it to the supplied Policy | ||||
|         fields_to_copy = POLICY_TASK_FIELDS_TO_COPY | ||||
|  | ||||
|         # Copies certain properties on this task (self) to a new task and sets it to the supplied Policy | ||||
|         task = AutomatedTask.objects.create( | ||||
|             policy=policy, | ||||
|             assigned_check=assigned_check, | ||||
|         ) | ||||
|  | ||||
|         for field in fields_to_copy: | ||||
|         for field in POLICY_TASK_FIELDS_TO_COPY: | ||||
|             setattr(task, field, getattr(self, field)) | ||||
|  | ||||
|         task.save() | ||||
|  | ||||
|     # agent version >= 1.8.0 | ||||
|     def generate_nats_task_payload( | ||||
|         self, agent: "Optional[Agent]" = None, editing: bool = False | ||||
|     ) -> Dict[str, Any]: | ||||
|     def generate_nats_task_payload(self) -> Dict[str, Any]: | ||||
|         task = { | ||||
|             "pk": self.pk, | ||||
|             "type": "rmm", | ||||
|             "name": self.win_task_name, | ||||
|             "overwrite_task": editing, | ||||
|             "overwrite_task": True, | ||||
|             "enabled": self.enabled, | ||||
|             "trigger": self.task_type | ||||
|             if self.task_type != TaskType.CHECK_FAILURE | ||||
|             else TaskType.MANUAL, | ||||
|             "multiple_instances": self.task_instance_policy | ||||
|             if self.task_instance_policy | ||||
|             else 0, | ||||
|             "delete_expired_task_after": self.remove_if_not_scheduled | ||||
|             if self.expire_date | ||||
|             else False, | ||||
|             "start_when_available": self.run_asap_after_missed | ||||
|             if self.task_type != TaskType.RUN_ONCE | ||||
|             else True, | ||||
|             "trigger": ( | ||||
|                 self.task_type | ||||
|                 if self.task_type != TaskType.CHECK_FAILURE | ||||
|                 else TaskType.MANUAL | ||||
|             ), | ||||
|             "multiple_instances": self.task_instance_policy or 0, | ||||
|             "delete_expired_task_after": ( | ||||
|                 self.remove_if_not_scheduled if self.expire_date else False | ||||
|             ), | ||||
|             "start_when_available": ( | ||||
|                 self.run_asap_after_missed | ||||
|                 if self.task_type != TaskType.RUN_ONCE | ||||
|                 else True | ||||
|             ), | ||||
|         } | ||||
|  | ||||
|         if self.task_type in ( | ||||
|             TaskType.RUN_ONCE, | ||||
|             TaskType.DAILY, | ||||
|             TaskType.WEEKLY, | ||||
|             TaskType.MONTHLY, | ||||
|             TaskType.MONTHLY_DOW, | ||||
|             TaskType.RUN_ONCE, | ||||
|         ): | ||||
|             # set runonce task in future if creating and run_asap_after_missed is set | ||||
|             if ( | ||||
|                 not editing | ||||
|                 and self.task_type == TaskType.RUN_ONCE | ||||
|                 and self.run_asap_after_missed | ||||
|                 and agent | ||||
|                 and self.run_time_date | ||||
|                 < djangotime.now().astimezone(pytz.timezone(agent.timezone)) | ||||
|             ): | ||||
|                 self.run_time_date = ( | ||||
|                     djangotime.now() + djangotime.timedelta(minutes=5) | ||||
|                 ).astimezone(pytz.timezone(agent.timezone)) | ||||
|             if not self.run_time_date: | ||||
|                 self.run_time_date = djangotime.now() | ||||
|  | ||||
|             task["start_year"] = int(self.run_time_date.strftime("%Y")) | ||||
|             task["start_month"] = int(self.run_time_date.strftime("%-m")) | ||||
|             task["start_day"] = int(self.run_time_date.strftime("%-d")) | ||||
|             task["start_hour"] = int(self.run_time_date.strftime("%-H")) | ||||
|             task["start_min"] = int(self.run_time_date.strftime("%-M")) | ||||
|             task["start_year"] = self.run_time_date.year | ||||
|             task["start_month"] = self.run_time_date.month | ||||
|             task["start_day"] = self.run_time_date.day | ||||
|             task["start_hour"] = self.run_time_date.hour | ||||
|             task["start_min"] = self.run_time_date.minute | ||||
|  | ||||
|             if self.expire_date: | ||||
|                 task["expire_year"] = int(self.expire_date.strftime("%Y")) | ||||
|                 task["expire_month"] = int(self.expire_date.strftime("%-m")) | ||||
|                 task["expire_day"] = int(self.expire_date.strftime("%-d")) | ||||
|                 task["expire_hour"] = int(self.expire_date.strftime("%-H")) | ||||
|                 task["expire_min"] = int(self.expire_date.strftime("%-M")) | ||||
|                 task["expire_year"] = self.expire_date.year | ||||
|                 task["expire_month"] = self.expire_date.month | ||||
|                 task["expire_day"] = self.expire_date.day | ||||
|                 task["expire_hour"] = self.expire_date.hour | ||||
|                 task["expire_min"] = self.expire_date.minute | ||||
|  | ||||
|             if self.random_task_delay: | ||||
|                 task["random_delay"] = convert_to_iso_duration(self.random_task_delay) | ||||
|  | ||||
|             if self.task_repetition_interval: | ||||
|             if self.task_repetition_interval and self.task_repetition_duration: | ||||
|                 task["repetition_interval"] = convert_to_iso_duration( | ||||
|                     self.task_repetition_interval | ||||
|                 ) | ||||
| @@ -316,7 +305,6 @@ class AutomatedTask(BaseAuditModel): | ||||
|                 task["days_of_week"] = self.run_time_bit_weekdays | ||||
|  | ||||
|             elif self.task_type == TaskType.MONTHLY: | ||||
|  | ||||
|                 # check if "last day is configured" | ||||
|                 if self.monthly_days_of_month >= 0x80000000: | ||||
|                     task["days_of_month"] = self.monthly_days_of_month - 0x80000000 | ||||
| @@ -348,27 +336,24 @@ class AutomatedTask(BaseAuditModel): | ||||
|  | ||||
|         nats_data = { | ||||
|             "func": "schedtask", | ||||
|             "schedtaskpayload": self.generate_nats_task_payload(agent), | ||||
|             "schedtaskpayload": self.generate_nats_task_payload(), | ||||
|         } | ||||
|         logger.debug(nats_data) | ||||
|  | ||||
|         r = asyncio.run(task_result.agent.nats_cmd(nats_data, timeout=5)) | ||||
|         r = asyncio.run(task_result.agent.nats_cmd(nats_data, timeout=10)) | ||||
|  | ||||
|         if r != "ok": | ||||
|             task_result.sync_status = TaskSyncStatus.INITIAL | ||||
|             task_result.save(update_fields=["sync_status"]) | ||||
|             DebugLog.warning( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"Unable to create scheduled task {self.name} on {task_result.agent.hostname}. It will be created when the agent checks in.", | ||||
|             logger.error( | ||||
|                 f"Unable to create scheduled task {self.name} on {task_result.agent.hostname}: {r}" | ||||
|             ) | ||||
|             return "timeout" | ||||
|         else: | ||||
|             task_result.sync_status = TaskSyncStatus.SYNCED | ||||
|             task_result.save(update_fields=["sync_status"]) | ||||
|             DebugLog.info( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"{task_result.agent.hostname} task {self.name} was successfully created", | ||||
|             logger.info( | ||||
|                 f"{task_result.agent.hostname} task {self.name} was successfully created." | ||||
|             ) | ||||
|  | ||||
|         return "ok" | ||||
| @@ -387,27 +372,24 @@ class AutomatedTask(BaseAuditModel): | ||||
|  | ||||
|         nats_data = { | ||||
|             "func": "schedtask", | ||||
|             "schedtaskpayload": self.generate_nats_task_payload(editing=True), | ||||
|             "schedtaskpayload": self.generate_nats_task_payload(), | ||||
|         } | ||||
|         logger.debug(nats_data) | ||||
|  | ||||
|         r = asyncio.run(task_result.agent.nats_cmd(nats_data, timeout=5)) | ||||
|         r = asyncio.run(task_result.agent.nats_cmd(nats_data, timeout=10)) | ||||
|  | ||||
|         if r != "ok": | ||||
|             task_result.sync_status = TaskSyncStatus.NOT_SYNCED | ||||
|             task_result.save(update_fields=["sync_status"]) | ||||
|             DebugLog.warning( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"Unable to modify scheduled task {self.name} on {task_result.agent.hostname}({task_result.agent.agent_id}). It will try again on next agent checkin", | ||||
|             logger.error( | ||||
|                 f"Unable to modify scheduled task {self.name} on {task_result.agent.hostname}: {r}" | ||||
|             ) | ||||
|             return "timeout" | ||||
|         else: | ||||
|             task_result.sync_status = TaskSyncStatus.SYNCED | ||||
|             task_result.save(update_fields=["sync_status"]) | ||||
|             DebugLog.info( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"{task_result.agent.hostname} task {self.name} was successfully modified", | ||||
|             logger.info( | ||||
|                 f"{task_result.agent.hostname} task {self.name} was successfully modified." | ||||
|             ) | ||||
|  | ||||
|         return "ok" | ||||
| @@ -436,20 +418,13 @@ class AutomatedTask(BaseAuditModel): | ||||
|             with suppress(DatabaseError): | ||||
|                 task_result.save(update_fields=["sync_status"]) | ||||
|  | ||||
|             DebugLog.warning( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"{task_result.agent.hostname} task {self.name} will be deleted on next checkin", | ||||
|             logger.error( | ||||
|                 f"Unable to delete task {self.name} on {task_result.agent.hostname}: {r}" | ||||
|             ) | ||||
|             return "timeout" | ||||
|         else: | ||||
|             self.delete() | ||||
|             DebugLog.info( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"{task_result.agent.hostname}({task_result.agent.agent_id}) task {self.name} was deleted", | ||||
|             ) | ||||
|  | ||||
|             logger.info(f"{task_result.agent.hostname} task {self.name} was deleted.") | ||||
|         return "ok" | ||||
|  | ||||
|     def run_win_task(self, agent: "Optional[Agent]" = None) -> str: | ||||
| @@ -472,18 +447,19 @@ class AutomatedTask(BaseAuditModel): | ||||
|         return "ok" | ||||
|  | ||||
|     def should_create_alert(self, alert_template=None): | ||||
|         has_autotask_notification = ( | ||||
|             self.dashboard_alert or self.email_alert or self.text_alert | ||||
|         ) | ||||
|         has_alert_template_notification = alert_template and ( | ||||
|             alert_template.task_always_alert | ||||
|             or alert_template.task_always_email | ||||
|             or alert_template.task_always_text | ||||
|         ) | ||||
|         return ( | ||||
|             self.dashboard_alert | ||||
|             or self.email_alert | ||||
|             or self.text_alert | ||||
|             or ( | ||||
|                 alert_template | ||||
|                 and ( | ||||
|                     alert_template.task_always_alert | ||||
|                     or alert_template.task_always_email | ||||
|                     or alert_template.task_always_text | ||||
|                 ) | ||||
|             ) | ||||
|             has_autotask_notification | ||||
|             or has_alert_template_notification | ||||
|             or has_webhook(alert_template, "task") | ||||
|             or has_script_actions(alert_template, "task") | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -493,6 +469,7 @@ class TaskResult(models.Model): | ||||
|  | ||||
|     objects = PermissionQuerySet.as_manager() | ||||
|  | ||||
|     id = models.BigAutoField(primary_key=True) | ||||
|     agent = models.ForeignKey( | ||||
|         "agents.Agent", | ||||
|         related_name="taskresults", | ||||
| @@ -531,7 +508,6 @@ class TaskResult(models.Model): | ||||
|         ) | ||||
|  | ||||
|     def save_collector_results(self) -> None: | ||||
|  | ||||
|         agent_field = self.task.custom_field.get_or_create_field_value(self.agent) | ||||
|  | ||||
|         value = ( | ||||
|   | ||||
| @@ -1,4 +1,8 @@ | ||||
| from datetime import datetime | ||||
|  | ||||
| from django.utils import timezone as djangotime | ||||
| from rest_framework import serializers | ||||
| from django.conf import settings | ||||
|  | ||||
| from scripts.models import Script | ||||
| from tacticalrmm.constants import TaskType | ||||
| @@ -14,7 +18,6 @@ class TaskResultSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|  | ||||
| class TaskSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|     check_name = serializers.ReadOnlyField(source="assigned_check.readable_desc") | ||||
|     schedule = serializers.ReadOnlyField() | ||||
|     alert_template = serializers.SerializerMethodField() | ||||
| @@ -30,52 +33,49 @@ class TaskSerializer(serializers.ModelSerializer): | ||||
|         ) | ||||
|  | ||||
|     def validate_actions(self, value): | ||||
|  | ||||
|         if not value: | ||||
|             raise serializers.ValidationError( | ||||
|                 f"There must be at least one action configured" | ||||
|                 "There must be at least one action configured" | ||||
|             ) | ||||
|  | ||||
|         for action in value: | ||||
|             if "type" not in action: | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Each action must have a type field of either 'script' or 'cmd'" | ||||
|                     "Each action must have a type field of either 'script' or 'cmd'" | ||||
|                 ) | ||||
|  | ||||
|             if action["type"] == "script": | ||||
|                 if "script" not in action: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"A script action type must have a 'script' field with primary key of script" | ||||
|                         "A script action type must have a 'script' field with primary key of script" | ||||
|                     ) | ||||
|  | ||||
|                 if "script_args" not in action: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"A script action type must have a 'script_args' field with an array of arguments" | ||||
|                         "A script action type must have a 'script_args' field with an array of arguments" | ||||
|                     ) | ||||
|  | ||||
|                 if "timeout" not in action: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"A script action type must have a 'timeout' field" | ||||
|                         "A script action type must have a 'timeout' field" | ||||
|                     ) | ||||
|  | ||||
|             if action["type"] == "cmd": | ||||
|                 if "command" not in action: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"A command action type must have a 'command' field" | ||||
|                         "A command action type must have a 'command' field" | ||||
|                     ) | ||||
|  | ||||
|                 if "timeout" not in action: | ||||
|                     raise serializers.ValidationError( | ||||
|                         f"A command action type must have a 'timeout' field" | ||||
|                         "A command action type must have a 'timeout' field" | ||||
|                     ) | ||||
|  | ||||
|         return value | ||||
|  | ||||
|     def validate(self, data): | ||||
|  | ||||
|         # allow editing with task_type not specified | ||||
|         if self.instance and "task_type" not in data: | ||||
|  | ||||
|             # remove schedule related fields from data | ||||
|             if "run_time_date" in data: | ||||
|                 del data["run_time_date"] | ||||
| @@ -97,6 +97,13 @@ class TaskSerializer(serializers.ModelSerializer): | ||||
|                 del data["assigned_check"] | ||||
|             return data | ||||
|  | ||||
|         if ( | ||||
|             "expire_date" in data | ||||
|             and isinstance(data["expire_date"], datetime) | ||||
|             and djangotime.now() > data["expire_date"] | ||||
|         ): | ||||
|             raise serializers.ValidationError("Expires date/time is in the past") | ||||
|  | ||||
|         # run_time_date required | ||||
|         if ( | ||||
|             data["task_type"] | ||||
| @@ -180,7 +187,6 @@ class TaskSerializer(serializers.ModelSerializer): | ||||
|         return data | ||||
|  | ||||
|     def get_alert_template(self, obj): | ||||
|  | ||||
|         if obj.agent: | ||||
|             alert_template = obj.agent.alert_template | ||||
|         else: | ||||
| @@ -228,6 +234,12 @@ class TaskGOGetSerializer(serializers.ModelSerializer): | ||||
|                     # script doesn't exist so remove it | ||||
|                     actions_to_remove.append(action["script"]) | ||||
|                     continue | ||||
|                 # wrote a custom migration for env_vars but leaving this just in case. | ||||
|                 # can be removed later | ||||
|                 try: | ||||
|                     env_vars = action["env_vars"] | ||||
|                 except KeyError: | ||||
|                     env_vars = [] | ||||
|                 tmp.append( | ||||
|                     { | ||||
|                         "type": "script", | ||||
| @@ -241,6 +253,13 @@ class TaskGOGetSerializer(serializers.ModelSerializer): | ||||
|                         "shell": script.shell, | ||||
|                         "timeout": action["timeout"], | ||||
|                         "run_as_user": script.run_as_user, | ||||
|                         "env_vars": Script.parse_script_env_vars( | ||||
|                             agent=agent, | ||||
|                             shell=script.shell, | ||||
|                             env_vars=env_vars, | ||||
|                         ), | ||||
|                         "nushell_enable_config": settings.NUSHELL_ENABLE_CONFIG, | ||||
|                         "deno_default_permissions": settings.DENO_DEFAULT_PERMISSIONS, | ||||
|                     } | ||||
|                 ) | ||||
|         if actions_to_remove: | ||||
|   | ||||
| @@ -1,18 +1,25 @@ | ||||
| import asyncio | ||||
| import datetime as dt | ||||
| import random | ||||
| from collections import namedtuple | ||||
| from contextlib import suppress | ||||
| from time import sleep | ||||
| from typing import Optional, Union | ||||
| from typing import TYPE_CHECKING, Optional, Union | ||||
|  | ||||
| import msgpack | ||||
| import nats | ||||
| from django.utils import timezone as djangotime | ||||
| from nats.errors import TimeoutError | ||||
|  | ||||
| from agents.models import Agent | ||||
| from alerts.models import Alert | ||||
| from autotasks.models import AutomatedTask, TaskResult | ||||
| from logs.models import DebugLog | ||||
| from tacticalrmm.celery import app | ||||
| from tacticalrmm.constants import DebugLogType | ||||
| from tacticalrmm.constants import AGENT_STATUS_ONLINE, ORPHANED_WIN_TASK_LOCK | ||||
| from tacticalrmm.helpers import rand_range, setup_nats_options | ||||
| from tacticalrmm.utils import redis_lock | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
|     from nats.aio.client import Client as NATSClient | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| @@ -79,61 +86,78 @@ def run_win_task(pk: int, agent_id: Optional[str] = None) -> str: | ||||
|     return "ok" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def remove_orphaned_win_tasks() -> None: | ||||
|     from agents.models import Agent | ||||
| @app.task(bind=True) | ||||
| def remove_orphaned_win_tasks(self) -> str: | ||||
|     with redis_lock(ORPHANED_WIN_TASK_LOCK, self.app.oid) as acquired: | ||||
|         if not acquired: | ||||
|             return f"{self.app.oid} still running" | ||||
|  | ||||
|     for agent in Agent.online_agents(): | ||||
|         r = asyncio.run(agent.nats_cmd({"func": "listschedtasks"}, timeout=10)) | ||||
|         from core.tasks import _get_agent_qs | ||||
|  | ||||
|         if not isinstance(r, list):  # empty list | ||||
|             DebugLog.error( | ||||
|                 agent=agent, | ||||
|                 log_type=DebugLogType.AGENT_ISSUES, | ||||
|                 message=f"Unable to pull list of scheduled tasks on {agent.hostname}: {r}", | ||||
|             ) | ||||
|             continue | ||||
|         AgentTup = namedtuple("AgentTup", ["agent_id", "task_names"]) | ||||
|         items: "list[AgentTup]" = [] | ||||
|         exclude_tasks = ("TacticalRMM_SchedReboot",) | ||||
|  | ||||
|         agent_task_names = [ | ||||
|             task.win_task_name for task in agent.get_tasks_with_policies() | ||||
|         ] | ||||
|         for agent in _get_agent_qs(): | ||||
|             if agent.status == AGENT_STATUS_ONLINE: | ||||
|                 names = [task.win_task_name for task in agent.get_tasks_with_policies()] | ||||
|                 items.append(AgentTup._make([agent.agent_id, names])) | ||||
|  | ||||
|         exclude_tasks = ( | ||||
|             "TacticalRMM_fixmesh", | ||||
|             "TacticalRMM_SchedReboot", | ||||
|             "TacticalRMM_sync", | ||||
|             "TacticalRMM_agentupdate", | ||||
|         ) | ||||
|         async def _handle_task(nc: "NATSClient", sub, data, names) -> str: | ||||
|             try: | ||||
|                 msg = await nc.request( | ||||
|                     subject=sub, payload=msgpack.dumps(data), timeout=5 | ||||
|                 ) | ||||
|             except TimeoutError: | ||||
|                 return "timeout" | ||||
|  | ||||
|         for task in r: | ||||
|             if task.startswith(exclude_tasks): | ||||
|                 # skip system tasks or any pending reboots | ||||
|                 continue | ||||
|             try: | ||||
|                 r = msgpack.loads(msg.data) | ||||
|             except Exception as e: | ||||
|                 return str(e) | ||||
|  | ||||
|             if task.startswith("TacticalRMM_") and task not in agent_task_names: | ||||
|                 # delete task since it doesn't exist in UI | ||||
|                 nats_data = { | ||||
|                     "func": "delschedtask", | ||||
|                     "schedtaskpayload": {"name": task}, | ||||
|                 } | ||||
|                 ret = asyncio.run(agent.nats_cmd(nats_data, timeout=10)) | ||||
|                 if ret != "ok": | ||||
|                     DebugLog.error( | ||||
|                         agent=agent, | ||||
|                         log_type=DebugLogType.AGENT_ISSUES, | ||||
|                         message=f"Unable to clean up orphaned task {task} on {agent.hostname}: {ret}", | ||||
|                     ) | ||||
|                 else: | ||||
|                     DebugLog.info( | ||||
|                         agent=agent, | ||||
|                         log_type=DebugLogType.AGENT_ISSUES, | ||||
|                         message=f"Removed orphaned task {task} from {agent.hostname}", | ||||
|                     ) | ||||
|             if not isinstance(r, list): | ||||
|                 return "notlist" | ||||
|  | ||||
|             for name in r: | ||||
|                 if name.startswith(exclude_tasks): | ||||
|                     # skip system tasks or any pending reboots | ||||
|                     continue | ||||
|  | ||||
|                 if name.startswith("TacticalRMM_") and name not in names: | ||||
|                     nats_data = { | ||||
|                         "func": "delschedtask", | ||||
|                         "schedtaskpayload": {"name": name}, | ||||
|                     } | ||||
|                     print(f"Deleting orphaned task: {name} on agent {sub}") | ||||
|                     await nc.publish(subject=sub, payload=msgpack.dumps(nats_data)) | ||||
|  | ||||
|             return "ok" | ||||
|  | ||||
|         async def _run() -> None: | ||||
|             opts = setup_nats_options() | ||||
|             try: | ||||
|                 nc = await nats.connect(**opts) | ||||
|             except Exception as e: | ||||
|                 return str(e) | ||||
|  | ||||
|             payload = {"func": "listschedtasks"} | ||||
|             tasks = [ | ||||
|                 _handle_task( | ||||
|                     nc=nc, sub=item.agent_id, data=payload, names=item.task_names | ||||
|                 ) | ||||
|                 for item in items | ||||
|             ] | ||||
|             await asyncio.gather(*tasks) | ||||
|             await nc.flush() | ||||
|             await nc.close() | ||||
|  | ||||
|         asyncio.run(_run()) | ||||
|         return "completed" | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| def handle_task_email_alert(pk: int, alert_interval: Union[float, None] = None) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -144,7 +168,7 @@ def handle_task_email_alert(pk: int, alert_interval: Union[float, None] = None) | ||||
|         task_result = TaskResult.objects.get( | ||||
|             task=alert.assigned_task, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 5)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         task_result.send_email() | ||||
|         alert.email_sent = djangotime.now() | ||||
|         alert.save(update_fields=["email_sent"]) | ||||
| @@ -156,7 +180,7 @@ def handle_task_email_alert(pk: int, alert_interval: Union[float, None] = None) | ||||
|                 task_result = TaskResult.objects.get( | ||||
|                     task=alert.assigned_task, agent=alert.agent | ||||
|                 ) | ||||
|                 sleep(random.randint(1, 5)) | ||||
|                 sleep(rand_range(100, 1500)) | ||||
|                 task_result.send_email() | ||||
|                 alert.email_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["email_sent"]) | ||||
| @@ -166,7 +190,6 @@ def handle_task_email_alert(pk: int, alert_interval: Union[float, None] = None) | ||||
|  | ||||
| @app.task | ||||
| def handle_task_sms_alert(pk: int, alert_interval: Union[float, None] = None) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -177,7 +200,7 @@ def handle_task_sms_alert(pk: int, alert_interval: Union[float, None] = None) -> | ||||
|         task_result = TaskResult.objects.get( | ||||
|             task=alert.assigned_task, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 3)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         task_result.send_sms() | ||||
|         alert.sms_sent = djangotime.now() | ||||
|         alert.save(update_fields=["sms_sent"]) | ||||
| @@ -189,7 +212,7 @@ def handle_task_sms_alert(pk: int, alert_interval: Union[float, None] = None) -> | ||||
|                 task_result = TaskResult.objects.get( | ||||
|                     task=alert.assigned_task, agent=alert.agent | ||||
|                 ) | ||||
|                 sleep(random.randint(1, 3)) | ||||
|                 sleep(rand_range(100, 1500)) | ||||
|                 task_result.send_sms() | ||||
|                 alert.sms_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["sms_sent"]) | ||||
| @@ -199,7 +222,6 @@ def handle_task_sms_alert(pk: int, alert_interval: Union[float, None] = None) -> | ||||
|  | ||||
| @app.task | ||||
| def handle_resolved_task_sms_alert(pk: int) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -210,7 +232,7 @@ def handle_resolved_task_sms_alert(pk: int) -> str: | ||||
|         task_result = TaskResult.objects.get( | ||||
|             task=alert.assigned_task, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 3)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         task_result.send_resolved_sms() | ||||
|         alert.resolved_sms_sent = djangotime.now() | ||||
|         alert.save(update_fields=["resolved_sms_sent"]) | ||||
| @@ -220,7 +242,6 @@ def handle_resolved_task_sms_alert(pk: int) -> str: | ||||
|  | ||||
| @app.task | ||||
| def handle_resolved_task_email_alert(pk: int) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -231,7 +252,7 @@ def handle_resolved_task_email_alert(pk: int) -> str: | ||||
|         task_result = TaskResult.objects.get( | ||||
|             task=alert.assigned_task, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 5)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         task_result.send_resolved_email() | ||||
|         alert.resolved_email_sent = djangotime.now() | ||||
|         alert.save(update_fields=["resolved_email_sent"]) | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| from unittest.mock import call, patch | ||||
| from unittest.mock import patch | ||||
|  | ||||
| from django.utils import timezone as djangotime | ||||
| from model_bakery import baker | ||||
| @@ -8,7 +8,7 @@ from tacticalrmm.test import TacticalTestCase | ||||
|  | ||||
| from .models import AutomatedTask, TaskResult, TaskSyncStatus | ||||
| from .serializers import TaskSerializer | ||||
| from .tasks import create_win_task_schedule, remove_orphaned_win_tasks, run_win_task | ||||
| from .tasks import create_win_task_schedule, run_win_task | ||||
|  | ||||
| base_url = "/tasks" | ||||
|  | ||||
| @@ -51,7 +51,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|         # setup data | ||||
|         script = baker.make_recipe("scripts.script") | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         policy = baker.make("automation.Policy") | ||||
|         policy = baker.make("automation.Policy")  # noqa | ||||
|         check = baker.make_recipe("checks.diskspace_check", agent=agent) | ||||
|         custom_field = baker.make("core.CustomField") | ||||
|  | ||||
| @@ -137,7 +137,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|             "weekly_interval": 2, | ||||
|             "run_time_bit_weekdays": 26, | ||||
|             "run_time_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now() + djangotime.timedelta(weeks=5), | ||||
|             "repetition_interval": "30S", | ||||
|             "repetition_duration": "1H", | ||||
|             "random_task_delay": "5M", | ||||
| @@ -160,7 +160,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|             "monthly_months_of_year": 56, | ||||
|             "monthly_days_of_month": 350, | ||||
|             "run_time_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now() + djangotime.timedelta(weeks=5), | ||||
|             "repetition_interval": "30S", | ||||
|             "repetition_duration": "1H", | ||||
|             "random_task_delay": "5M", | ||||
| @@ -183,7 +183,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|             "monthly_weeks_of_month": 4, | ||||
|             "run_time_bit_weekdays": 15, | ||||
|             "run_time_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now() + djangotime.timedelta(weeks=5), | ||||
|             "repetition_interval": "30S", | ||||
|             "repetition_duration": "1H", | ||||
|             "random_task_delay": "5M", | ||||
| @@ -206,7 +206,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|             "monthly_weeks_of_month": 4, | ||||
|             "run_time_bit_weekdays": 15, | ||||
|             "run_time_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now() + djangotime.timedelta(weeks=5), | ||||
|             "repetition_interval": "30S", | ||||
|             "repetition_duration": "1H", | ||||
|             "random_task_delay": "5M", | ||||
| @@ -238,7 +238,6 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     def test_get_autotask(self): | ||||
|  | ||||
|         # setup data | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         task = baker.make("autotasks.AutomatedTask", agent=agent) | ||||
| @@ -258,7 +257,9 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         agent_task = baker.make("autotasks.AutomatedTask", agent=agent) | ||||
|         policy = baker.make("automation.Policy") | ||||
|         policy_task = baker.make("autotasks.AutomatedTask", enabled=True, policy=policy) | ||||
|         policy_task = baker.make(  # noqa | ||||
|             "autotasks.AutomatedTask", enabled=True, policy=policy | ||||
|         ) | ||||
|         custom_field = baker.make("core.CustomField") | ||||
|         script = baker.make("scripts.Script") | ||||
|  | ||||
| @@ -294,7 +295,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|             "monthly_weeks_of_month": 4, | ||||
|             "run_time_bit_weekdays": 15, | ||||
|             "run_time_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now() + djangotime.timedelta(weeks=5), | ||||
|             "repetition_interval": "30S", | ||||
|             "repetition_duration": "1H", | ||||
|             "random_task_delay": "5M", | ||||
| @@ -314,7 +315,7 @@ class TestAutotaskViews(TacticalTestCase): | ||||
|             "monthly_weeks_of_month": 4, | ||||
|             "run_time_bit_weekdays": 15, | ||||
|             "run_time_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now(), | ||||
|             "expire_date": djangotime.now() + djangotime.timedelta(weeks=5), | ||||
|             "repetition_interval": "30S", | ||||
|             "repetition_duration": "1H", | ||||
|             "random_task_delay": "5M", | ||||
| @@ -381,60 +382,6 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|         self.authenticate() | ||||
|         self.setup_coresettings() | ||||
|  | ||||
|     @patch("agents.models.Agent.nats_cmd") | ||||
|     def test_remove_orphaned_win_task(self, nats_cmd): | ||||
|         agent = baker.make_recipe("agents.online_agent") | ||||
|         baker.make_recipe("agents.offline_agent") | ||||
|         task1 = AutomatedTask.objects.create( | ||||
|             agent=agent, | ||||
|             name="test task 1", | ||||
|         ) | ||||
|  | ||||
|         # test removing an orphaned task | ||||
|         win_tasks = [ | ||||
|             "Adobe Acrobat Update Task", | ||||
|             "AdobeGCInvoker-1.0", | ||||
|             "GoogleUpdateTaskMachineCore", | ||||
|             "GoogleUpdateTaskMachineUA", | ||||
|             "OneDrive Standalone Update Task-S-1-5-21-717461175-241712648-1206041384-1001", | ||||
|             task1.win_task_name, | ||||
|             "TacticalRMM_fixmesh", | ||||
|             "TacticalRMM_SchedReboot_jk324kajd", | ||||
|             "TacticalRMM_iggrLcOaldIZnUzLuJWPLNwikiOoJJHHznb",  # orphaned task | ||||
|         ] | ||||
|  | ||||
|         calls = [ | ||||
|             call({"func": "listschedtasks"}, timeout=10), | ||||
|             call( | ||||
|                 { | ||||
|                     "func": "delschedtask", | ||||
|                     "schedtaskpayload": { | ||||
|                         "name": "TacticalRMM_iggrLcOaldIZnUzLuJWPLNwikiOoJJHHznb" | ||||
|                     }, | ||||
|                 }, | ||||
|                 timeout=10, | ||||
|             ), | ||||
|         ] | ||||
|  | ||||
|         nats_cmd.side_effect = [win_tasks, "ok"] | ||||
|         remove_orphaned_win_tasks() | ||||
|         self.assertEqual(nats_cmd.call_count, 2) | ||||
|         nats_cmd.assert_has_calls(calls) | ||||
|  | ||||
|         # test nats delete task fail | ||||
|         nats_cmd.reset_mock() | ||||
|         nats_cmd.side_effect = [win_tasks, "error deleting task"] | ||||
|         remove_orphaned_win_tasks() | ||||
|         nats_cmd.assert_has_calls(calls) | ||||
|         self.assertEqual(nats_cmd.call_count, 2) | ||||
|  | ||||
|         # no orphaned tasks | ||||
|         nats_cmd.reset_mock() | ||||
|         win_tasks.remove("TacticalRMM_iggrLcOaldIZnUzLuJWPLNwikiOoJJHHznb") | ||||
|         nats_cmd.side_effect = [win_tasks, "ok"] | ||||
|         remove_orphaned_win_tasks() | ||||
|         self.assertEqual(nats_cmd.call_count, 1) | ||||
|  | ||||
|     @patch("agents.models.Agent.nats_cmd") | ||||
|     def test_run_win_task(self, nats_cmd): | ||||
|         self.agent = baker.make_recipe("agents.agent") | ||||
| @@ -470,7 +417,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "daily", | ||||
|                     "multiple_instances": 1, | ||||
| @@ -484,7 +431,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "day_interval": 1, | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|         nats_cmd.reset_mock() | ||||
|         self.assertEqual( | ||||
| @@ -523,7 +470,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "weekly", | ||||
|                     "multiple_instances": 2, | ||||
| @@ -543,7 +490,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "days_of_week": 127, | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|         nats_cmd.reset_mock() | ||||
|  | ||||
| @@ -571,7 +518,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "monthly", | ||||
|                     "multiple_instances": 1, | ||||
| @@ -591,7 +538,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "months_of_year": 1024, | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|         nats_cmd.reset_mock() | ||||
|  | ||||
| @@ -615,7 +562,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "monthlydow", | ||||
|                     "multiple_instances": 1, | ||||
| @@ -631,7 +578,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "weeks_of_month": 3, | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|         nats_cmd.reset_mock() | ||||
|  | ||||
| @@ -653,7 +600,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "runonce", | ||||
|                     "multiple_instances": 1, | ||||
| @@ -666,39 +613,10 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "start_min": int(task1.run_time_date.strftime("%-M")), | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|         nats_cmd.reset_mock() | ||||
|  | ||||
|         # test runonce with date in the past | ||||
|         task1 = baker.make( | ||||
|             "autotasks.AutomatedTask", | ||||
|             agent=agent, | ||||
|             name="test task 3", | ||||
|             task_type=TaskType.RUN_ONCE, | ||||
|             run_asap_after_missed=True, | ||||
|             run_time_date=djangotime.datetime(2018, 6, 1, 23, 23, 23), | ||||
|         ) | ||||
|         nats_cmd.return_value = "ok" | ||||
|         create_win_task_schedule(pk=task1.pk) | ||||
|         nats_cmd.assert_called() | ||||
|  | ||||
|         # check if task is scheduled for at most 5min in the future | ||||
|         _, args, _ = nats_cmd.mock_calls[0] | ||||
|  | ||||
|         current_minute = int(djangotime.now().strftime("%-M")) | ||||
|  | ||||
|         if current_minute >= 55 and current_minute < 60: | ||||
|             self.assertLess( | ||||
|                 args[0]["schedtaskpayload"]["start_min"], | ||||
|                 int(djangotime.now().strftime("%-M")), | ||||
|             ) | ||||
|         else: | ||||
|             self.assertGreater( | ||||
|                 args[0]["schedtaskpayload"]["start_min"], | ||||
|                 int(djangotime.now().strftime("%-M")), | ||||
|             ) | ||||
|  | ||||
|         # test checkfailure task | ||||
|         nats_cmd.reset_mock() | ||||
|         check = baker.make_recipe("checks.diskspace_check", agent=agent) | ||||
| @@ -718,7 +636,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "manual", | ||||
|                     "multiple_instances": 1, | ||||
| @@ -726,7 +644,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "start_when_available": False, | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|         nats_cmd.reset_mock() | ||||
|  | ||||
| @@ -745,7 +663,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "pk": task1.pk, | ||||
|                     "type": "rmm", | ||||
|                     "name": task1.win_task_name, | ||||
|                     "overwrite_task": False, | ||||
|                     "overwrite_task": True, | ||||
|                     "enabled": True, | ||||
|                     "trigger": "manual", | ||||
|                     "multiple_instances": 1, | ||||
| @@ -753,7 +671,7 @@ class TestAutoTaskCeleryTasks(TacticalTestCase): | ||||
|                     "start_when_available": False, | ||||
|                 }, | ||||
|             }, | ||||
|             timeout=5, | ||||
|             timeout=10, | ||||
|         ) | ||||
|  | ||||
|  | ||||
| @@ -766,12 +684,14 @@ class TestTaskPermissions(TacticalTestCase): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         policy = baker.make("automation.Policy") | ||||
|         unauthorized_agent = baker.make_recipe("agents.agent") | ||||
|         task = baker.make("autotasks.AutomatedTask", agent=agent, _quantity=5) | ||||
|         unauthorized_task = baker.make( | ||||
|         task = baker.make("autotasks.AutomatedTask", agent=agent, _quantity=5)  # noqa | ||||
|         unauthorized_task = baker.make(  # noqa | ||||
|             "autotasks.AutomatedTask", agent=unauthorized_agent, _quantity=7 | ||||
|         ) | ||||
|  | ||||
|         policy_tasks = baker.make("autotasks.AutomatedTask", policy=policy, _quantity=2) | ||||
|         policy_tasks = baker.make(  # noqa | ||||
|             "autotasks.AutomatedTask", policy=policy, _quantity=2 | ||||
|         ) | ||||
|  | ||||
|         # test super user access | ||||
|         self.check_authorized_superuser("get", f"{base_url}/") | ||||
| @@ -864,7 +784,7 @@ class TestTaskPermissions(TacticalTestCase): | ||||
|  | ||||
|         url = f"{base_url}/" | ||||
|  | ||||
|         for data in [policy_data, agent_data]: | ||||
|         for data in (policy_data, agent_data): | ||||
|             # test superuser access | ||||
|             self.check_authorized_superuser("post", url, data) | ||||
|  | ||||
| @@ -900,8 +820,7 @@ class TestTaskPermissions(TacticalTestCase): | ||||
|         ) | ||||
|         policy_task = baker.make("autotasks.AutomatedTask", policy=policy) | ||||
|  | ||||
|         for method in ["get", "put", "delete"]: | ||||
|  | ||||
|         for method in ("get", "put", "delete"): | ||||
|             url = f"{base_url}/{task.id}/" | ||||
|             unauthorized_url = f"{base_url}/{unauthorized_task.id}/" | ||||
|             policy_url = f"{base_url}/{policy_task.id}/" | ||||
| @@ -939,7 +858,6 @@ class TestTaskPermissions(TacticalTestCase): | ||||
|             self.check_authorized(method, policy_url) | ||||
|  | ||||
|     def test_task_action_permissions(self): | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         unauthorized_agent = baker.make_recipe("agents.agent") | ||||
|         task = baker.make("autotasks.AutomatedTask", agent=agent) | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| from django.shortcuts import get_object_or_404 | ||||
| from packaging import version as pyver | ||||
| from rest_framework.exceptions import PermissionDenied | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| @@ -6,6 +7,8 @@ from rest_framework.views import APIView | ||||
|  | ||||
| from agents.models import Agent | ||||
| from automation.models import Policy | ||||
| from tacticalrmm.constants import TaskType | ||||
| from tacticalrmm.helpers import notify_error | ||||
| from tacticalrmm.permissions import _has_perm_on_agent | ||||
|  | ||||
| from .models import AutomatedTask | ||||
| @@ -18,7 +21,6 @@ class GetAddAutoTasks(APIView): | ||||
|     permission_classes = [IsAuthenticated, AutoTaskPerms] | ||||
|  | ||||
|     def get(self, request, agent_id=None, policy=None): | ||||
|  | ||||
|         if agent_id: | ||||
|             agent = get_object_or_404(Agent, agent_id=agent_id) | ||||
|             tasks = agent.get_tasks_with_policies() | ||||
| @@ -41,6 +43,11 @@ class GetAddAutoTasks(APIView): | ||||
|             if not _has_perm_on_agent(request.user, agent.agent_id): | ||||
|                 raise PermissionDenied() | ||||
|  | ||||
|             if data["task_type"] == TaskType.ONBOARDING and pyver.parse( | ||||
|                 agent.version | ||||
|             ) < pyver.parse("2.6.0"): | ||||
|                 return notify_error("Onboarding tasks require agent >= 2.6.0") | ||||
|  | ||||
|             data["agent"] = agent.pk | ||||
|  | ||||
|         serializer = TaskSerializer(data=data) | ||||
| @@ -59,7 +66,6 @@ class GetEditDeleteAutoTask(APIView): | ||||
|     permission_classes = [IsAuthenticated, AutoTaskPerms] | ||||
|  | ||||
|     def get(self, request, pk): | ||||
|  | ||||
|         task = get_object_or_404(AutomatedTask, pk=pk) | ||||
|  | ||||
|         if task.agent and not _has_perm_on_agent(request.user, task.agent.agent_id): | ||||
| @@ -68,7 +74,6 @@ class GetEditDeleteAutoTask(APIView): | ||||
|         return Response(TaskSerializer(task).data) | ||||
|  | ||||
|     def put(self, request, pk): | ||||
|  | ||||
|         task = get_object_or_404(AutomatedTask, pk=pk) | ||||
|  | ||||
|         if task.agent and not _has_perm_on_agent(request.user, task.agent.agent_id): | ||||
|   | ||||
							
								
								
									
										0
									
								
								api/tacticalrmm/beta/v1/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								api/tacticalrmm/beta/v1/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								api/tacticalrmm/beta/v1/agent/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								api/tacticalrmm/beta/v1/agent/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										37
									
								
								api/tacticalrmm/beta/v1/agent/filter.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								api/tacticalrmm/beta/v1/agent/filter.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| import django_filters | ||||
| from agents.models import Agent | ||||
|  | ||||
|  | ||||
| class AgentFilter(django_filters.FilterSet): | ||||
|     last_seen_range = django_filters.DateTimeFromToRangeFilter(field_name="last_seen") | ||||
|     total_ram_range = django_filters.NumericRangeFilter(field_name="total_ram") | ||||
|     patches_last_installed_range = django_filters.DateTimeFromToRangeFilter( | ||||
|         field_name="patches_last_installed" | ||||
|     ) | ||||
|  | ||||
|     client_id = django_filters.NumberFilter(method="client_id_filter") | ||||
|  | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "operating_system", | ||||
|             "plat", | ||||
|             "monitoring_type", | ||||
|             "needs_reboot", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "alert_template", | ||||
|             "site", | ||||
|             "policy", | ||||
|             "last_seen_range", | ||||
|             "total_ram_range", | ||||
|             "patches_last_installed_range", | ||||
|         ] | ||||
|  | ||||
|     def client_id_filter(self, queryset, name, value): | ||||
|         if value: | ||||
|             return queryset.filter(site__client__id=value) | ||||
|         return queryset | ||||
							
								
								
									
										40
									
								
								api/tacticalrmm/beta/v1/agent/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								api/tacticalrmm/beta/v1/agent/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,40 @@ | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.filters import SearchFilter, OrderingFilter | ||||
| from rest_framework.request import Request | ||||
| from rest_framework.serializers import BaseSerializer | ||||
|  | ||||
| from agents.models import Agent | ||||
| from agents.permissions import AgentPerms | ||||
| from beta.v1.agent.filter import AgentFilter | ||||
| from beta.v1.pagination import StandardResultsSetPagination | ||||
| from ..serializers import DetailAgentSerializer, ListAgentSerializer | ||||
|  | ||||
|  | ||||
| class AgentViewSet(viewsets.ModelViewSet): | ||||
|     permission_classes = [IsAuthenticated, AgentPerms] | ||||
|     queryset = Agent.objects.all() | ||||
|     pagination_class = StandardResultsSetPagination | ||||
|     http_method_names = ["get", "put"] | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] | ||||
|     filterset_class = AgentFilter | ||||
|     search_fields = ["hostname", "services"] | ||||
|     ordering_fields = ["id"] | ||||
|     ordering = ["id"] | ||||
|  | ||||
|     def check_permissions(self, request: Request) -> None: | ||||
|         if "agent_id" in request.query_params: | ||||
|             self.kwargs["agent_id"] = request.query_params["agent_id"] | ||||
|         super().check_permissions(request) | ||||
|  | ||||
|     def get_permissions(self): | ||||
|         if self.request.method == "POST": | ||||
|             self.permission_classes = [IsAuthenticated] | ||||
|         return super().get_permissions() | ||||
|  | ||||
|     def get_serializer_class(self) -> type[BaseSerializer]: | ||||
|         if self.kwargs: | ||||
|             if self.kwargs["pk"]: | ||||
|                 return DetailAgentSerializer | ||||
|         return ListAgentSerializer | ||||
							
								
								
									
										0
									
								
								api/tacticalrmm/beta/v1/client/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								api/tacticalrmm/beta/v1/client/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										13
									
								
								api/tacticalrmm/beta/v1/client/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								api/tacticalrmm/beta/v1/client/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
|  | ||||
| from clients.models import Client | ||||
| from clients.permissions import ClientsPerms | ||||
| from ..serializers import ClientSerializer | ||||
|  | ||||
|  | ||||
| class ClientViewSet(viewsets.ModelViewSet): | ||||
|     permission_classes = [IsAuthenticated, ClientsPerms] | ||||
|     queryset = Client.objects.all() | ||||
|     serializer_class = ClientSerializer | ||||
|     http_method_names = ["get", "put"] | ||||
							
								
								
									
										7
									
								
								api/tacticalrmm/beta/v1/pagination.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								api/tacticalrmm/beta/v1/pagination.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | ||||
| from rest_framework.pagination import PageNumberPagination | ||||
|  | ||||
|  | ||||
| class StandardResultsSetPagination(PageNumberPagination): | ||||
|     page_size = 100 | ||||
|     page_size_query_param = "page_size" | ||||
|     max_page_size = 1000 | ||||
							
								
								
									
										73
									
								
								api/tacticalrmm/beta/v1/serializers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								api/tacticalrmm/beta/v1/serializers.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| from rest_framework import serializers | ||||
|  | ||||
| from agents.models import Agent | ||||
| from clients.models import Client, Site | ||||
|  | ||||
|  | ||||
| class ListAgentSerializer(serializers.ModelSerializer[Agent]): | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class DetailAgentSerializer(serializers.ModelSerializer[Agent]): | ||||
|     status = serializers.ReadOnlyField() | ||||
|  | ||||
|     class Meta: | ||||
|         model = Agent | ||||
|         fields = ( | ||||
|             "version", | ||||
|             "operating_system", | ||||
|             "plat", | ||||
|             "goarch", | ||||
|             "hostname", | ||||
|             "agent_id", | ||||
|             "last_seen", | ||||
|             "services", | ||||
|             "public_ip", | ||||
|             "total_ram", | ||||
|             "disks", | ||||
|             "boot_time", | ||||
|             "logged_in_username", | ||||
|             "last_logged_in_user", | ||||
|             "monitoring_type", | ||||
|             "description", | ||||
|             "mesh_node_id", | ||||
|             "overdue_email_alert", | ||||
|             "overdue_text_alert", | ||||
|             "overdue_dashboard_alert", | ||||
|             "offline_time", | ||||
|             "overdue_time", | ||||
|             "check_interval", | ||||
|             "needs_reboot", | ||||
|             "choco_installed", | ||||
|             "wmi_detail", | ||||
|             "patches_last_installed", | ||||
|             "time_zone", | ||||
|             "maintenance_mode", | ||||
|             "block_policy_inheritance", | ||||
|             "alert_template", | ||||
|             "site", | ||||
|             "policy", | ||||
|             "status", | ||||
|             "checks", | ||||
|             "pending_actions_count", | ||||
|             "cpu_model", | ||||
|             "graphics", | ||||
|             "local_ips", | ||||
|             "make_model", | ||||
|             "physical_disks", | ||||
|             "serial_number", | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class ClientSerializer(serializers.ModelSerializer[Client]): | ||||
|     class Meta: | ||||
|         model = Client | ||||
|         fields = "__all__" | ||||
|  | ||||
|  | ||||
| class SiteSerializer(serializers.ModelSerializer[Site]): | ||||
|     class Meta: | ||||
|         model = Site | ||||
|         fields = "__all__" | ||||
							
								
								
									
										21
									
								
								api/tacticalrmm/beta/v1/site/views.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								api/tacticalrmm/beta/v1/site/views.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| from rest_framework import viewsets | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from rest_framework.filters import SearchFilter, OrderingFilter | ||||
|  | ||||
| from clients.models import Site | ||||
| from clients.permissions import SitesPerms | ||||
| from beta.v1.pagination import StandardResultsSetPagination | ||||
| from ..serializers import SiteSerializer | ||||
|  | ||||
|  | ||||
| class SiteViewSet(viewsets.ModelViewSet): | ||||
|     permission_classes = [IsAuthenticated, SitesPerms] | ||||
|     queryset = Site.objects.all() | ||||
|     serializer_class = SiteSerializer | ||||
|     pagination_class = StandardResultsSetPagination | ||||
|     http_method_names = ["get", "put"] | ||||
|     filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] | ||||
|     search_fields = ["name"] | ||||
|     ordering_fields = ["id"] | ||||
|     ordering = ["id"] | ||||
							
								
								
									
										12
									
								
								api/tacticalrmm/beta/v1/urls.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								api/tacticalrmm/beta/v1/urls.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| from rest_framework import routers | ||||
| from .agent import views as agent | ||||
| from .client import views as client | ||||
| from .site import views as site | ||||
|  | ||||
| router = routers.DefaultRouter() | ||||
|  | ||||
| router.register("agent", agent.AgentViewSet, basename="agent") | ||||
| router.register("client", client.ClientViewSet, basename="client") | ||||
| router.register("site", site.SiteViewSet, basename="site") | ||||
|  | ||||
| urlpatterns = router.urls | ||||
							
								
								
									
										25
									
								
								api/tacticalrmm/checks/migrations/0031_check_env_vars.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										25
									
								
								api/tacticalrmm/checks/migrations/0031_check_env_vars.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,25 @@ | ||||
| # Generated by Django 4.1.3 on 2022-12-03 09:38 | ||||
|  | ||||
| import django.contrib.postgres.fields | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("checks", "0030_alter_checkresult_retcode"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name="check", | ||||
|             name="env_vars", | ||||
|             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,23 @@ | ||||
| # Generated by Django 4.2.10 on 2024-02-19 05:57 | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ("checks", "0031_check_env_vars"), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name="checkhistory", | ||||
|             name="id", | ||||
|             field=models.BigAutoField(primary_key=True, serialize=False), | ||||
|         ), | ||||
|         migrations.AlterField( | ||||
|             model_name="checkresult", | ||||
|             name="id", | ||||
|             field=models.BigAutoField(primary_key=True, serialize=False), | ||||
|         ), | ||||
|     ] | ||||
| @@ -19,6 +19,7 @@ from tacticalrmm.constants import ( | ||||
|     EvtLogNames, | ||||
|     EvtLogTypes, | ||||
| ) | ||||
| from tacticalrmm.helpers import has_script_actions, has_webhook | ||||
| from tacticalrmm.models import PermissionQuerySet | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
| @@ -98,6 +99,12 @@ class Check(BaseAuditModel): | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     env_vars = ArrayField( | ||||
|         models.TextField(null=True, blank=True), | ||||
|         null=True, | ||||
|         blank=True, | ||||
|         default=list, | ||||
|     ) | ||||
|     info_return_codes = ArrayField( | ||||
|         models.PositiveIntegerField(), | ||||
|         null=True, | ||||
| @@ -153,7 +160,6 @@ class Check(BaseAuditModel): | ||||
|         return f"{self.policy.name} - {self.readable_desc}" | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|  | ||||
|         # if check is a policy check clear cache on everything | ||||
|         if self.policy: | ||||
|             cache.delete_many_pattern("site_*_checks") | ||||
| @@ -163,13 +169,9 @@ class Check(BaseAuditModel): | ||||
|         elif self.agent: | ||||
|             cache.delete(f"agent_{self.agent.agent_id}_checks") | ||||
|  | ||||
|         super(Check, self).save( | ||||
|             *args, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     def delete(self, *args, **kwargs): | ||||
|  | ||||
|         # if check is a policy check clear cache on everything | ||||
|         if self.policy: | ||||
|             cache.delete_many_pattern("site_*_checks") | ||||
| @@ -179,16 +181,12 @@ class Check(BaseAuditModel): | ||||
|         elif self.agent: | ||||
|             cache.delete(f"agent_{self.agent.agent_id}_checks") | ||||
|  | ||||
|         super(Check, self).delete( | ||||
|             *args, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         super().delete(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def readable_desc(self): | ||||
|         display = self.get_check_type_display()  # type: ignore | ||||
|         if self.check_type == CheckType.DISK_SPACE: | ||||
|  | ||||
|             text = "" | ||||
|             if self.warning_threshold: | ||||
|                 text += f" Warning Threshold: {self.warning_threshold}%" | ||||
| @@ -220,9 +218,6 @@ class Check(BaseAuditModel): | ||||
|         return CHECKS_NON_EDITABLE_FIELDS | ||||
|  | ||||
|     def create_policy_check(self, policy: "Policy") -> None: | ||||
|  | ||||
|         fields_to_copy = POLICY_CHECK_FIELDS_TO_COPY | ||||
|  | ||||
|         check = Check.objects.create( | ||||
|             policy=policy, | ||||
|         ) | ||||
| @@ -230,25 +225,25 @@ class Check(BaseAuditModel): | ||||
|         for task in self.assignedtasks.all():  # type: ignore | ||||
|             task.create_policy_task(policy=policy, assigned_check=check) | ||||
|  | ||||
|         for field in fields_to_copy: | ||||
|         for field in POLICY_CHECK_FIELDS_TO_COPY: | ||||
|             setattr(check, field, getattr(self, field)) | ||||
|  | ||||
|         check.save() | ||||
|  | ||||
|     def should_create_alert(self, alert_template=None): | ||||
|  | ||||
|         has_check_notifications = ( | ||||
|             self.dashboard_alert or self.email_alert or self.text_alert | ||||
|         ) | ||||
|         has_alert_template_notification = alert_template and ( | ||||
|             alert_template.check_always_alert | ||||
|             or alert_template.check_always_email | ||||
|             or alert_template.check_always_text | ||||
|         ) | ||||
|         return ( | ||||
|             self.dashboard_alert | ||||
|             or self.email_alert | ||||
|             or self.text_alert | ||||
|             or ( | ||||
|                 alert_template | ||||
|                 and ( | ||||
|                     alert_template.check_always_alert | ||||
|                     or alert_template.check_always_email | ||||
|                     or alert_template.check_always_text | ||||
|                 ) | ||||
|             ) | ||||
|             has_check_notifications | ||||
|             or has_alert_template_notification | ||||
|             or has_webhook(alert_template, "check") | ||||
|             or has_script_actions(alert_template, "check") | ||||
|         ) | ||||
|  | ||||
|     def add_check_history( | ||||
| @@ -291,6 +286,7 @@ class CheckResult(models.Model): | ||||
|     class Meta: | ||||
|         unique_together = (("agent", "assigned_check"),) | ||||
|  | ||||
|     id = models.BigAutoField(primary_key=True) | ||||
|     agent = models.ForeignKey( | ||||
|         "agents.Agent", | ||||
|         related_name="checkresults", | ||||
| @@ -330,7 +326,6 @@ class CheckResult(models.Model): | ||||
|         return f"{self.agent.hostname} - {self.assigned_check}" | ||||
|  | ||||
|     def save(self, *args, **kwargs): | ||||
|  | ||||
|         # if check is a policy check clear cache on everything | ||||
|         if not self.alert_severity and self.assigned_check.check_type in ( | ||||
|             CheckType.MEMORY, | ||||
| @@ -340,10 +335,7 @@ class CheckResult(models.Model): | ||||
|         ): | ||||
|             self.alert_severity = AlertSeverity.WARNING | ||||
|  | ||||
|         super(CheckResult, self).save( | ||||
|             *args, | ||||
|             **kwargs, | ||||
|         ) | ||||
|         super().save(*args, **kwargs) | ||||
|  | ||||
|     @property | ||||
|     def history_info(self): | ||||
| @@ -368,15 +360,16 @@ class CheckResult(models.Model): | ||||
|         update_fields = [] | ||||
|         # cpuload or mem checks | ||||
|         if check.check_type in (CheckType.CPU_LOAD, CheckType.MEMORY): | ||||
|  | ||||
|             self.history.append(data["percent"]) | ||||
|  | ||||
|             if len(self.history) > 15: | ||||
|                 self.history = self.history[-15:] | ||||
|  | ||||
|             update_fields.extend(["history"]) | ||||
|             update_fields.extend(["history", "more_info"]) | ||||
|  | ||||
|             avg = int(mean(self.history)) | ||||
|             txt = "Memory Usage" if check.check_type == CheckType.MEMORY else "CPU Load" | ||||
|             self.more_info = f"Average {txt}: {avg}%" | ||||
|  | ||||
|             if check.error_threshold and avg > check.error_threshold: | ||||
|                 self.status = CheckStatus.FAILING | ||||
| @@ -533,7 +526,6 @@ class CheckResult(models.Model): | ||||
|         return self.status | ||||
|  | ||||
|     def send_email(self): | ||||
|  | ||||
|         CORE = get_core_settings() | ||||
|  | ||||
|         body: str = "" | ||||
| @@ -562,14 +554,12 @@ class CheckResult(models.Model): | ||||
|                 body = subject + f" - Disk {self.assigned_check.disk} does not exist" | ||||
|  | ||||
|         elif self.assigned_check.check_type == CheckType.SCRIPT: | ||||
|  | ||||
|             body = ( | ||||
|                 subject | ||||
|                 + f" - Return code: {self.retcode}\nStdout:{self.stdout}\nStderr: {self.stderr}" | ||||
|             ) | ||||
|  | ||||
|         elif self.assigned_check.check_type == CheckType.PING: | ||||
|  | ||||
|             body = self.more_info | ||||
|  | ||||
|         elif self.assigned_check.check_type in (CheckType.CPU_LOAD, CheckType.MEMORY): | ||||
| @@ -591,7 +581,6 @@ class CheckResult(models.Model): | ||||
|             body = subject + f" - Status: {self.more_info}" | ||||
|  | ||||
|         elif self.assigned_check.check_type == CheckType.EVENT_LOG: | ||||
|  | ||||
|             if self.assigned_check.event_source and self.assigned_check.event_message: | ||||
|                 start = f"Event ID {self.assigned_check.event_id}, source {self.assigned_check.event_source}, containing string {self.assigned_check.event_message} " | ||||
|             elif self.assigned_check.event_source: | ||||
| @@ -613,7 +602,6 @@ class CheckResult(models.Model): | ||||
|         CORE.send_mail(subject, body, alert_template=self.agent.alert_template) | ||||
|  | ||||
|     def send_sms(self): | ||||
|  | ||||
|         CORE = get_core_settings() | ||||
|         body: str = "" | ||||
|  | ||||
| @@ -681,6 +669,7 @@ class CheckResult(models.Model): | ||||
| class CheckHistory(models.Model): | ||||
|     objects = PermissionQuerySet.as_manager() | ||||
|  | ||||
|     id = models.BigAutoField(primary_key=True) | ||||
|     check_id = models.PositiveIntegerField(default=0) | ||||
|     agent_id = models.CharField(max_length=200, null=True, blank=True) | ||||
|     x = models.DateTimeField(auto_now_add=True) | ||||
|   | ||||
| @@ -1,6 +1,11 @@ | ||||
| from rest_framework import permissions | ||||
|  | ||||
| from tacticalrmm.permissions import _has_perm, _has_perm_on_agent | ||||
| from tacticalrmm.permissions import ( | ||||
|     _has_perm, | ||||
|     _has_perm_on_agent, | ||||
|     _has_perm_on_client, | ||||
|     _has_perm_on_site, | ||||
| ) | ||||
|  | ||||
|  | ||||
| class ChecksPerms(permissions.BasePermission): | ||||
| @@ -21,3 +26,17 @@ class RunChecksPerms(permissions.BasePermission): | ||||
|         return _has_perm(r, "can_run_checks") and _has_perm_on_agent( | ||||
|             r.user, view.kwargs["agent_id"] | ||||
|         ) | ||||
|  | ||||
|  | ||||
| class BulkRunChecksPerms(permissions.BasePermission): | ||||
|     def has_permission(self, r, view) -> bool: | ||||
|         if not _has_perm(r, "can_run_checks"): | ||||
|             return False | ||||
|  | ||||
|         if view.kwargs["target"] == "client": | ||||
|             return _has_perm_on_client(user=r.user, client_id=view.kwargs["pk"]) | ||||
|  | ||||
|         elif view.kwargs["target"] == "site": | ||||
|             return _has_perm_on_site(user=r.user, site_id=view.kwargs["pk"]) | ||||
|  | ||||
|         return False | ||||
|   | ||||
| @@ -22,7 +22,6 @@ class CheckResultSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|  | ||||
| class CheckSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|     readable_desc = serializers.ReadOnlyField() | ||||
|     assignedtasks = AssignedTaskField(many=True, read_only=True) | ||||
|     alert_template = serializers.SerializerMethodField() | ||||
| @@ -82,7 +81,7 @@ class CheckSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|             if not val["warning_threshold"] and not val["error_threshold"]: | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Warning threshold or Error Threshold must be set" | ||||
|                     "Warning threshold or Error Threshold must be set" | ||||
|                 ) | ||||
|  | ||||
|             if ( | ||||
| @@ -91,7 +90,7 @@ class CheckSerializer(serializers.ModelSerializer): | ||||
|                 and val["error_threshold"] > 0 | ||||
|             ): | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Warning threshold must be greater than Error Threshold" | ||||
|                     "Warning threshold must be greater than Error Threshold" | ||||
|                 ) | ||||
|  | ||||
|         # ping checks | ||||
| @@ -113,7 +112,7 @@ class CheckSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|             if not val["warning_threshold"] and not val["error_threshold"]: | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Warning threshold or Error Threshold must be set" | ||||
|                     "Warning threshold or Error Threshold must be set" | ||||
|                 ) | ||||
|  | ||||
|             if ( | ||||
| @@ -122,7 +121,7 @@ class CheckSerializer(serializers.ModelSerializer): | ||||
|                 and val["error_threshold"] > 0 | ||||
|             ): | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Warning threshold must be less than Error Threshold" | ||||
|                     "Warning threshold must be less than Error Threshold" | ||||
|                 ) | ||||
|  | ||||
|         if check_type == CheckType.MEMORY and not self.instance: | ||||
| @@ -133,7 +132,7 @@ class CheckSerializer(serializers.ModelSerializer): | ||||
|  | ||||
|             if not val["warning_threshold"] and not val["error_threshold"]: | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Warning threshold or Error Threshold must be set" | ||||
|                     "Warning threshold or Error Threshold must be set" | ||||
|                 ) | ||||
|  | ||||
|             if ( | ||||
| @@ -142,7 +141,7 @@ class CheckSerializer(serializers.ModelSerializer): | ||||
|                 and val["error_threshold"] > 0 | ||||
|             ): | ||||
|                 raise serializers.ValidationError( | ||||
|                     f"Warning threshold must be less than Error Threshold" | ||||
|                     "Warning threshold must be less than Error Threshold" | ||||
|                 ) | ||||
|  | ||||
|         return val | ||||
| @@ -158,6 +157,7 @@ class CheckRunnerGetSerializer(serializers.ModelSerializer): | ||||
|     # only send data needed for agent to run a check | ||||
|     script = ScriptCheckSerializer(read_only=True) | ||||
|     script_args = serializers.SerializerMethodField() | ||||
|     env_vars = serializers.SerializerMethodField() | ||||
|  | ||||
|     def get_script_args(self, obj): | ||||
|         if obj.check_type != CheckType.SCRIPT: | ||||
| @@ -168,6 +168,18 @@ class CheckRunnerGetSerializer(serializers.ModelSerializer): | ||||
|             agent=agent, shell=obj.script.shell, args=obj.script_args | ||||
|         ) | ||||
|  | ||||
|     def get_env_vars(self, obj): | ||||
|         if obj.check_type != CheckType.SCRIPT: | ||||
|             return [] | ||||
|  | ||||
|         agent = self.context["agent"] if "agent" in self.context.keys() else obj.agent | ||||
|  | ||||
|         return Script.parse_script_env_vars( | ||||
|             agent=agent, | ||||
|             shell=obj.script.shell, | ||||
|             env_vars=obj.env_vars, | ||||
|         ) | ||||
|  | ||||
|     class Meta: | ||||
|         model = Check | ||||
|         exclude = [ | ||||
|   | ||||
| @@ -1,5 +1,4 @@ | ||||
| import datetime as dt | ||||
| import random | ||||
| from time import sleep | ||||
| from typing import Optional | ||||
|  | ||||
| @@ -8,6 +7,8 @@ from django.utils import timezone as djangotime | ||||
| from alerts.models import Alert | ||||
| from checks.models import CheckResult | ||||
| from tacticalrmm.celery import app | ||||
| from tacticalrmm.helpers import rand_range | ||||
| from tacticalrmm.logger import logger | ||||
|  | ||||
|  | ||||
| @app.task | ||||
| @@ -24,7 +25,7 @@ def handle_check_email_alert_task( | ||||
|         check_result = CheckResult.objects.get( | ||||
|             assigned_check=alert.assigned_check, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 5)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         check_result.send_email() | ||||
|         alert.email_sent = djangotime.now() | ||||
|         alert.save(update_fields=["email_sent"]) | ||||
| @@ -36,7 +37,7 @@ def handle_check_email_alert_task( | ||||
|                 check_result = CheckResult.objects.get( | ||||
|                     assigned_check=alert.assigned_check, agent=alert.agent | ||||
|                 ) | ||||
|                 sleep(random.randint(1, 5)) | ||||
|                 sleep(rand_range(100, 1500)) | ||||
|                 check_result.send_email() | ||||
|                 alert.email_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["email_sent"]) | ||||
| @@ -46,7 +47,6 @@ def handle_check_email_alert_task( | ||||
|  | ||||
| @app.task | ||||
| def handle_check_sms_alert_task(pk: int, alert_interval: Optional[float] = None) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -57,7 +57,7 @@ def handle_check_sms_alert_task(pk: int, alert_interval: Optional[float] = None) | ||||
|         check_result = CheckResult.objects.get( | ||||
|             assigned_check=alert.assigned_check, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 3)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         check_result.send_sms() | ||||
|         alert.sms_sent = djangotime.now() | ||||
|         alert.save(update_fields=["sms_sent"]) | ||||
| @@ -69,7 +69,7 @@ def handle_check_sms_alert_task(pk: int, alert_interval: Optional[float] = None) | ||||
|                 check_result = CheckResult.objects.get( | ||||
|                     assigned_check=alert.assigned_check, agent=alert.agent | ||||
|                 ) | ||||
|                 sleep(random.randint(1, 3)) | ||||
|                 sleep(rand_range(100, 1500)) | ||||
|                 check_result.send_sms() | ||||
|                 alert.sms_sent = djangotime.now() | ||||
|                 alert.save(update_fields=["sms_sent"]) | ||||
| @@ -79,7 +79,6 @@ def handle_check_sms_alert_task(pk: int, alert_interval: Optional[float] = None) | ||||
|  | ||||
| @app.task | ||||
| def handle_resolved_check_sms_alert_task(pk: int) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -90,7 +89,7 @@ def handle_resolved_check_sms_alert_task(pk: int) -> str: | ||||
|         check_result = CheckResult.objects.get( | ||||
|             assigned_check=alert.assigned_check, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 3)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         check_result.send_resolved_sms() | ||||
|         alert.resolved_sms_sent = djangotime.now() | ||||
|         alert.save(update_fields=["resolved_sms_sent"]) | ||||
| @@ -100,7 +99,6 @@ def handle_resolved_check_sms_alert_task(pk: int) -> str: | ||||
|  | ||||
| @app.task | ||||
| def handle_resolved_check_email_alert_task(pk: int) -> str: | ||||
|  | ||||
|     try: | ||||
|         alert = Alert.objects.get(pk=pk) | ||||
|     except Alert.DoesNotExist: | ||||
| @@ -111,7 +109,7 @@ def handle_resolved_check_email_alert_task(pk: int) -> str: | ||||
|         check_result = CheckResult.objects.get( | ||||
|             assigned_check=alert.assigned_check, agent=alert.agent | ||||
|         ) | ||||
|         sleep(random.randint(1, 5)) | ||||
|         sleep(rand_range(100, 1500)) | ||||
|         check_result.send_resolved_email() | ||||
|         alert.resolved_email_sent = djangotime.now() | ||||
|         alert.save(update_fields=["resolved_email_sent"]) | ||||
| @@ -123,9 +121,9 @@ def handle_resolved_check_email_alert_task(pk: int) -> str: | ||||
| def prune_check_history(older_than_days: int) -> str: | ||||
|     from .models import CheckHistory | ||||
|  | ||||
|     CheckHistory.objects.filter( | ||||
|         x__lt=djangotime.make_aware(dt.datetime.today()) | ||||
|         - djangotime.timedelta(days=older_than_days) | ||||
|     c, _ = CheckHistory.objects.filter( | ||||
|         x__lt=djangotime.now() - djangotime.timedelta(days=older_than_days) | ||||
|     ).delete() | ||||
|     logger.info(f"Pruned {c} check history objects") | ||||
|  | ||||
|     return "ok" | ||||
|   | ||||
| @@ -41,7 +41,7 @@ class TestCheckViews(TacticalTestCase): | ||||
|         self.assertEqual(len(resp.data), 4) | ||||
|  | ||||
|         # test agent doesn't exist | ||||
|         url = f"/agents/jh3498uf8fkh4ro8hfd8df98/checks/" | ||||
|         url = "/agents/jh3498uf8fkh4ro8hfd8df98/checks/" | ||||
|         resp = self.client.get(url, format="json") | ||||
|         self.assertEqual(resp.status_code, 404) | ||||
|  | ||||
| @@ -101,8 +101,7 @@ class TestCheckViews(TacticalTestCase): | ||||
|             "fails_b4_alert": 3, | ||||
|         } | ||||
|  | ||||
|         for payload in [agent_payload, policy_payload]: | ||||
|  | ||||
|         for payload in (agent_payload, policy_payload): | ||||
|             # add valid check | ||||
|             resp = self.client.post(url, payload, format="json") | ||||
|             self.assertEqual(resp.status_code, 200) | ||||
| @@ -148,8 +147,7 @@ class TestCheckViews(TacticalTestCase): | ||||
|             "fails_b4_alert": 9, | ||||
|         } | ||||
|  | ||||
|         for payload in [agent_payload, policy_payload]: | ||||
|  | ||||
|         for payload in (agent_payload, policy_payload): | ||||
|             # add cpu check | ||||
|             resp = self.client.post(url, payload, format="json") | ||||
|             self.assertEqual(resp.status_code, 200) | ||||
| @@ -174,6 +172,31 @@ class TestCheckViews(TacticalTestCase): | ||||
|  | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     def test_reset_all_checks_status(self): | ||||
|         # setup data | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         check = baker.make_recipe("checks.diskspace_check", agent=agent) | ||||
|         baker.make("checks.CheckResult", assigned_check=check, agent=agent) | ||||
|         baker.make( | ||||
|             "checks.CheckHistory", | ||||
|             check_id=check.id, | ||||
|             agent_id=agent.agent_id, | ||||
|             _quantity=30, | ||||
|         ) | ||||
|         baker.make( | ||||
|             "checks.CheckHistory", | ||||
|             check_id=check.id, | ||||
|             agent_id=agent.agent_id, | ||||
|             _quantity=30, | ||||
|         ) | ||||
|  | ||||
|         url = f"{base_url}/{agent.agent_id}/resetall/" | ||||
|  | ||||
|         resp = self.client.post(url) | ||||
|         self.assertEqual(resp.status_code, 200) | ||||
|  | ||||
|         self.check_not_authenticated("post", url) | ||||
|  | ||||
|     def test_add_memory_check(self): | ||||
|         url = f"{base_url}/" | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
| @@ -195,8 +218,7 @@ class TestCheckViews(TacticalTestCase): | ||||
|             "fails_b4_alert": 1, | ||||
|         } | ||||
|  | ||||
|         for payload in [agent_payload, policy_payload]: | ||||
|  | ||||
|         for payload in (agent_payload, policy_payload): | ||||
|             # add memory check | ||||
|             resp = self.client.post(url, payload, format="json") | ||||
|             self.assertEqual(resp.status_code, 200) | ||||
| @@ -884,12 +906,12 @@ class TestCheckPermissions(TacticalTestCase): | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         policy = baker.make("automation.Policy") | ||||
|         unauthorized_agent = baker.make_recipe("agents.agent") | ||||
|         check = baker.make("checks.Check", agent=agent, _quantity=5) | ||||
|         unauthorized_check = baker.make( | ||||
|         check = baker.make("checks.Check", agent=agent, _quantity=5)  # noqa | ||||
|         unauthorized_check = baker.make(  # noqa | ||||
|             "checks.Check", agent=unauthorized_agent, _quantity=7 | ||||
|         ) | ||||
|  | ||||
|         policy_checks = baker.make("checks.Check", policy=policy, _quantity=2) | ||||
|         policy_checks = baker.make("checks.Check", policy=policy, _quantity=2)  # noqa | ||||
|  | ||||
|         # test super user access | ||||
|         self.check_authorized_superuser("get", f"{base_url}/") | ||||
| @@ -972,7 +994,7 @@ class TestCheckPermissions(TacticalTestCase): | ||||
|  | ||||
|         url = f"{base_url}/" | ||||
|  | ||||
|         for data in [policy_data, agent_data]: | ||||
|         for data in (policy_data, agent_data): | ||||
|             # test superuser access | ||||
|             self.check_authorized_superuser("post", url, data) | ||||
|  | ||||
| @@ -1006,8 +1028,7 @@ class TestCheckPermissions(TacticalTestCase): | ||||
|         unauthorized_check = baker.make("checks.Check", agent=unauthorized_agent) | ||||
|         policy_check = baker.make("checks.Check", policy=policy) | ||||
|  | ||||
|         for method in ["get", "put", "delete"]: | ||||
|  | ||||
|         for method in ("get", "put", "delete"): | ||||
|             url = f"{base_url}/{check.id}/" | ||||
|             unauthorized_url = f"{base_url}/{unauthorized_check.id}/" | ||||
|             policy_url = f"{base_url}/{policy_check.id}/" | ||||
| @@ -1046,7 +1067,6 @@ class TestCheckPermissions(TacticalTestCase): | ||||
|  | ||||
|     @patch("agents.models.Agent.nats_cmd") | ||||
|     def test_check_action_permissions(self, nats_cmd): | ||||
|  | ||||
|         agent = baker.make_recipe("agents.agent") | ||||
|         unauthorized_agent = baker.make_recipe("agents.agent") | ||||
|         check = baker.make("checks.Check", agent=agent) | ||||
| @@ -1060,7 +1080,7 @@ class TestCheckPermissions(TacticalTestCase): | ||||
|             assigned_check=unauthorized_check, | ||||
|         ) | ||||
|  | ||||
|         for action in ["reset", "run"]: | ||||
|         for action in ("reset", "run"): | ||||
|             if action == "reset": | ||||
|                 url = f"{base_url}/{check_result.id}/{action}/" | ||||
|                 unauthorized_url = ( | ||||
|   | ||||
| @@ -6,6 +6,8 @@ urlpatterns = [ | ||||
|     path("", views.GetAddChecks.as_view()), | ||||
|     path("<int:pk>/", views.GetUpdateDeleteCheck.as_view()), | ||||
|     path("<int:pk>/reset/", views.ResetCheck.as_view()), | ||||
|     path("<agent:agent_id>/resetall/", views.ResetAllChecksStatus.as_view()), | ||||
|     path("<agent:agent_id>/run/", views.run_checks), | ||||
|     path("<int:pk>/history/", views.GetCheckHistory.as_view()), | ||||
|     path("<str:target>/<int:pk>/csbulkrun/", views.bulk_run_checks), | ||||
| ] | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user