Compare commits

..

174 Commits

Author SHA1 Message Date
Milo Schwartz
489f6bed17 Merge pull request #202 from fosrl/dev
hotfixes coming from beta13
2025-02-14 16:53:58 -05:00
Milo Schwartz
6aa4908446 bump version 2025-02-14 16:53:05 -05:00
Milo Schwartz
d5a220a004 create target validator and add url validator 2025-02-14 16:46:46 -05:00
Owen
a418195b28 Fix ip range pick initial range; add test 2025-02-14 15:49:40 -05:00
Milo Schwartz
2ff6d1d117 allow any string as target 2025-02-14 13:27:34 -05:00
Milo Schwartz
8dd30c88ab fix reset password sql error 2025-02-14 13:12:29 -05:00
Owen
7797c6c770 Allow the chars from RFC 3986 2025-02-14 12:38:28 -05:00
Owen
40922fedb8 Support v6 2025-02-14 12:32:18 -05:00
Milo Schwartz
4c1366ef91 force router refresh on save closes #198 2025-02-14 12:27:03 -05:00
Owen
f61d442989 Allow . in path; resolves #199 2025-02-14 09:51:17 -05:00
Milo Schwartz
b1702bf99a Merge pull request #194 from fosrl/dev
access control rules
2025-02-13 14:48:35 -05:00
Milo Schwartz
a35e24bc0e fix table filters and update readme 2025-02-13 14:45:32 -05:00
Milo Schwartz
c230e034cf update readme 2025-02-12 23:01:00 -05:00
Milo Schwartz
06ceff7427 change migration script text 2025-02-12 22:29:42 -05:00
Milo Schwartz
19273ddbd5 use zod for rules ip validation 2025-02-12 21:52:58 -05:00
Milo Schwartz
fdf1dfdeba rules server validation, enabled toggle, fix wildcard 2025-02-11 23:59:13 -05:00
Milo Schwartz
f14ecf50e4 add docker deployment snippets to create site form 2025-02-10 22:26:29 -05:00
Milo Schwartz
c244ef387b make subdomain input better accommodate long domains 2025-02-10 21:48:34 -05:00
Milo Schwartz
8165051dd8 fix toast dismiss causing components to rerender and clean up rules text 2025-02-10 21:35:06 -05:00
Milo Schwartz
6fba13c8d1 Merge pull request #185 from fosrl/rules
Rules
2025-02-10 21:11:57 -05:00
Owen
3c99fbb1ef Seperate ip and cidr 2025-02-10 21:06:37 -05:00
Milo Schwartz
5b44ffa2fb Merge branch 'rules' of https://github.com/fosrl/pangolin into rules 2025-02-09 23:24:09 -05:00
Milo Schwartz
6e6992e19f add rules info card 2025-02-09 23:23:55 -05:00
Owen
4bce210ff5 Be more lenient with leading and trailing slashes 2025-02-09 22:03:18 -05:00
Owen
bbc1a9eac4 Format 2025-02-09 22:00:02 -05:00
Owen
5e92aebd20 Drop first 2025-02-09 21:56:39 -05:00
Owen
2428738fa6 Fix missing ruleId issue 2025-02-09 21:47:59 -05:00
Owen
34e3fe690d Fix check on string 2025-02-09 11:33:40 -05:00
Owen
c415ceef8d Add migrations 2025-02-09 11:10:19 -05:00
Owen
73798f9e61 Add ecr login 2025-02-09 11:05:42 -05:00
Owen
9694261f3e Add enable rules toggle 2025-02-09 11:02:40 -05:00
Owen
874c67345e Adjust rule processing 2025-02-09 10:50:43 -05:00
Owen
42434ca832 Add validation 2025-02-08 17:54:01 -05:00
Owen
4a6da91faf API and rule screen working 2025-02-08 17:38:30 -05:00
Owen
8f96d0795c Add update 2025-02-08 17:10:37 -05:00
Owen
da3c8823f8 rename to resource rules and add api endpoints 2025-02-08 17:07:21 -05:00
Owen
3cd20cab55 Merge branch 'dev' into rules 2025-02-08 16:55:46 -05:00
Milo Schwartz
b1fa980f56 expand list of allowed special characters in password 2025-02-08 16:04:41 -05:00
Milo Schwartz
ef0bc9a764 add note about backup codes to mfa form 2025-02-08 15:55:49 -05:00
Milo Schwartz
dc2ec5b73b add description to whitelist email field 2025-02-08 15:51:28 -05:00
Milo Schwartz
d8a089fbc2 remove annoying debug log 2025-02-08 15:47:01 -05:00
Milo Schwartz
00a0d89d6c add allow_base_domain_resources to installer 2025-02-08 12:26:52 -05:00
Owen
2f49be69fe Initial pass at rules 2025-02-06 21:42:18 -05:00
Owen
b92639647a Add applyRules to resources 2025-02-06 21:19:55 -05:00
Owen
befdc3a002 Add table 2025-02-06 21:18:34 -05:00
Milo Schwartz
3c7025a327 add strict rate limit to endpoints that send email 2025-02-05 22:46:33 -05:00
Milo Schwartz
58a084426b allow logout to fail 2025-02-05 22:00:29 -05:00
Milo Schwartz
d070415515 fix table page size selector 2025-02-05 21:56:28 -05:00
Milo Schwartz
3fa7132534 fix update resource without subdomain 2025-02-05 21:32:49 -05:00
Milo Schwartz
feeeba5cee fix path in cicd 2025-02-04 22:46:41 -05:00
Milo Schwartz
9e5d5e8990 Merge pull request #159 from fosrl/dev
1.0.0-beta12
2025-02-04 22:45:03 -05:00
Owen
c51f1cb6a2 Add network config to compose; Resolves #155 2025-02-04 22:28:37 -05:00
Milo Schwartz
786551d86a replace version in consts file instead of package.json 2025-02-04 22:23:13 -05:00
Owen
0e73365106 Pull dashboard url for the newt config 2025-02-04 22:14:11 -05:00
Milo Schwartz
b6963a9c35 allow 80 or 443 raw resources 2025-02-04 21:39:13 -05:00
Milo Schwartz
bc0b467f1a update traefik version 2025-02-03 22:58:04 -05:00
Milo Schwartz
7cf798851c fix sorting auth column if no auth closes #149 2025-02-03 22:47:47 -05:00
Milo Schwartz
e475c1ea50 all resources at the base domain closes #137 2025-02-03 21:18:16 -05:00
Milo Schwartz
0840c166ab prevent api resource updates if raw resources is disabled 2025-02-02 16:22:00 -05:00
Milo Schwartz
65a537a670 make update raw resource port functional 2025-02-02 16:03:10 -05:00
Milo Schwartz
a7c99b016c prevent raw tcp on port 80 or 443 2025-02-02 15:47:29 -05:00
Milo Schwartz
6a8132546e reset create resource form on dialog close closes #145 2025-02-02 15:36:43 -05:00
Milo Schwartz
94ce5edc61 pull app version from consts instead of package.json 2025-02-02 15:30:41 -05:00
Milo Schwartz
889f8e1394 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-02-01 21:19:35 -05:00
Milo Schwartz
9d36198459 fix search id value in command items 2025-02-01 21:19:24 -05:00
Owen Schwartz
673635a585 Merge pull request #64 from j4n-e4t/j4n-e4t/transfer-resource-to-new-site
Transfer a resource to another site
2025-02-01 21:14:17 -05:00
Milo Schwartz
53660a163c minor changes to verbiage and id value 2025-02-01 21:11:31 -05:00
Owen Schwartz
b5420a40ab Clean up and add target manipulation 2025-02-01 18:36:12 -05:00
Owen Schwartz
962c5fb886 Merge branch 'dev' into transfer-resource-to-new-site 2025-02-01 17:03:05 -05:00
Milo Schwartz
7d6dd9e9fd Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-02-01 16:52:30 -05:00
Milo Schwartz
dc9b1f1efd add project board to readme 2025-02-01 16:52:18 -05:00
Owen Schwartz
3257c39fca Merge pull request #130 from nkkfs/patch-3
Update pl.md
2025-02-01 10:29:40 -05:00
Kamil
8b43c6f9c5 Update pl.md
Add Authentication Site strings
2025-02-01 08:56:20 +01:00
Owen Schwartz
8b5cac40e0 Merge pull request #120 from synologyy/main
german-translation
2025-01-31 15:16:32 -05:00
Milo Schwartz
722b877ea5 Merge pull request #125 from fosrl/dev
Hotfix Various Bugs
2025-01-31 15:11:48 -05:00
Owen Schwartz
a9477d7eb9 Complex filter generating config; Resolves #124 2025-01-31 15:07:28 -05:00
Milo Schwartz
bb5573a8f4 allow comma in password closes #121 2025-01-31 15:03:36 -05:00
synologyy
81571a8fb7 german-translation 2025-01-31 09:01:00 +01:00
Owen Schwartz
57cd776c34 Fix migrations ordering 2025-01-30 23:30:33 -05:00
Milo Schwartz
5c507cc0ec Merge pull request #118 from fosrl/dev
Small Bugfixes
2025-01-30 22:47:56 -05:00
Milo Schwartz
55c0953fde update version in migration script log 2025-01-30 22:43:47 -05:00
Milo Schwartz
844b12d363 add copy code snippets to raw tcp/udp 2025-01-30 22:31:29 -05:00
Milo Schwartz
f40d91ff9e remove secure_cookies option from config 2025-01-30 21:53:42 -05:00
Owen Schwartz
f5e894e06a Make sure secure_cookies is true 2025-01-30 21:10:24 -05:00
Owen Schwartz
8fe479f809 Add . to make it clear there is already a dot 2025-01-30 21:02:12 -05:00
Owen Schwartz
9b9c343e2d Fix missing where clause; Resolves #117 2025-01-30 20:51:37 -05:00
Milo Schwartz
cb1ccbe945 update traefik_config example and remove quotes around smtp_port 2025-01-30 17:15:07 -05:00
Owen Schwartz
5de6028136 Put replaceme back 2025-01-30 12:27:07 -05:00
Owen Schwartz
e226a5e86b Move back to * imports 2025-01-30 12:25:59 -05:00
Owen Schwartz
f0ecfbb403 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-30 12:17:02 -05:00
Owen Schwartz
985418b9af Fix wrong config 2025-01-30 12:16:56 -05:00
Milo Schwartz
197c797264 fix cicd 2025-01-30 11:16:57 -05:00
Milo Schwartz
16b131970b Merge pull request #111 from fosrl/dev
major changes for 1.0.0-beta.9
2025-01-30 11:07:52 -05:00
Milo Schwartz
4541880d57 fix typo 2025-01-30 11:04:51 -05:00
Milo Schwartz
3e41e3d725 change order of cicd docker build step 2025-01-30 10:59:31 -05:00
Milo Schwartz
1bad0c538b add link to docs for tcp/udp 2025-01-30 10:55:57 -05:00
Milo Schwartz
61e6fb3126 update upload artifact version 2025-01-30 10:32:37 -05:00
Milo Schwartz
f80171ad53 update readme 2025-01-30 10:30:27 -05:00
Milo Schwartz
2b6552319c Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-30 00:08:47 -05:00
Milo Schwartz
5ce6cb01ff prep migration for release 2025-01-30 00:03:11 -05:00
Owen Schwartz
69621a430d Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-29 22:18:57 -05:00
Owen Schwartz
4f0b45dd9f Add badger version 2025-01-29 22:18:39 -05:00
Milo Schwartz
bdf72662bf do migration in one transaction with rollback 2025-01-29 19:55:08 -05:00
Milo Schwartz
34c8c0db70 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-29 11:14:28 -05:00
Milo Schwartz
44e7bf1199 fix typo 2025-01-29 11:14:10 -05:00
Owen Schwartz
f4ae2188e0 Fix typo courtesy of Discord @kazak 2025-01-29 09:34:55 -05:00
Milo Schwartz
20f659db89 fix zod schemas 2025-01-29 00:03:10 -05:00
Owen Schwartz
0e04e82b88 Squashed commit of the following:
commit c276d2193da5dbe7af5197bdf7e2bcce6f87b0cf
Author: Owen Schwartz <owen@txv.io>
Date:   Tue Jan 28 22:06:04 2025 -0500

    Okay actually now

commit 9afdc0aadc3f4fb4e811930bacff70a9e17eab9f
Author: Owen Schwartz <owen@txv.io>
Date:   Tue Jan 28 21:58:44 2025 -0500

    Migrations working finally

commit a7336b3b2466fe74d650b9c253ecadbe1eff749d
Merge: e7c7203 fdb1ab4
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 22:19:15 2025 -0500

    Merge branch 'dev' into tcp-udp-traffic

commit e7c7203330b1b08e570048b10ef314b55068e466
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 22:18:09 2025 -0500

    Working on migration

commit a4704dfd44b10647257c7c7054c0dae806d315bb
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 21:40:52 2025 -0500

    Add flag to allow raw resources

commit d74f7a57ed11e2a6bf1a7e0c28c29fb07eb573a0
Merge: 6817788 d791b9b
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 21:28:50 2025 -0500

    Merge branch 'tcp-udp-traffic' of https://github.com/fosrl/pangolin into tcp-udp-traffic

commit 68177882781b54ef30b62cca7dee8bbed7c5a2fa
Author: Owen Schwartz <owen@txv.io>
Date:   Mon Jan 27 21:28:32 2025 -0500

    Get everything working

commit d791b9b47f9f6ca050d6edfd1d674438f8562d99
Author: Milo Schwartz <mschwartz10612@gmail.com>
Date:   Mon Jan 27 17:46:19 2025 -0500

    fix orgId check in verifyAdmin

commit 6ac30afd7a449a126190d311bd98d7f1048f73a4
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 23:19:33 2025 -0500

    Trying to figure out traefik...

commit 9886b42272882f8bb6baff2efdbe26cee7cac2b6
Merge: 786e67e 85e9129
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 21:53:32 2025 -0500

    Merge branch 'tcp-udp-traffic' of https://github.com/fosrl/pangolin into tcp-udp-traffic

commit 786e67eadd6df1ee8df24e77aed20c1f1fc9ca67
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 21:51:37 2025 -0500

    Bug fixing

commit 85e9129ae313b2e4a460a8bc53a0af9f9fbbafb2
Author: Milo Schwartz <mschwartz10612@gmail.com>
Date:   Sun Jan 26 18:35:24 2025 -0500

    rethrow errors in migration and remove permanent redirect

commit bd82699505fc7510c27f72cd80ea0ce815d8c5ef
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:49:12 2025 -0500

    Fix merge issue

commit 933dbf3a02b1f19fd1f627410b2407fdf05cd9bf
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:46:13 2025 -0500

    Add sql to update resources and targets

commit f19437bad847c8dbf57fddd2c48cd17bab20ddb0
Merge: 58980eb 9f1f291
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:19:51 2025 -0500

    Merge branch 'dev' into tcp-udp-traffic

commit 58980ebb64d1040b4d224c76beb38c2254f3c5d9
Merge: 1de682a d284d36
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:10:09 2025 -0500

    Merge branch 'dev' into tcp-udp-traffic

commit 1de682a9f6039f40e05c8901c7381a94b0d018ed
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 17:08:29 2025 -0500

    Working on migrations

commit dc853d2bc02b11997be5c3c7ea789402716fb4c2
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 16:56:49 2025 -0500

    Finish config of resource pages

commit 37c681c08d7ab73d2cad41e7ef1dbe3a8852e1f2
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 16:07:25 2025 -0500

    Finish up table

commit 461c6650bbea0d7439cc042971ec13fdb52a7431
Author: Owen Schwartz <owen@txv.io>
Date:   Sun Jan 26 15:54:46 2025 -0500

    Working toward having dual resource types

commit f0894663627375e16ce6994370cb30b298efc2dc
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 22:31:25 2025 -0500

    Add qutoes

commit edc535b79b94c2e65b290cd90a69fe17d27245e9
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 22:28:45 2025 -0500

    Add readTimeout to allow long file uploads

commit 194892fa14b505bd7c2b31873dc13d4b8996c0e1
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 20:37:34 2025 -0500

    Rework traefik config generation

commit ad3f896b5333e4706d610c3198f29dcd67610365
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 13:01:47 2025 -0500

    Add proxy port to api

commit ca6013b2ffda0924a696ec3141825a54a4e5297d
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 12:58:01 2025 -0500

    Add migration

commit 2258d76cb3a49d3db7f05f76d8b8a9f1c248b5e4
Author: Owen Schwartz <owen@txv.io>
Date:   Sat Jan 25 12:55:02 2025 -0500

    Add new proxy port
2025-01-28 22:26:45 -05:00
Milo Schwartz
f874449d36 remove no reply check in send email 2025-01-28 22:13:46 -05:00
Milo Schwartz
397036640e add additional_middlewares 2025-01-28 21:39:17 -05:00
Milo Schwartz
60110350aa use smtp user if no no-reply set 2025-01-28 21:26:34 -05:00
Milo Schwartz
a57f0ab360 log password reset token if no smtp to allow reset password 2025-01-28 21:23:19 -05:00
Milo Schwartz
e0dd3c34b2 Merge pull request #107 from nkkfs/patch-1
Create pl.md
2025-01-28 11:58:10 -05:00
Kamil
472b0d7086 Create pl.md 2025-01-28 17:34:22 +01:00
Milo Schwartz
0bd8217d9e add failed auth logging 2025-01-27 22:43:32 -05:00
Milo Schwartz
fdb1ab4bd9 allow setting secure for smtp in config 2025-01-27 21:19:31 -05:00
Milo Schwartz
61b34c8b16 allow wildcard emails in email whitelist 2025-01-26 18:14:47 -05:00
Milo Schwartz
9f1f2910e4 refactor auth to work cross domain and with http resources closes #100 2025-01-26 14:42:02 -05:00
Milo Schwartz
6050a0a7d7 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-25 13:23:46 -05:00
Milo Schwartz
72f1686395 remove permanent redirect for https 2025-01-25 13:23:36 -05:00
Owen Schwartz
d284d36c24 Remove double transaction 2025-01-25 12:55:19 -05:00
Owen Schwartz
6cc6b0c239 docker-compose vs docker compose; Resolves #83 2025-01-25 12:27:27 -05:00
Milo Schwartz
8e5330fb82 add cicd 2025-01-24 23:18:27 -05:00
Milo Schwartz
2d0a367f1a fix link in resource alert not updating when changing ssl 2025-01-23 22:38:35 -05:00
Milo Schwartz
02b5f4d390 increase hitbox for links in buttons 2025-01-23 22:34:12 -05:00
Milo Schwartz
d1fead5050 use quotes around strings in yaml closes #96 2025-01-23 22:23:50 -05:00
Milo Schwartz
9a831e8e34 use id for data-value closes #86 2025-01-23 21:26:59 -05:00
Milo Schwartz
5f92b0bbc1 make all emails lowercase closes #89 2025-01-21 19:03:18 -05:00
Milo Schwartz
19232a81ef Create FUNDING.yml 2025-01-21 15:24:48 -05:00
Owen Schwartz
d1278c252b Merge branch 'dev' 2025-01-20 21:35:14 -05:00
Owen Schwartz
273d9675bf Bump version 2025-01-20 21:31:38 -05:00
Milo Schwartz
b4620cfea6 bump version 2025-01-20 21:30:34 -05:00
Owen Schwartz
2c8f824240 Pick always a new port for newt 2025-01-20 21:07:02 -05:00
Owen Schwartz
7c34f76695 Merge pull request #82 from fosrl/dev
Dev
2025-01-19 17:37:20 -05:00
Owen Schwartz
72d7ecb2ed Update clean 2025-01-19 17:36:48 -05:00
Owen Schwartz
75e70b5477 Merge branch 'main' of https://github.com/fosrl/pangolin 2025-01-19 17:33:54 -05:00
Owen Schwartz
4eca127781 Update gerbil version 2025-01-19 17:33:46 -05:00
Milo Schwartz
d27ecaae5e Merge pull request #77 from fosrl/hotfix-2
remove double createHttpError
2025-01-17 22:00:25 -05:00
Milo Schwartz
f0898613a2 remove double createHttpError 2025-01-17 21:59:06 -05:00
Owen Schwartz
40a2933e25 Merge pull request #76 from fosrl/bump-version
Bump version
2025-01-17 21:55:34 -05:00
Owen Schwartz
a208ab36b8 Bump version 2025-01-17 21:53:16 -05:00
Milo Schwartz
680c665242 Merge pull request #75 from mallendeo/patch-1
fix: add missing `await` when verifying pincode
2025-01-17 21:26:39 -05:00
Mauricio Allende
6b141c3ea0 fix: add missing await when verifying pincode
`validPincode` ends up as a `Promise` and evaluates as a thruthy value wether the pin is correct or not.
2025-01-17 22:54:20 -03:00
Julian
a039168217 add ability to transfer a resource to another site 2025-01-16 21:15:41 +01:00
Milo Schwartz
e4fe749251 Merge pull request #58 from fosrl/dev
various changes to to allow for unraid deployment
2025-01-15 23:52:49 -05:00
Milo Schwartz
ed5e6ec0f7 add port templates to traefik example files 2025-01-15 23:36:32 -05:00
Milo Schwartz
1aec431c36 optionally generate traefik files, set cors in config, and set trust proxy in config 2025-01-15 23:26:31 -05:00
Owen Schwartz
cb87463a69 Merge branch 'main' into dev 2025-01-15 21:38:15 -05:00
Owen Schwartz
4b5c74e8d6 Import start port at startup for now for exit node 2025-01-15 21:37:10 -05:00
Milo Schwartz
ab18e15a71 allow controlling cors from config and add cors middleware to traefik 2025-01-13 23:59:10 -05:00
Milo Schwartz
7ff5376d13 log url to docs if config error 2025-01-12 20:42:16 -05:00
Milo Schwartz
516c68224a Merge pull request #42 from fosrl/dev
fix missing exitNodeId on new newt sites
2025-01-12 20:39:08 -05:00
Owen Schwartz
7b93fbeba3 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 18:07:50 -05:00
Owen Schwartz
f958067139 Fix missing exitNodeId on new newt sites 2025-01-12 18:07:38 -05:00
Milo Schwartz
4e606836a1 Merge pull request #40 from fosrl/dev
add migration to update badger
2025-01-12 16:47:27 -05:00
Milo Schwartz
5da5ee3581 add migration to update badger 2025-01-12 16:46:27 -05:00
Milo Schwartz
302ac2e644 Merge pull request #39 from fosrl/dev
local sites and direct share links
2025-01-12 16:12:50 -05:00
Owen Schwartz
baab56b6d8 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 16:09:17 -05:00
Owen Schwartz
79c4f13440 Update to beta.5 2025-01-12 16:09:08 -05:00
Milo Schwartz
7b3db11b82 Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 15:59:36 -05:00
Milo Schwartz
3ffca75915 add targets for local sites 2025-01-12 15:59:28 -05:00
Owen Schwartz
f72dd3471e Merge branch 'no-gerbil' into dev 2025-01-12 15:58:29 -05:00
Owen Schwartz
3f55103542 Resolve ui quirks, add link 2025-01-12 15:58:07 -05:00
Milo Schwartz
b39fe87eea increase badger version in installer 2025-01-12 15:53:44 -05:00
Milo Schwartz
bfc81e52b0 bootstrap volume to create db closes #6 2025-01-12 15:41:35 -05:00
Milo Schwartz
54f5d159a5 bootstrap volume 2025-01-12 15:02:19 -05:00
Milo Schwartz
a2ed7c7117 complete integration of direct share link as discussed in #35 2025-01-12 13:43:16 -05:00
Owen Schwartz
161e87dbda Local sites working 2025-01-12 13:09:30 -05:00
Owen Schwartz
4c7581df4f Allow "local" sites witn no tunnel 2025-01-12 12:31:04 -05:00
Owen Schwartz
bfd1b21f9c Merge branch 'dev' of https://github.com/fosrl/pangolin into dev 2025-01-12 10:39:42 -05:00
Owen Schwartz
84ee25e441 Add version lock to dockerfile and hide password 2025-01-12 10:39:27 -05:00
Milo Schwartz
47683f2b8c add authors to readme 2025-01-11 22:37:50 -05:00
Milo Schwartz
81f1f48045 Merge branch 'main' into dev 2025-01-11 22:35:46 -05:00
Milo Schwartz
f5fda5d8ea allow access token in resource url 2025-01-11 19:47:07 -05:00
150 changed files with 7912 additions and 1925 deletions

3
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# These are supported funding model platforms
github: [fosrl]

78
.github/workflows/cicd.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: CI/CD Pipeline
on:
push:
tags:
- "*"
jobs:
release:
name: Build and Release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Log in to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
- name: Install Go
uses: actions/setup-go@v4
with:
go-version: 1.23.0
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
- name: Pull latest Gerbil version
id: get-gerbil-tag
run: |
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name')
echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV
- name: Pull latest Badger version
id: get-badger-tag
run: |
LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name')
echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV
- name: Update install/main.go
run: |
PANGOLIN_VERSION=${{ env.TAG }}
GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }}
BADGER_VERSION=${{ env.LATEST_BADGER_TAG }}
sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go
sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go
sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go
echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION"
cat install/main.go
- name: Build installer
working-directory: install
run: |
make release
- name: Upload artifacts from /install/bin
uses: actions/upload-artifact@v4
with:
name: install-bin
path: install/bin/
- name: Build and push Docker images
run: |
TAG=${{ env.TAG }}
make build-release tag=$TAG

2
.gitignore vendored
View File

@@ -31,3 +31,5 @@ dist
installer
*.tar
bin
.secrets
test_event.json

View File

@@ -27,6 +27,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init
COPY config/config.example.yml ./dist/config.example.yml
COPY config/traefik/traefik_config.example.yml ./dist/traefik_config.example.yml
COPY config/traefik/dynamic_config.example.yml ./dist/dynamic_config.example.yml
COPY server/db/names.json ./dist/names.json
COPY public ./public

View File

@@ -12,9 +12,6 @@ build-arm:
build-x86:
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build-x86-ecr:
docker buildx build --platform linux/amd64 -t 216989133116.dkr.ecr.us-east-1.amazonaws.com/pangolin:latest --push .
build:
docker build -t fosrl/pangolin:latest .

View File

@@ -1,4 +1,5 @@
# Pangolin
<div align="center">
<h2 align="center"><a href="https://fossorial.io"><img alt="pangolin" src="public/logo//word_mark.png" width="400" /></a></h2>
[![Documentation](https://img.shields.io/badge/docs-latest-blue.svg?style=flat-square)](https://docs.fossorial.io/)
[![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin)
@@ -6,14 +7,28 @@
[![Discord](https://img.shields.io/discord/1325658630518865980?logo=discord&style=flat-square)](https://discord.gg/HCJR8Xhme4)
[![Youtube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app)
Pangolin is a self-hosted tunneled reverse proxy management server with identity and access management, designed to securely expose private resources through use with the Traefik reverse proxy and WireGuard tunnel clients like Newt. With Pangolin, you retain full control over your infrastructure while providing a user-friendly and feature-rich solution for managing proxies, authentication, and access, and simplifying complex network setups, all with a clean and simple UI.
</div>
### Installation and Documentation
<div align="center">
<h5>
<a href="https://docs.fossorial.io/Getting%20Started/quick-install">
Install Guide
</a>
<span> | </span>
<a href="https://docs.fossorial.io">
Full Documentation
</a>
</h5>
</div>
- [Installation Instructions](https://docs.fossorial.io/Getting%20Started/quick-install)
- [Full Documentation](https://docs.fossorial.io)
<h3 align="center">Tunneled Mesh Reverse Proxy Server with Access Control</h3>
<div align="center">
## Preview
_Your own self-hosted zero trust tunnel._
</div>
Pangolin is a self-hosted tunneled reverse proxy server with identity and access control, designed to securely expose private resources on distributed networks. Acting as a central hub, it connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports.
<img src="public/screenshots/sites.png" alt="Preview"/>
@@ -23,15 +38,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Reverse Proxy Through WireGuard Tunnel
- Expose private resources on your network **without opening ports**.
- Expose private resources on your network **without opening ports** (firewall punching).
- Secure and easy to configure site-to-site connectivity via a custom **user space WireGuard client**, [Newt](https://github.com/fosrl/newt).
- Built-in support for any WireGuard client.
- Automated **SSL certificates** (https) via [LetsEncrypt](https://letsencrypt.org/).
- Support for HTTP/HTTPS and **raw TCP/UDP services**.
- Load balancing.
### Identity & Access Management
- Centralized authentication system using platform SSO. **Users will only have to manage one login.**
- Totp with backup codes for two-factor authentication.
- **Define access control rules for IPs, IP ranges, and URL paths per resource.**
- TOTP with backup codes for two-factor authentication.
- Create organizations, each with multiple sites, users, and roles.
- **Role-based access control** to manage resource access permissions.
- Additional authentication options include:
@@ -49,20 +67,18 @@ _Sites page of Pangolin dashboard (dark mode) showing multiple tunnels connected
### Easy Deployment
- Run on any cloud provider or on-premises.
- Docker Compose based setup for simplified deployment.
- Future-proof installation script for streamlined setup and feature additions.
- Run on any VPS.
- Use your preferred WireGuard client to connect, or use Newt, our custom user space client for the best experience.
### Modular Design
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin), which integrate seamlessly.
- Extend functionality with existing [Traefik](https://github.com/traefik/traefik) plugins, such as [Fail2Ban](https://plugins.traefik.io/plugins/628c9ebcffc0cd18356a979f/fail2-ban) or [CrowdSec](https://plugins.traefik.io/plugins/6335346ca4caa9ddeffda116/crowdsec-bouncer-traefik-plugin).
- Attach as many sites to the central server as you wish.
## Screenshots
Pangolin has a straightforward and simple dashboard UI:
<div align="center">
<table>
<tr>
@@ -88,22 +104,23 @@ Pangolin has a straightforward and simple dashboard UI:
</table>
</div>
## Workflow Example
### Deployment and Usage Example
## Deployment and Usage Example
1. **Deploy the Central Server**:
- Deploy the Docker Compose stack containing Pangolin, Gerbil, and Traefik onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
- Deploy the Docker Compose stack onto a VPS hosted on a cloud platform like Amazon EC2, DigitalOcean Droplet, or similar. There are many cheap VPS hosting options available to suit your needs.
2. **Domain Configuration**:
- Point your domain name to the VPS and configure Pangolin with your preferred settings.
3. **Connect Private Sites**:
- Install Newt or use another WireGuard client on private sites.
- Automatically establish a connection from these sites to the central server.
4. **Configure Users & Roles**
- Define organizations and invite users.
- Implement user- or role-based permissions to control resource access.
@@ -115,14 +132,19 @@ Pangolin has a straightforward and simple dashboard UI:
## Similar Projects and Inspirations
Pangolin was inspired by several existing projects and concepts:
- **Cloudflare Tunnels**:
**Cloudflare Tunnels**:
A similar approach to proxying private resources securely, but Pangolin is a self-hosted alternative, giving you full control over your infrastructure.
- **Authentik and Authelia**:
**Authentik and Authelia**:
These projects inspired Pangolins centralized authentication system for proxies, enabling robust user and role management.
## Project Development / Roadmap
> [!NOTE]
> Pangolin is under heavy development. The roadmap is subject to change as we fix bugs, add new features, and make improvements.
View the [project board](https://github.com/orgs/fosrl/projects/1) for more detailed info.
## Licensing
Pangolin is dual licensed under the AGPLv3 and the Fossorial Commercial license. For inquiries about commercial licensing, please contact us.

View File

@@ -1,26 +1,26 @@
app:
dashboard_url: http://localhost
base_domain: localhost
log_level: debug
dashboard_url: "http://localhost:3002"
base_domain: "localhost"
log_level: "info"
save_logs: false
server:
external_port: 3000
internal_port: 3001
next_port: 3002
internal_hostname: localhost
secure_cookies: false
session_cookie_name: p_session
resource_session_cookie_name: p_resource_session
internal_hostname: "pangolin"
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
traefik:
cert_resolver: letsencrypt
http_entrypoint: web
https_entrypoint: websecure
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"
gerbil:
start_port: 51820
base_endpoint: localhost
base_endpoint: "localhost"
block_size: 24
site_block_size: 30
subnet_group: 100.89.137.0/20
@@ -33,8 +33,12 @@ rate_limits:
users:
server_admin:
email: admin@example.com
password: Password123!
email: "admin@example.com"
password: "Password123!"
flags:
require_email_verification: false
disable_signup_without_invite: true
disable_user_create_org: true
allow_raw_resources: true
allow_base_domain_resources: true

View File

@@ -0,0 +1,53 @@
http:
middlewares:
redirect-to-https:
redirectScheme:
scheme: https
routers:
# HTTP to HTTPS redirect router
main-app-router-redirect:
rule: "Host(`{{.DashboardDomain}}`)"
service: next-service
entryPoints:
- web
middlewares:
- redirect-to-https
# Next.js router (handles everything except API and WebSocket paths)
next-router:
rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)"
service: next-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
# API router (handles /api/v1 paths)
api-router:
rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)"
service: api-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
# WebSocket router
ws-router:
rule: "Host(`{{.DashboardDomain}}`)"
service: api-service
entryPoints:
- websecure
tls:
certResolver: letsencrypt
services:
next-service:
loadBalancer:
servers:
- url: "http://pangolin:{{.NEXT_PORT}}" # Next.js server
api-service:
loadBalancer:
servers:
- url: "http://pangolin:{{.EXTERNAL_PORT}}" # API/WebSocket server

View File

@@ -0,0 +1,44 @@
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:{{.INTERNAL_PORT}}/api/v1/traefik-config"
pollInterval: "5s"
file:
filename: "/etc/traefik/dynamic_config.yml"
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.3"
log:
level: "INFO"
format: "common"
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "{{.LetsEncryptEmail}}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
entryPoints:
web:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"
serversTransport:
insecureSkipVerify: true

View File

@@ -37,7 +37,7 @@ services:
- 80:80 # Port for traefik because of the network_mode
traefik:
image: traefik:v3.1
image: traefik:v3.3.3
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service
@@ -49,3 +49,8 @@ services:
volumes:
- ./traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks:
default:
driver: bridge
name: pangolin

9
eslint.config.js Normal file
View File

@@ -0,0 +1,9 @@
// eslint.config.js
export default [
{
rules: {
semi: "error",
"prefer-const": "error"
}
}
];

View File

@@ -1,4 +1,3 @@
all: build
build:
@@ -9,6 +8,6 @@ release:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64
clean:
rm bin/installer
rm bin/installer_linux_amd64
rm bin/installer_linux_arm64
rm -f bin/installer
rm -f bin/installer_linux_amd64
rm -f bin/installer_linux_arm64

View File

@@ -1,27 +1,32 @@
app:
dashboard_url: https://{{.DashboardDomain}}
base_domain: {{.BaseDomain}}
log_level: info
dashboard_url: "https://{{.DashboardDomain}}"
base_domain: "{{.BaseDomain}}"
log_level: "info"
save_logs: false
server:
external_port: 3000
internal_port: 3001
next_port: 3002
internal_hostname: pangolin
secure_cookies: false
session_cookie_name: p_session
resource_session_cookie_name: p_resource_session
internal_hostname: "pangolin"
session_cookie_name: "p_session_token"
resource_access_token_param: "p_token"
resource_session_request_param: "p_session_request"
cors:
origins: ["https://{{.DashboardDomain}}"]
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
traefik:
cert_resolver: letsencrypt
http_entrypoint: web
https_entrypoint: websecure
cert_resolver: "letsencrypt"
http_entrypoint: "web"
https_entrypoint: "websecure"
prefer_wildcard_cert: false
gerbil:
start_port: 51820
base_endpoint: {{.DashboardDomain}}
base_endpoint: "{{.DashboardDomain}}"
use_subdomain: false
block_size: 24
site_block_size: 30
@@ -33,18 +38,20 @@ rate_limits:
max_requests: 100
{{if .EnableEmail}}
email:
smtp_host: {{.EmailSMTPHost}}
smtp_host: "{{.EmailSMTPHost}}"
smtp_port: {{.EmailSMTPPort}}
smtp_user: {{.EmailSMTPUser}}
smtp_pass: {{.EmailSMTPPass}}
no_reply: {{.EmailNoReply}}
smtp_user: "{{.EmailSMTPUser}}"
smtp_pass: "{{.EmailSMTPPass}}"
no_reply: "{{.EmailNoReply}}"
{{end}}
users:
server_admin:
email: {{.AdminUserEmail}}
password: {{.AdminUserPassword}}
email: "{{.AdminUserEmail}}"
password: "{{.AdminUserPassword}}"
flags:
require_email_verification: {{.EnableEmail}}
disable_signup_without_invite: {{.DisableSignupWithoutInvite}}
disable_user_create_org: {{.DisableUserCreateOrg}}
allow_raw_resources: true
allow_base_domain_resources: true

View File

@@ -1,6 +1,6 @@
services:
pangolin:
image: fosrl/pangolin:latest
image: fosrl/pangolin:{{.PangolinVersion}}
container_name: pangolin
restart: unless-stopped
volumes:
@@ -11,8 +11,9 @@ services:
timeout: "3s"
retries: 5
{{if .InstallGerbil}}
gerbil:
image: fosrl/gerbil:latest
image: fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil
restart: unless-stopped
depends_on:
@@ -32,12 +33,20 @@ services:
- 51820:51820/udp
- 443:443 # Port for traefik because of the network_mode
- 80:80 # Port for traefik because of the network_mode
{{end}}
traefik:
image: traefik:v3.1
image: traefik:v3.3.3
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}}
network_mode: service:gerbil # Ports appear on the gerbil service
{{end}}
{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
{{end}}
depends_on:
pangolin:
condition: service_healthy
@@ -46,3 +55,8 @@ services:
volumes:
- ./config/traefik:/etc/traefik:ro # Volume to store the Traefik configuration
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
networks:
default:
driver: bridge
name: pangolin

View File

@@ -3,7 +3,6 @@ http:
redirect-to-https:
redirectScheme:
scheme: https
permanent: true
routers:
# HTTP to HTTPS redirect router

View File

@@ -13,7 +13,7 @@ experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.1"
version: "{{.BadgerVersion}}"
log:
level: "INFO"
@@ -33,6 +33,9 @@ entryPoints:
address: ":80"
websecure:
address: ":443"
transport:
respondingTimeouts:
readTimeout: "30m"
http:
tls:
certResolver: "letsencrypt"

View File

@@ -1,3 +1,8 @@
module installer
go 1.23.0
go 1.23.0
require (
golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.28.0 // indirect
)

View File

@@ -0,0 +1,4 @@
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=

View File

@@ -10,27 +10,41 @@ import (
"path/filepath"
"runtime"
"strings"
"syscall"
"text/template"
"unicode"
"golang.org/x/term"
)
// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD
func loadVersions(config *Config) {
config.PangolinVersion = "replaceme"
config.GerbilVersion = "replaceme"
config.BadgerVersion = "replaceme"
}
//go:embed fs/*
var configFiles embed.FS
type Config struct {
BaseDomain string `yaml:"baseDomain"`
DashboardDomain string `yaml:"dashboardUrl"`
LetsEncryptEmail string `yaml:"letsEncryptEmail"`
AdminUserEmail string `yaml:"adminUserEmail"`
AdminUserPassword string `yaml:"adminUserPassword"`
DisableSignupWithoutInvite bool `yaml:"disableSignupWithoutInvite"`
DisableUserCreateOrg bool `yaml:"disableUserCreateOrg"`
EnableEmail bool `yaml:"enableEmail"`
EmailSMTPHost string `yaml:"emailSMTPHost"`
EmailSMTPPort int `yaml:"emailSMTPPort"`
EmailSMTPUser string `yaml:"emailSMTPUser"`
EmailSMTPPass string `yaml:"emailSMTPPass"`
EmailNoReply string `yaml:"emailNoReply"`
PangolinVersion string
GerbilVersion string
BadgerVersion string
BaseDomain string
DashboardDomain string
LetsEncryptEmail string
AdminUserEmail string
AdminUserPassword string
DisableSignupWithoutInvite bool
DisableUserCreateOrg bool
EnableEmail bool
EmailSMTPHost string
EmailSMTPPort int
EmailSMTPUser string
EmailSMTPPass string
EmailNoReply string
InstallGerbil bool
}
func main() {
@@ -45,13 +59,16 @@ func main() {
// check if there is already a config file
if _, err := os.Stat("config/config.yml"); err != nil {
config := collectUserInput(reader)
loadVersions(&config)
if err := createConfigFiles(config); err != nil {
fmt.Printf("Error creating config files: %v\n", err)
os.Exit(1)
}
if !isDockerInstalled() && runtime.GOOS == "linux" {
if shouldInstallDocker() {
if readBool(reader, "Docker is not installed. Would you like to install it?", true) {
installDocker()
}
}
@@ -82,6 +99,24 @@ func readString(reader *bufio.Reader, prompt string, defaultValue string) string
return input
}
func readPassword(prompt string) string {
fmt.Print(prompt + ": ")
// Read password without echo
password, err := term.ReadPassword(int(syscall.Stdin))
fmt.Println() // Add a newline since ReadPassword doesn't add one
if err != nil {
return ""
}
input := strings.TrimSpace(string(password))
if input == "" {
return readPassword(prompt)
}
return input
}
func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool {
defaultStr := "no"
if defaultValue {
@@ -109,21 +144,29 @@ func collectUserInput(reader *bufio.Reader) Config {
config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "")
config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", "pangolin."+config.BaseDomain)
config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "")
config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunned connections", true)
// Admin user configuration
fmt.Println("\n=== Admin User Configuration ===")
config.AdminUserEmail = readString(reader, "Enter admin user email", "admin@"+config.BaseDomain)
for {
config.AdminUserPassword = readString(reader, "Enter admin user password", "")
if valid, message := validatePassword(config.AdminUserPassword); valid {
break
pass1 := readPassword("Create admin user password")
pass2 := readPassword("Confirm admin user password")
if pass1 != pass2 {
fmt.Println("Passwords do not match")
} else {
fmt.Println("Invalid password:", message)
fmt.Println("Password requirements:")
fmt.Println("- At least one uppercase English letter")
fmt.Println("- At least one lowercase English letter")
fmt.Println("- At least one digit")
fmt.Println("- At least one special character")
config.AdminUserPassword = pass1
if valid, message := validatePassword(config.AdminUserPassword); valid {
break
} else {
fmt.Println("Invalid password:", message)
fmt.Println("Password requirements:")
fmt.Println("- At least one uppercase English letter")
fmt.Println("- At least one lowercase English letter")
fmt.Println("- At least one digit")
fmt.Println("- At least one special character")
}
}
}
@@ -231,6 +274,11 @@ func createConfigFiles(config Config) error {
// Get the relative path by removing the "fs/" prefix
relPath := strings.TrimPrefix(path, "fs/")
// skip .DS_Store
if strings.Contains(relPath, ".DS_Store") {
return nil
}
// Create the full output path under "config/"
outPath := filepath.Join("config", relPath)
@@ -302,13 +350,6 @@ func createConfigFiles(config Config) error {
return nil
}
func shouldInstallDocker() bool {
reader := bufio.NewReader(os.Stdin)
fmt.Print("Would you like to install Docker? (yes/no): ")
response, _ := reader.ReadString('\n')
return strings.ToLower(strings.TrimSpace(response)) == "yes"
}
func installDocker() error {
// Detect Linux distribution
cmd := exec.Command("cat", "/etc/os-release")
@@ -341,7 +382,7 @@ func installDocker() error {
switch {
case strings.Contains(osRelease, "ID=ubuntu"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update &&
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
@@ -350,7 +391,7 @@ func installDocker() error {
`, dockerArch))
case strings.Contains(osRelease, "ID=debian"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update &&
apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl software-properties-common &&
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
@@ -399,29 +440,53 @@ func isDockerInstalled() bool {
return true
}
func getCommandString(useNewStyle bool) string {
if useNewStyle {
return "'docker compose'"
}
return "'docker-compose'"
}
func pullAndStartContainers() error {
fmt.Println("Starting containers...")
// First try docker compose (new style)
cmd := exec.Command("docker", "compose", "-f", "docker-compose.yml", "pull")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err := cmd.Run()
if err != nil {
fmt.Println("Failed to start containers using docker compose, falling back to docker-compose command")
os.Exit(1)
// Check which docker compose command is available
var useNewStyle bool
checkCmd := exec.Command("docker", "compose", "version")
if err := checkCmd.Run(); err == nil {
useNewStyle = true
} else {
// Check if docker-compose (old style) is available
checkCmd = exec.Command("docker-compose", "version")
if err := checkCmd.Run(); err != nil {
return fmt.Errorf("neither 'docker compose' nor 'docker-compose' command is available: %v", err)
}
}
cmd = exec.Command("docker", "compose", "-f", "docker-compose.yml", "up", "-d")
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
err = cmd.Run()
if err != nil {
fmt.Println("Failed to start containers using docker-compose command")
os.Exit(1)
// Helper function to execute docker compose commands
executeCommand := func(args ...string) error {
var cmd *exec.Cmd
if useNewStyle {
cmd = exec.Command("docker", append([]string{"compose"}, args...)...)
} else {
cmd = exec.Command("docker-compose", args...)
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
return err
// Pull containers
fmt.Printf("Using %s command to pull containers...\n", getCommandString(useNewStyle))
if err := executeCommand("-f", "docker-compose.yml", "pull"); err != nil {
return fmt.Errorf("failed to pull containers: %v", err)
}
// Start containers
fmt.Printf("Using %s command to start containers...\n", getCommandString(useNewStyle))
if err := executeCommand("-f", "docker-compose.yml", "up", "-d"); err != nil {
return fmt.Errorf("failed to start containers: %v", err)
}
return nil
}

267
internationalization/de.md Normal file
View File

@@ -0,0 +1,267 @@
## Login site
| EN | DE | Notes |
| --------------------- | ---------------------------------- | ----------- |
| Welcome to Pangolin | Willkommen bei Pangolin | |
| Log in to get started | Melden Sie sich an, um zu beginnen | |
| Email | E-Mail | |
| Enter your email | Geben Sie Ihre E-Mail-Adresse ein | placeholder |
| Password | Passwort | |
| Enter your password | Geben Sie Ihr Passwort ein | placeholder |
| Forgot your password? | Passwort vergessen? | |
| Log in | Anmelden | |
# Ogranization site after successful login
| EN | DE | Notes |
| ----------------------------------------- | -------------------------------------------- | ----- |
| Welcome to Pangolin | Willkommen bei Pangolin | |
| You're a member of {number} organization. | Sie sind Mitglied von {number} Organisation. | |
## Shared Header, Navbar and Footer
##### Header
| EN | DE | Notes |
| ------------------- | ------------------- | ----- |
| Documentation | Dokumentation | |
| Support | Support | |
| Organization {name} | Organisation {name} | |
##### Organization selector
| EN | DE | Notes |
| ---------------- | ----------------- | ----- |
| Search… | Suchen… | |
| Create | Erstellen | |
| New Organization | Neue Organisation | |
| Organizations | Organisationen | |
##### Navbar
| EN | DE | Notes |
| --------------- | ----------------- | ----- |
| Sites | Websites | |
| Resources | Ressourcen | |
| User & Roles | Benutzer & Rollen | |
| Shareable Links | Teilbare Links | |
| General | Allgemein | |
##### Footer
| EN | DE | |
| ------------------------- | --------------------------- | ------------------- |
| Page {number} of {number} | Seite {number} von {number} | |
| Rows per page | Zeilen pro Seite | |
| Pangolin | Pangolin | unten auf der Seite |
| Built by Fossorial | Erstellt von Fossorial | unten auf der Seite |
| Open Source | Open Source | unten auf der Seite |
| Documentation | Dokumentation | unten auf der Seite |
| {version} | {version} | unten auf der Seite |
## Main “Sites”
##### “Hero” section
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| Newt (Recommended) | Newt (empfohlen) | |
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Für das beste Benutzererlebnis verwenden Sie Newt. Es nutzt WireGuard im Hintergrund und ermöglicht es Ihnen, auf Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk direkt aus dem Pangolin-Dashboard zuzugreifen. | |
| Runs in Docker | Läuft in Docker | |
| Runs in shell on macOS, Linux, and Windows | Läuft in der Shell auf macOS, Linux und Windows | |
| Install Newt | Newt installieren | |
| Basic WireGuard<br> | Verwenden Sie einen beliebigen WireGuard-Client, um eine Verbindung herzustellen. Sie müssen auf Ihre internen Ressourcen über die Peer-IP-Adresse zugreifen. | |
| Compatible with all WireGuard clients<br> | Kompatibel mit allen WireGuard-Clients<br> | |
| Manual configuration required | Manuelle Konfiguration erforderlich<br> | |
##### Content
| EN | DE | Notes |
| --------------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
| Manage Sites | Seiten verwalten | |
| Allow connectivity to your network through secure tunnels | Ermöglichen Sie die Verbindung zu Ihrem Netzwerk über ein sicheren Tunnel | |
| Search sites | Seiten suchen | placeholder |
| Add Site | Seite hinzufügen | |
| Name | Name | table header |
| Online | Status | table header |
| Site | Seite | table header |
| Data In | Eingehende Daten | table header |
| Data Out | Ausgehende Daten | table header |
| Connection Type | Verbindungstyp | table header |
| Online | Online | site state |
| Offline | Offline | site state |
| Edit → | Bearbeiten → | |
| View settings | Einstellungen anzeigen | Popup after clicking “…” on site |
| Delete | Löschen | Popup after clicking “…” on site |
##### Add Site Popup
| EN | DE | Notes |
| ------------------------------------------------------ | ----------------------------------------------------------- | ----------- |
| Create Site | Seite erstellen | |
| Create a new site to start connection for this site | Erstellen Sie eine neue Seite, um die Verbindung zu starten | |
| Name | Name | |
| Site name | Seiten-Name | placeholder |
| This is the name that will be displayed for this site. | So wird Ihre Seite angezeigt | desc |
| Method | Methode | |
| Local | Lokal | |
| Newt | Newt | |
| WireGuard | WireGuard | |
| This is how you will expose connections. | So werden Verbindungen freigegeben. | |
| You will only be able to see the configuration once. | Diese Konfiguration können Sie nur einmal sehen. | |
| Learn how to install Newt on your system | Erfahren Sie, wie Sie Newt auf Ihrem System installieren | |
| I have copied the config | Ich habe die Konfiguration kopiert | |
| Create Site | Website erstellen | |
| Close | Schließen | |
## Main “Resources”
##### “Hero” section
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| Resources | Ressourcen | |
| Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | Ressourcen sind Proxy-Server für Anwendungen, die in Ihrem privaten Netzwerk laufen. Erstellen Sie eine Ressource für jede HTTP- oder HTTPS-Anwendung in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private und sichere Verbindung über den verschlüsselten WireGuard-Tunnel zu ermöglichen. | |
| Secure connectivity with WireGuard encryption | Sichere Verbindung mit WireGuard-Verschlüsselung | |
| Configure multiple authentication methods | Konfigurieren Sie mehrere Authentifizierungsmethoden | |
| User and role-based access control | Benutzer- und rollenbasierte Zugriffskontrolle | |
##### Content
| EN | DE | Notes |
| -------------------------------------------------- | ---------------------------------------------------------- | -------------------- |
| Manage Resources | Ressourcen verwalten | |
| Create secure proxies to your private applications | Erstellen Sie sichere Proxys für Ihre privaten Anwendungen | |
| Search resources | Ressourcen durchsuchen | placeholder |
| Name | Name | |
| Site | Website | |
| Full URL | Vollständige URL | |
| Authentication | Authentifizierung | |
| Not Protected | Nicht geschützt | authentication state |
| Protected | Geschützt | authentication state |
| Edit → | Bearbeiten → | |
| Add Resource | Ressource hinzufügen | |
##### Add Resource Popup
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------- |
| Create Resource | Ressource erstellen | |
| Create a new resource to proxy request to your app | Erstellen Sie eine neue Ressource, um Anfragen an Ihre App zu proxen | |
| Name | Name | |
| My Resource | Neue Ressource | name placeholder |
| This is the name that will be displayed for this resource. | Dies ist der Name, der für diese Ressource angezeigt wird | |
| Subdomain | Subdomain | |
| Enter subdomain | Subdomain eingeben | |
| This is the fully qualified domain name that will be used to access the resource. | Dies ist der vollständige Domainname, der für den Zugriff auf die Ressource verwendet wird. | |
| Site | Website | |
| Search site… | Website suchen… | Site selector popup |
| This is the site that will be used in the dashboard. | Dies ist die Website, die im Dashboard verwendet wird. | |
| Create Resource | Ressource erstellen | |
| Close | Schließen | |
## Main “User & Roles”
##### Content
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------- |
| Manage User & Roles | Benutzer & Rollen verwalten | |
| Invite users and add them to roles to manage access to your organization | Laden Sie Benutzer ein und weisen Sie ihnen Rollen zu, um den Zugriff auf Ihre Organisation zu verwalten | |
| Users | Benutzer | sidebar item |
| Roles | Rollen | sidebar item |
| **User tab** | | |
| Search users | Benutzer suchen | placeholder |
| Invite User | Benutzer einladen | addbutton |
| Email | E-Mail | table header |
| Status | Status | table header |
| Role | Rolle | table header |
| Confirmed | Bestätigt | account status |
| Not confirmed (?) | Nicht bestätigt (?) | unknown for me account status |
| Owner | Besitzer | role |
| Admin | Administrator | role |
| Member | Mitglied | role |
| **Roles Tab** | | |
| Search roles | Rollen suchen | placeholder |
| Add Role | Rolle hinzufügen | addbutton |
| Name | Name | table header |
| Description | Beschreibung | table header |
| Admin | Administrator | role |
| Member | Mitglied | role |
| Admin role with the most permissions | Administratorrolle mit den meisten Berechtigungen | admin role desc |
| Members can only view resources | Mitglieder können nur Ressourcen anzeigen | member role desc |
##### Invite User popup
| EN | DE | Notes |
| ----------------- | ------------------------------------------------------- | ----------- |
| Invite User | Geben Sie neuen Benutzern Zugriff auf Ihre Organisation | |
| Email | E-Mail | |
| Enter an email | E-Mail eingeben | placeholder |
| Role | Rolle | |
| Select role | Rolle auswählen | placeholder |
| Gültig für | Gültig bis | |
| 1 day | Tag | |
| 2 days | 2 Tage | |
| 3 days | 3 Tage | |
| 4 days | 4 Tage | |
| 5 days | 5 Tage | |
| 6 days | 6 Tage | |
| 7 days | 7 Tage | |
| Create Invitation | Einladung erstellen | |
| Close | Schließen | |
## Main “Shareable Links”
##### “Hero” section
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----- |
| Shareable Links | Teilbare Links | |
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Erstellen Sie teilbare Links zu Ihren Ressourcen. Links bieten temporären oder unbegrenzten Zugriff auf Ihre Ressource. Sie können die Gültigkeitsdauer des Links beim Erstellen konfigurieren. | |
| Easy to create and share | Einfach zu erstellen und zu teilen | |
| Configurable expiration duration | Konfigurierbare Gültigkeitsdauer | |
| Secure and revocable | Sicher und widerrufbar | |
##### Content
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------- |
| Manage Shareable Links | Teilbare Links verwalten | |
| Create shareable links to grant temporary or permanent access to your resources | Erstellen Sie teilbare Links, um temporären oder permanenten Zugriff auf Ihre Ressourcen zu gewähren | |
| Search links | Links suchen | placeholder |
| Create Share Link | Neuen Link erstellen | addbutton |
| Resource | Ressource | table header |
| Title | Titel | table header |
| Created | Erstellt | table header |
| Expires | Gültig bis | table header |
| No links. Create one to get started. | Keine Links. Erstellen Sie einen, um zu beginnen. | table placeholder |
##### Create Shareable Link popup
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------- |
| Create Shareable Link | Teilbaren Link erstellen | |
| Anyone with this link can access the resource | Jeder mit diesem Link kann auf die Ressource zugreifen | |
| Resource | Ressource | |
| Select resource | Ressource auswählen | |
| Search resources… | Ressourcen suchen… | resource selector popup |
| Title (optional) | Titel (optional) | |
| Enter title | Titel eingeben | placeholder |
| Expire in | Gültig bis | |
| Minutes | Minuten | |
| Hours | Stunden | |
| Days | Tage | |
| Months | Monate | |
| Years | Jahre | |
| Never expire | Nie ablaufen | |
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Die Gültigkeitsdauer bestimmt, wie lange der Link nutzbar ist und Zugriff auf die Ressource bietet. Nach Ablauf dieser Zeit funktioniert der Link nicht mehr, und Benutzer, die diesen Link verwendet haben, verlieren den Zugriff auf die Ressource. | |
| Create Link | Link erstellen | |
| Close | Schließen | |
## Main “General”
| EN | DE | Notes |
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------ |
| General | Allgemein | |
| Configure your organizations general settings | Konfigurieren Sie die allgemeinen Einstellungen Ihrer Organisation | |
| General | Allgemein | sidebar item |
| Organization Settings | Organisationseinstellungen | |
| Manage your organization details and configuration | Verwalten Sie die Details und Konfiguration Ihrer Organisation | |
| Name | Name | |
| This is the display name of the org | Dies ist der Anzeigename Ihrer Organisation | |
| Save Settings | Einstellungen speichern | |
| Danger Zone | Gefahrenzone | |
| Once you delete this org, there is no going back. Please be certain. | Wenn Sie diese Organisation löschen, gibt es kein Zurück. Bitte seien Sie sicher. | |
| Delete Organization Data | Organisationsdaten löschen | |

287
internationalization/pl.md Normal file
View File

@@ -0,0 +1,287 @@
## Authentication Site
| EN | PL | Notes |
| -------------------------------------------------------- | ------------------------------------------------------------ | ---------- |
| Powered by [Pangolin](https://github.com/fosrl/pangolin) | Zasilane przez [Pangolin](https://github.com/fosrl/pangolin) | |
| Authentication Required | Wymagane uwierzytelnienie | |
| Choose your preferred method to access {resource} | Wybierz preferowaną metodę dostępu do {resource} | |
| PIN | PIN | |
| User | Zaloguj | |
| 6-digit PIN Code | 6-cyfrowy kod PIN | pin login |
| Login in with PIN | Zaloguj się PINem | pin login |
| Email | Email | user login |
| Enter your email | Wprowadź swój email | user login |
| Password | Hasło | user login |
| Enter your password | Wprowadź swoje hasło | user login |
| Forgot your password? | Zapomniałeś hasła? | user login |
| Log in | Zaloguj | user login |
## Login site
| EN | PL | Notes |
| --------------------- | ------------------------------ | ----------- |
| Welcome to Pangolin | Witaj w Pangolin | |
| Log in to get started | Zaloguj się, aby rozpocząć<br> | |
| Email | Email | |
| Enter your email | Wprowadź swój adres e-mail<br> | placeholder |
| Password | Hasło | |
| Enter your password | Wprowadź swoje hasło | placeholder |
| Forgot your password? | Nie pamiętasz hasła? | |
| Log in | Zaloguj | |
# Ogranization site after successful login
| EN | PL | Notes |
| ----------------------------------------- | ------------------------------------------ | ----- |
| Welcome to Pangolin | Witaj w Pangolin | |
| You're a member of {number} organization. | Jesteś użytkownikiem {number} organizacji. | |
## Shared Header, Navbar and Footer
##### Header
| EN | PL | Notes |
| ------------------- | ------------------ | ----- |
| Documentation | Dokumentacja | |
| Support | Wsparcie | |
| Organization {name} | Organizacja {name} | |
##### Organization selector
| EN | PL | Notes |
| ---------------- | ---------------- | ----- |
| Search… | Szukaj… | |
| Create | Utwórz | |
| New Organization | Nowa organizacja | |
| Organizations | Organizacje | |
##### Navbar
| EN | PL | Notes |
| --------------- | ---------------------- | ----- |
| Sites | Witryny | |
| Resources | Zasoby | |
| User & Roles | Użytkownicy i Role | |
| Shareable Links | Łącza do udostępniania | |
| General | Ogólne | |
##### Footer
| EN | PL | |
| ------------------------- | -------------------------- | -------------- |
| Page {number} of {number} | Strona {number} z {number} | |
| Rows per page | Wierszy na stronę | |
| Pangolin | Pangolin | bottom of site |
| Built by Fossorial | Stworzone przez Fossorial | bottom of site |
| Open Source | Open source | bottom of site |
| Documentation | Dokumentacja | bottom of site |
| {version} | {version} | bottom of site |
## Main “Sites”
##### “Hero” section
| EN | PL | Notes |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| Newt (Recommended) | Newt (zalecane) | |
| For the best user experience, use Newt. It uses WireGuard under the hood and allows you to address your private resources by their LAN address on your private network from within the Pangolin dashboard. | Aby zapewnić najlepsze doświadczenie użytkownika, korzystaj z Newt. Wykorzystuje on technologię WireGuard w tle i pozwala na dostęp do Twoich prywatnych zasobów za pomocą ich adresu LAN w prywatnej sieci bezpośrednio z poziomu pulpitu nawigacyjnego Pangolin. | |
| Runs in Docker | Działa w Dockerze | |
| Runs in shell on macOS, Linux, and Windows | Działa w powłoce na systemach macOS, Linux i Windows | |
| Install Newt | Zainstaluj Newt | |
| Podstawowy WireGuard<br> | Użyj dowolnego klienta WireGuard, aby się połączyć. Będziesz musiał uzyskiwać dostęp do swoich wewnętrznych zasobów za pomocą adresu IP równorzędnego | |
| Compatible with all WireGuard clients<br> | Kompatybilny ze wszystkimi klientami WireGuard<br> | |
| Manual configuration required | Wymagana ręczna konfiguracja<br> | |
##### Content
| EN | PL | Notes |
| --------------------------------------------------------- | ------------------------------------------------------------------------ | -------------------------------- |
| Manage Sites | Zarządzanie witrynami | |
| Allow connectivity to your network through secure tunnels | Zezwalaj na łączność z Twoją siecią za pośrednictwem bezpiecznych tuneli | |
| Search sites | Szukaj witryny | placeholder |
| Add Site | Dodaj witrynę | |
| Name | Nazwa | table header |
| Online | Status | table header |
| Site | Witryna | table header |
| Data In | Dane wchodzące | table header |
| Data Out | Dane wychodzące | table header |
| Connection Type | Typ połączenia | table header |
| Online | Online | site state |
| Offline | Poza siecią | site state |
| Edit → | Edytuj → | |
| View settings | Pokaż ustawienia | Popup after clicking “…” on site |
| Delete | Usuń | Popup after clicking “…” on site |
##### Add Site Popup
| EN | PL | Notes |
| ------------------------------------------------------ | --------------------------------------------------- | ----------- |
| Create Site | Utwórz witrynę | |
| Create a new site to start connection for this site | Utwórz nową witrynę aby rozpocząć połączenie | |
| Name | Nazwa | |
| Site name | Nazwa witryny | placeholder |
| This is the name that will be displayed for this site. | Tak będzie wyświetlana twoja witryna | desc |
| Method | Metoda | |
| Local | Lokalna | |
| Newt | Newt | |
| WireGuard | WireGuard | |
| This is how you will expose connections. | Tak będą eksponowane połączenie. | |
| You will only be able to see the configuration once. | Tą konfigurację możesz zobaczyć tylko raz. | |
| Learn how to install Newt on your system | Dowiedz się jak zainstalować Newt na twoim systemie | |
| I have copied the config | Skopiowałem konfigurację | |
| Create Site | Utwórz witrynę | |
| Close | Zamknij | |
## Main “Resources”
##### “Hero” section
| EN | PL | Notes |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----- |
| Resources | Zasoby | |
| Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | Zasoby to serwery proxy dla aplikacji działających w Twojej prywatnej sieci. Utwórz zasób dla dowolnej aplikacji HTTP lub HTTPS w swojej prywatnej sieci. Każdy zasób musi być połączony z witryną, aby umożliwić prywatne i bezpieczne połączenie przez szyfrowany tunel WireGuard. | |
| Secure connectivity with WireGuard encryption | Bezpieczna łączność z szyfrowaniem WireGuard | |
| Configure multiple authentication methods | Konfigurowanie wielu metod uwierzytelniania | |
| User and role-based access control | Kontrola dostępu oparta na użytkownikach i rolach | |
##### Content
| EN | PL | Notes |
| -------------------------------------------------- | -------------------------------------------------------------- | -------------------- |
| Manage Resources | Zarządzaj zasobami | |
| Create secure proxies to your private applications | Twórz bezpieczne serwery proxy dla swoich prywatnych aplikacji | |
| Search resources | Szukaj w zasobach | placeholder |
| Name | Nazwa | |
| Site | Witryna | |
| Full URL | Pełny URL | |
| Authentication | Uwierzytelnianie | |
| Not Protected | Niezabezpieczony | authentication state |
| Protected | Zabezpieczony | authentication state |
| Edit → | Edytuj → | |
| Add Resource | Dodaj zasób | |
##### Add Resource Popup
| EN | PL | Notes |
| --------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ------------------- |
| Create Resource | Utwórz zasób | |
| Create a new resource to proxy request to your app | Utwórz nowy zasób, aby przekazywać żądania do swojej aplikacji | |
| Name | Nazwa | |
| My Resource | Nowy zasób | name placeholder |
| This is the name that will be displayed for this resource. | To jest nazwa, która będzie wyświetlana dla tego zasobu | |
| Subdomain | Subdomena | |
| Enter subdomain | Wprowadź subdomenę | |
| This is the fully qualified domain name that will be used to access the resource. | To jest pełna nazwa domeny, która będzie używana do dostępu do zasobu. | |
| Site | Witryna | |
| Search site… | Szukaj witryny… | Site selector popup |
| This is the site that will be used in the dashboard. | To jest witryna, która będzie używana w pulpicie nawigacyjnym. | |
| Create Resource | Utwórz zasób | |
| Close | Zamknij | |
## Main “User & Roles”
##### Content
| EN | PL | Notes |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | ----------------------------- |
| Manage User & Roles | Zarządzanie użytkownikami i rolami | |
| Invite users and add them to roles to manage access to your organization | Zaproś użytkowników i przypisz im role, aby zarządzać dostępem do Twojej organizacji | |
| Users | Użytkownicy | sidebar item |
| Roles | Role | sidebar item |
| **User tab** | | |
| Search users | Wyszukaj użytkownika | placeholder |
| Invite User | Zaproś użytkownika | addbutton |
| Email | Email | table header |
| Status | Status | table header |
| Role | Rola | table header |
| Confirmed | Zatwierdzony | account status |
| Not confirmed (?) | Niezatwierdzony (?) | unknown for me account status |
| Owner | Właściciel | role |
| Admin | Administrator | role |
| Member | Użytkownik | role |
| **Roles Tab** | | |
| Search roles | Wyszukaj role | placeholder |
| Add Role | Dodaj role | addbutton |
| Name | Nazwa | table header |
| Description | Opis | table header |
| Admin | Administrator | role |
| Member | Użytkownik | role |
| Admin role with the most permissions | Rola administratora z najszerszymi uprawnieniami | admin role desc |
| Members can only view resources | Członkowie mogą jedynie przeglądać zasoby | member role desc |
##### Invite User popup
| EN | PL | Notes |
| ----------------- | ------------------------------------------ | ----------- |
| Invite User | Give new users access to your organization | |
| Email | Email | |
| Enter an email | Wprowadź email | placeholder |
| Role | Rola | |
| Select role | Wybierz role | placeholder |
| Vaild for | Ważne do | |
| 1 day | Dzień | |
| 2 days | 2 dni | |
| 3 days | 3 dni | |
| 4 days | 4 dni | |
| 5 days | 5 dni | |
| 6 days | 6 dni | |
| 7 days | 7 dni | |
| Create Invitation | Utwórz zaproszenie | |
| Close | Zamknij | |
## Main “Shareable Links”
##### “Hero” section
| EN | PL | Notes |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- |
| Shareable Links | Łącza do udostępniania | |
| Create shareable links to your resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one. | Twórz linki do udostępniania swoich zasobów. Linki zapewniają tymczasowy lub nieograniczony dostęp do zasobu. Możesz skonfigurować czas wygaśnięcia linku podczas jego tworzenia. | |
| Easy to create and share | Łatwe tworzenie i udostępnianie | |
| Configurable expiration duration | Konfigurowalny czas wygaśnięcia | |
| Secure and revocable | Bezpieczne i odwołalne | |
##### Content
| EN | PL | Notes |
| ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | ----------------- |
| Manage Shareable Links | Zarządzaj łączami do udostępniania | |
| Create shareable links to grant temporary or permament access to your resources | Utwórz łącze do udostępniania w celu przyznania tymczasowego lub stałego dostępu do zasobów | |
| Search links | Szukaj łączy | placeholder |
| Create Share Link | Utwórz nowe łącze | addbutton |
| Resource | Zasób | table header |
| Title | Tytuł | table header |
| Created | Utworzone | table header |
| Expires | Wygasa | table header |
| No links. Create one to get started. | Brak łączy. Utwórz, aby rozpocząć. | table placeholder |
##### Create Shareable Link popup
| EN | PL | Notes |
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- |
| Create Shareable Link | Utwórz łącze do udostępnienia | |
| Anyone with this link can access the resource | Każdy kto ma ten link może korzystać z zasobu | |
| Resource | Zasób | |
| Select resource | Wybierz zasób | |
| Search resources… | Szukaj zasobów… | resource selector popup |
| Title (optional) | Tytuł (opcjonalny) | |
| Enter title | Wprowadź tytuł | placeholder |
| Expire in | Wygasa za | |
| Minutes | Minut | |
| Hours | Godzin | |
| Days | Dni | |
| Months | Miesięcy | |
| Years | Lat | |
| Never expire | Nie wygasa | |
| Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource. | Czas wygaśnięcia to okres, przez który link będzie aktywny i zapewni dostęp do zasobu. Po upływie tego czasu link przestanie działać, a użytkownicy, którzy go użyli, stracą dostęp do zasobu. | |
| Create Link | Utwórz łącze | |
| Close | Zamknij | |
## Main “General”
| EN | PL | Notes |
| -------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------ |
| General | Ogólne | |
| Configure your organizations general settings | Zarządzaj ogólnymi ustawieniami twoich organizacji | |
| General | Ogólne | sidebar item |
| Organization Settings | Ustawienia organizacji | |
| Manage your organization details and configuration | Zarządzaj szczegółami i konfiguracją organizacji | |
| Name | Nazwa | |
| This is the display name of the org | To jest wyświetlana nazwa Twojej organizacji | |
| Save Settings | Zapisz ustawienia | |
| Danger Zone | Niebezpieczna strefa | |
| Once you delete this org, there is no going back. Please be certain. | Jeśli usuniesz swoją tą organizację, nie ma odwrotu. Bądź ostrożny! | |
| Delete Organization Data | Usuń dane organizacji | |

View File

@@ -1,6 +1,6 @@
{
"name": "@fosrl/pangolin",
"version": "1.0.0-beta.4",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI",
@@ -26,6 +26,7 @@
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.2",
"@radix-ui/react-checkbox": "1.1.3",
"@radix-ui/react-collapsible": "1.1.2",
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-icons": "1.3.2",
@@ -63,6 +64,7 @@
"moment": "2.30.1",
"next": "15.1.3",
"next-themes": "0.4.4",
"node-cache": "5.1.2",
"node-fetch": "3.3.2",
"nodemailer": "6.9.16",
"oslo": "1.2.1",

BIN
public/logo/word_mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@@ -20,23 +20,32 @@ const externalPort = config.getRawConfig().server.external_port;
export function createApiServer() {
const apiServer = express();
// Middleware setup
apiServer.set("trust proxy", 1);
if (dev) {
apiServer.use(
cors({
origin: `http://localhost:${config.getRawConfig().server.next_port}`,
credentials: true
})
);
} else {
const corsOptions = {
origin: config.getRawConfig().app.dashboard_url,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"]
};
if (config.getRawConfig().server.trust_proxy) {
apiServer.set("trust proxy", 1);
}
apiServer.use(cors(corsOptions));
const corsConfig = config.getRawConfig().server.cors;
const options = {
...(corsConfig?.origins
? { origin: corsConfig.origins }
: {
origin: (origin: any, callback: any) => {
callback(null, true);
}
}),
...(corsConfig?.methods && { methods: corsConfig.methods }),
...(corsConfig?.allowed_headers && {
allowedHeaders: corsConfig.allowed_headers
}),
credentials: !(corsConfig?.credentials === false)
};
logger.debug("Using CORS options", options);
apiServer.use(cors(options));
if (!dev) {
apiServer.use(helmet());
apiServer.use(csrfProtectionMiddleware);
}
@@ -47,7 +56,8 @@ export function createApiServer() {
if (!dev) {
apiServer.use(
rateLimitMiddleware({
windowMin: config.getRawConfig().rate_limits.global.window_minutes,
windowMin:
config.getRawConfig().rate_limits.global.window_minutes,
max: config.getRawConfig().rate_limits.global.max_requests,
type: "IP_AND_PATH"
})

View File

@@ -51,13 +51,17 @@ export enum ActionsEnum {
// removeUserAction = "removeUserAction",
// removeUserSite = "removeUserSite",
getOrgUser = "getOrgUser",
"setResourcePassword" = "setResourcePassword",
"setResourcePincode" = "setResourcePincode",
"setResourceWhitelist" = "setResourceWhitelist",
"getResourceWhitelist" = "getResourceWhitelist",
"generateAccessToken" = "generateAccessToken",
"deleteAcessToken" = "deleteAcessToken",
"listAccessTokens" = "listAccessTokens"
setResourcePassword = "setResourcePassword",
setResourcePincode = "setResourcePincode",
setResourceWhitelist = "setResourceWhitelist",
getResourceWhitelist = "getResourceWhitelist",
generateAccessToken = "generateAccessToken",
deleteAcessToken = "deleteAcessToken",
listAccessTokens = "listAccessTokens",
createResourceRule = "createResourceRule",
deleteResourceRule = "deleteResourceRule",
listResourceRules = "listResourceRules",
updateResourceRule = "updateResourceRule",
}
export async function checkUserActionPermission(

View File

@@ -0,0 +1,45 @@
import db from "@server/db";
import { and, eq } from "drizzle-orm";
import { roleResources, userResources } from "@server/db/schema";
export async function canUserAccessResource({
userId,
resourceId,
roleId
}: {
userId: string;
resourceId: number;
roleId: number;
}): Promise<boolean> {
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
eq(roleResources.roleId, roleId)
)
)
.limit(1);
if (roleResourceAccess.length > 0) {
return true;
}
const userResourceAccess = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
.limit(1);
if (userResourceAccess.length > 0) {
return true;
}
return false;
}

View File

@@ -1,118 +0,0 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
crypto.getRandomValues(bytes);
const token = encodeBase32LowerCaseNoPadding(bytes);
return token;
}
export async function createSession(
token: string,
userId: string,
): Promise<Session> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
};
await db.insert(sessions).values(session);
return session;
}
export async function validateSessionToken(
token: string,
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
);
const result = await db
.select({ user: users, session: sessions })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.userId))
.where(eq(sessions.sessionId, sessionId));
if (result.length < 1) {
return { session: null, user: null };
}
const { user, session } = result[0];
if (Date.now() >= session.expiresAt) {
await db
.delete(sessions)
.where(eq(sessions.sessionId, session.sessionId));
return { session: null, user: null };
}
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES,
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessions.sessionId, session.sessionId));
}
return { session, user };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
}
export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
export function serializeSessionCookie(token: string): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
export function createBlankSessionTokenCookie(): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
}
}
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
},
};
export function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length);
}
export function generateIdFromEntropySize(size: number): string {
const buffer = crypto.getRandomValues(new Uint8Array(size));
return encodeBase32LowerCaseNoPadding(buffer);
}
export type SessionValidationResult =
| { session: Session; user: User }
| { session: null; user: null };

View File

@@ -3,8 +3,8 @@ import z from "zod";
export const passwordSchema = z
.string()
.min(8, { message: "Password must be at least 8 characters long" })
.max(64, { message: "Password must be at most 64 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
.max(128, { message: "Password must be at most 128 characters long" })
.regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[~!`@#$%^&*()_\-+={}[\]|\\:;"'<>,.\/?]).*$/, {
message: `Your password must meet the following conditions:
at least one uppercase English letter,
at least one lowercase English letter,

View File

@@ -26,7 +26,7 @@ export async function sendResourceOtpEmail(
}),
{
to: email,
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
subject: `Your one-time code to access ${resourceName}`
}
);

View File

@@ -21,7 +21,7 @@ export async function sendEmailVerificationCode(
}),
{
to: email,
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
subject: "Verify your email address"
}
);

View File

@@ -1,19 +1,31 @@
import {
encodeBase32LowerCaseNoPadding,
encodeHexLowerCase,
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { Session, sessions, User, users } from "@server/db/schema";
import {
resourceSessions,
Session,
sessions,
User,
users
} from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
import logger from "@server/logger";
export const SESSION_COOKIE_NAME = config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
export const SESSION_COOKIE_NAME =
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES =
1000 *
60 *
60 *
config.getRawConfig().server.dashboard_session_length_hours;
export const COOKIE_DOMAIN =
"." + new URL(config.getRawConfig().app.dashboard_url).hostname;
export function generateSessionToken(): string {
const bytes = new Uint8Array(20);
@@ -24,25 +36,25 @@ export function generateSessionToken(): string {
export async function createSession(
token: string,
userId: string,
userId: string
): Promise<Session> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
sha256(new TextEncoder().encode(token))
);
const session: Session = {
sessionId: sessionId,
userId,
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime(),
expiresAt: new Date(Date.now() + SESSION_COOKIE_EXPIRES).getTime()
};
await db.insert(sessions).values(session);
return session;
}
export async function validateSessionToken(
token: string,
token: string
): Promise<SessionValidationResult> {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(token)),
sha256(new TextEncoder().encode(token))
);
const result = await db
.select({ user: users, session: sessions })
@@ -61,46 +73,83 @@ export async function validateSessionToken(
}
if (Date.now() >= session.expiresAt - SESSION_COOKIE_EXPIRES / 2) {
session.expiresAt = new Date(
Date.now() + SESSION_COOKIE_EXPIRES,
Date.now() + SESSION_COOKIE_EXPIRES
).getTime();
await db
.update(sessions)
.set({
expiresAt: session.expiresAt,
})
.where(eq(sessions.sessionId, session.sessionId));
await db.transaction(async (trx) => {
await trx
.update(sessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(sessions.sessionId, session.sessionId));
await trx
.update(resourceSessions)
.set({
expiresAt: session.expiresAt
})
.where(eq(resourceSessions.userSessionId, session.sessionId));
});
}
return { session, user };
}
export async function invalidateSession(sessionId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.sessionId, sessionId));
}
export async function invalidateAllSessions(userId: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
export function serializeSessionCookie(token: string): string {
if (SECURE_COOKIES) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
try {
await db.transaction(async (trx) => {
await trx
.delete(resourceSessions)
.where(eq(resourceSessions.userSessionId, sessionId));
await trx.delete(sessions).where(eq(sessions.sessionId, sessionId));
});
} catch (e) {
logger.error("Failed to invalidate session", e);
}
}
export function createBlankSessionTokenCookie(): string {
if (SECURE_COOKIES) {
export async function invalidateAllSessions(userId: string): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(eq(sessions.userId, userId));
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
await trx.delete(sessions).where(eq(sessions.userId, userId));
});
} catch (e) {
logger.error("Failed to all invalidate user sessions", e);
}
}
export function serializeSessionCookie(
token: string,
isSecure: boolean
): string {
if (isSecure) {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/;`;
}
}
export function createBlankSessionTokenCookie(isSecure: boolean): string {
if (isSecure) {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
} else {
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Max-Age=0; Path=/;`;
}
}
const random: RandomReader = {
read(bytes: Uint8Array): void {
crypto.getRandomValues(bytes);
},
}
};
export function generateId(length: number): string {

View File

@@ -6,19 +6,19 @@ import { eq, and } from "drizzle-orm";
import config from "@server/lib/config";
export const SESSION_COOKIE_NAME =
config.getRawConfig().server.resource_session_cookie_name;
export const SESSION_COOKIE_EXPIRES = 1000 * 60 * 60 * 24 * 30;
export const SECURE_COOKIES = config.getRawConfig().server.secure_cookies;
export const COOKIE_DOMAIN = "." + config.getBaseDomain();
config.getRawConfig().server.session_cookie_name;
export const SESSION_COOKIE_EXPIRES =
1000 * 60 * 60 * config.getRawConfig().server.resource_session_length_hours;
export async function createResourceSession(opts: {
token: string;
resourceId: number;
passwordId?: number;
pincodeId?: number;
whitelistId?: number;
accessTokenId?: string;
usedOtp?: boolean;
isRequestToken?: boolean;
passwordId?: number | null;
pincodeId?: number | null;
userSessionId?: string | null;
whitelistId?: number | null;
accessTokenId?: string | null;
doNotExtend?: boolean;
expiresAt?: number | null;
sessionLength?: number | null;
@@ -27,7 +27,8 @@ export async function createResourceSession(opts: {
!opts.passwordId &&
!opts.pincodeId &&
!opts.whitelistId &&
!opts.accessTokenId
!opts.accessTokenId &&
!opts.userSessionId
) {
throw new Error("Auth method must be provided");
}
@@ -47,7 +48,9 @@ export async function createResourceSession(opts: {
pincodeId: opts.pincodeId || null,
whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null
accessTokenId: opts.accessTokenId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null
};
await db.insert(resourceSessions).values(session);
@@ -162,22 +165,26 @@ export async function invalidateAllSessions(
export function serializeResourceSessionCookie(
cookieName: string,
token: string
domain: string,
token: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES) {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
if (!isHttp) {
return `${cookieName}_s=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES}; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}=${token}; HttpOnly; SameSite=Strict; Max-Age=${SESSION_COOKIE_EXPIRES / 1000}; Path=/; Domain=${"." + domain}`;
}
}
export function createBlankResourceSessionTokenCookie(
cookieName: string
cookieName: string,
domain: string,
isHttp: boolean = false
): string {
if (SECURE_COOKIES) {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${COOKIE_DOMAIN}`;
if (!isHttp) {
return `${cookieName}_s=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Secure; Domain=${"." + domain}`;
} else {
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${COOKIE_DOMAIN}`;
return `${cookieName}=; HttpOnly; SameSite=Strict; Max-Age=0; Path=/; Domain=${"." + domain}`;
}
}

View File

@@ -0,0 +1,67 @@
import db from "@server/db";
import {
Resource,
ResourceAccessToken,
resourceAccessToken,
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "./password";
export async function verifyResourceAccessToken({
resource,
accessTokenId,
accessToken
}: {
resource: Resource;
accessTokenId: string;
accessToken: string;
}): Promise<{
valid: boolean;
error?: string;
tokenItem?: ResourceAccessToken;
}> {
const [result] = await db
.select()
.from(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.resourceId, resource.resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.limit(1);
const tokenItem = result;
if (!tokenItem) {
return {
valid: false,
error: "Access token does not exist for resource"
};
}
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
if (!validCode) {
return {
valid: false,
error: "Invalid access token"
};
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
return {
valid: false,
error: "Access token has expired"
};
}
return {
valid: true,
tokenItem
};
}

View File

@@ -4,10 +4,13 @@ import * as schema from "@server/db/schema";
import path from "path";
import fs from "fs/promises";
import { APP_PATH } from "@server/lib/consts";
import { existsSync, mkdirSync } from "fs";
export const location = path.join(APP_PATH, "db", "db.sqlite");
export const exists = await checkFileExists(location);
bootstrapVolume();
const sqlite = new Database(location);
export const db = drizzle(sqlite, { schema });
@@ -21,3 +24,29 @@ async function checkFileExists(filePath: string): Promise<boolean> {
return false;
}
}
function bootstrapVolume() {
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const logsDir = path.join(appPath, "logs");
// check if the db directory exists and create it if it doesn't
if (!existsSync(dbDir)) {
mkdirSync(dbDir, { recursive: true });
}
// check if the logs directory exists and create it if it doesn't
if (!existsSync(logsDir)) {
mkdirSync(logsDir, { recursive: true });
}
// THIS IS FOR TRAEFIK; NOT REALLY NEEDED, BUT JUST IN CASE
const traefikDir = path.join(appPath, "traefik");
// check if the traefik directory exists and create it if it doesn't
if (!existsSync(traefikDir)) {
mkdirSync(traefikDir, { recursive: true });
}
}

View File

@@ -41,16 +41,21 @@ export const resources = sqliteTable("resources", {
})
.notNull(),
name: text("name").notNull(),
subdomain: text("subdomain").notNull(),
fullDomain: text("fullDomain").notNull().unique(),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false)
.default(false),
isBaseDomain: integer("isBaseDomain", { mode: "boolean" }),
applyRules: integer("applyRules", { mode: "boolean" }).notNull().default(false)
});
export const targets = sqliteTable("targets", {
@@ -61,10 +66,9 @@ export const targets = sqliteTable("targets", {
})
.notNull(),
ip: text("ip").notNull(),
method: text("method").notNull(),
method: text("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
protocol: text("protocol"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true)
});
@@ -313,6 +317,10 @@ export const resourceSessions = sqliteTable("resourceSessions", {
doNotExtend: integer("doNotExtend", { mode: "boolean" })
.notNull()
.default(false),
isRequestToken: integer("isRequestToken", { mode: "boolean" }),
userSessionId: text("userSessionId").references(() => sessions.sessionId, {
onDelete: "cascade"
}),
passwordId: integer("passwordId").references(
() => resourcePassword.passwordId,
{
@@ -364,6 +372,18 @@ export const versionMigrations = sqliteTable("versionMigrations", {
executedAt: integer("executedAt").notNull()
});
export const resourceRules = sqliteTable("resourceRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP
match: text("match").notNull(), // CIDR, PATH, IP
value: text("value").notNull()
});
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
export type Site = InferSelectModel<typeof sites>;
@@ -396,3 +416,4 @@ export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;

View File

@@ -6,26 +6,21 @@ import logger from "@server/logger";
function createEmailClient() {
const emailConfig = config.getRawConfig().email;
if (
!emailConfig?.smtp_host ||
!emailConfig?.smtp_pass ||
!emailConfig?.smtp_port ||
!emailConfig?.smtp_user
) {
logger.warn(
"Email SMTP configuration is missing. Emails will not be sent.",
);
return;
}
if (!emailConfig) {
logger.warn(
"Email SMTP configuration is missing. Emails will not be sent."
);
return;
}
return nodemailer.createTransport({
host: emailConfig.smtp_host,
port: emailConfig.smtp_port,
secure: false,
secure: emailConfig.smtp_secure || false,
auth: {
user: emailConfig.smtp_user,
pass: emailConfig.smtp_pass,
},
pass: emailConfig.smtp_pass
}
});
}

View File

@@ -44,7 +44,7 @@ export const ResourceOTPCode = ({
<EmailLetterHead />
<EmailHeading>
Your One-Time Password for {resourceName}
Your One-Time Code for {resourceName}
</EmailHeading>
<EmailGreeting>Hi {email || "there"},</EmailGreeting>

View File

@@ -2,7 +2,7 @@ import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
import { createNextServer } from "./nextServer";
import { createInternalServer } from "./internalServer";
import { User, UserOrg } from "./db/schema";
import { Session, User, UserOrg } from "./db/schema";
async function startServers() {
await runSetupFunctions();
@@ -24,6 +24,7 @@ declare global {
namespace Express {
interface Request {
user?: User;
session?: Session;
userOrg?: UserOrg;
userOrgRoleId?: number;
userOrgId?: string;

View File

@@ -3,9 +3,15 @@ import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { __DIRNAME, APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import {
__DIRNAME,
APP_PATH,
APP_VERSION,
configFilePath1,
configFilePath2
} from "@server/lib/consts";
import { passwordSchema } from "@server/auth/passwordSchema";
import stoi from "./stoi";
const portSchema = z.number().positive().gt(0).lte(65535);
const hostnameSchema = z
@@ -15,34 +21,89 @@ const hostnameSchema = z
)
.or(z.literal("localhost"));
const environmentSchema = z.object({
const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => {
return process.env[envVar] ?? valFromYaml;
};
const configSchema = z.object({
app: z.object({
dashboard_url: z
.string()
.url()
.optional()
.transform(getEnvOrYaml("APP_DASHBOARDURL"))
.pipe(z.string().url())
.transform((url) => url.toLowerCase()),
base_domain: hostnameSchema
.optional()
.transform(getEnvOrYaml("APP_BASEDOMAIN"))
.pipe(hostnameSchema)
.transform((url) => url.toLowerCase()),
base_domain: hostnameSchema,
log_level: z.enum(["debug", "info", "warn", "error"]),
save_logs: z.boolean()
save_logs: z.boolean(),
log_failed_attempts: z.boolean().optional()
}),
server: z.object({
external_port: portSchema,
internal_port: portSchema,
next_port: portSchema,
external_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_EXTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
internal_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_INTERNALPORT"))
.transform(stoi)
.pipe(portSchema),
next_port: portSchema
.optional()
.transform(getEnvOrYaml("SERVER_NEXTPORT"))
.transform(stoi)
.pipe(portSchema),
internal_hostname: z.string().transform((url) => url.toLowerCase()),
secure_cookies: z.boolean(),
session_cookie_name: z.string(),
resource_session_cookie_name: z.string()
resource_access_token_param: z.string(),
resource_session_request_param: z.string(),
dashboard_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
resource_session_length_hours: z
.number()
.positive()
.gt(0)
.optional()
.default(720),
cors: z
.object({
origins: z.array(z.string()).optional(),
methods: z.array(z.string()).optional(),
allowed_headers: z.array(z.string()).optional(),
credentials: z.boolean().optional()
})
.optional(),
trust_proxy: z.boolean().optional().default(true)
}),
traefik: z.object({
http_entrypoint: z.string(),
https_entrypoint: z.string().optional(),
cert_resolver: z.string().optional(),
prefer_wildcard_cert: z.boolean().optional()
prefer_wildcard_cert: z.boolean().optional(),
additional_middlewares: z.array(z.string()).optional()
}),
gerbil: z.object({
start_port: portSchema,
base_endpoint: z.string().transform((url) => url.toLowerCase()),
start_port: portSchema
.optional()
.transform(getEnvOrYaml("GERBIL_STARTPORT"))
.transform(stoi)
.pipe(portSchema),
base_endpoint: z
.string()
.optional()
.transform(getEnvOrYaml("GERBIL_BASEENDPOINT"))
.pipe(z.string())
.transform((url) => url.toLowerCase()),
use_subdomain: z.boolean(),
subnet_group: z.string(),
block_size: z.number().positive().gt(0),
@@ -62,35 +123,53 @@ const environmentSchema = z.object({
}),
email: z
.object({
smtp_host: z.string(),
smtp_port: portSchema,
smtp_user: z.string(),
smtp_pass: z.string(),
no_reply: z.string().email()
smtp_host: z.string().optional(),
smtp_port: portSchema.optional(),
smtp_user: z.string().optional(),
smtp_pass: z.string().optional(),
smtp_secure: z.boolean().optional(),
no_reply: z.string().email().optional()
})
.optional(),
users: z.object({
server_admin: z.object({
email: z.string().email(),
email: z
.string()
.email()
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_EMAIL"))
.pipe(z.string().email())
.transform((v) => v.toLowerCase()),
password: passwordSchema
.optional()
.transform(getEnvOrYaml("USERS_SERVERADMIN_PASSWORD"))
.pipe(passwordSchema)
})
}),
flags: z
.object({
require_email_verification: z.boolean().optional(),
disable_signup_without_invite: z.boolean().optional(),
disable_user_create_org: z.boolean().optional()
disable_user_create_org: z.boolean().optional(),
allow_raw_resources: z.boolean().optional(),
allow_base_domain_resources: z.boolean().optional()
})
.optional()
});
export class Config {
private rawConfig!: z.infer<typeof environmentSchema>;
private rawConfig!: z.infer<typeof configSchema>;
constructor() {
this.loadConfig();
if (process.env.GENERATE_TRAEFIK_CONFIG === "true") {
this.createTraefikConfig();
}
}
public loadEnvironment() {}
public loadConfig() {
const loadConfig = (configPath: string) => {
try {
@@ -131,6 +210,9 @@ export class Config {
);
environment = loadConfig(configFilePath1);
} catch (error) {
console.log(
"See the docs for information about what to include in the configuration file: https://docs.fossorial.io/Pangolin/Configuration/config"
);
if (error instanceof Error) {
throw new Error(
`Error creating configuration file from example: ${
@@ -151,18 +233,14 @@ export class Config {
throw new Error("No configuration file found");
}
const parsedConfig = environmentSchema.safeParse(environment);
const parsedConfig = configSchema.safeParse(environment);
if (!parsedConfig.success) {
const errors = fromError(parsedConfig.error);
throw new Error(`Invalid configuration file: ${errors}`);
}
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("Could not load the application version");
}
process.env.APP_VERSION = appVersion;
process.env.APP_VERSION = APP_VERSION;
process.env.NEXT_PORT = parsedConfig.data.server.next_port.toString();
process.env.SERVER_EXTERNAL_PORT =
@@ -173,10 +251,12 @@ export class Config {
?.require_email_verification
? "true"
: "false";
process.env.FLAGS_ALLOW_RAW_RESOURCES = parsedConfig.data.flags
?.allow_raw_resources
? "true"
: "false";
process.env.SESSION_COOKIE_NAME =
parsedConfig.data.server.session_cookie_name;
process.env.RESOURCE_SESSION_COOKIE_NAME =
parsedConfig.data.server.resource_session_cookie_name;
process.env.EMAIL_ENABLED = parsedConfig.data.email ? "true" : "false";
process.env.DISABLE_SIGNUP_WITHOUT_INVITE = parsedConfig.data.flags
?.disable_signup_without_invite
@@ -186,6 +266,15 @@ export class Config {
?.disable_user_create_org
? "true"
: "false";
process.env.RESOURCE_ACCESS_TOKEN_PARAM =
parsedConfig.data.server.resource_access_token_param;
process.env.RESOURCE_SESSION_REQUEST_PARAM =
parsedConfig.data.server.resource_session_request_param;
process.env.FLAGS_ALLOW_BASE_DOMAIN_RESOURCES = parsedConfig.data.flags
?.allow_base_domain_resources
? "true"
: "false";
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
this.rawConfig = parsedConfig.data;
}
@@ -197,6 +286,78 @@ export class Config {
public getBaseDomain(): string {
return this.rawConfig.app.base_domain;
}
public getNoReplyEmail(): string | undefined {
return (
this.rawConfig.email?.no_reply || this.rawConfig.email?.smtp_user
);
}
private createTraefikConfig() {
try {
// check if traefik_config.yml and dynamic_config.yml exists in APP_PATH/traefik
const defaultTraefikConfigPath = path.join(
__DIRNAME,
"traefik_config.example.yml"
);
const defaultDynamicConfigPath = path.join(
__DIRNAME,
"dynamic_config.example.yml"
);
const traefikPath = path.join(APP_PATH, "traefik");
if (!fs.existsSync(traefikPath)) {
return;
}
// load default configs
let traefikConfig = fs.readFileSync(
defaultTraefikConfigPath,
"utf8"
);
let dynamicConfig = fs.readFileSync(
defaultDynamicConfigPath,
"utf8"
);
traefikConfig = traefikConfig
.split("{{.LetsEncryptEmail}}")
.join(this.rawConfig.users.server_admin.email);
traefikConfig = traefikConfig
.split("{{.INTERNAL_PORT}}")
.join(this.rawConfig.server.internal_port.toString());
dynamicConfig = dynamicConfig
.split("{{.DashboardDomain}}")
.join(new URL(this.rawConfig.app.dashboard_url).hostname);
dynamicConfig = dynamicConfig
.split("{{.NEXT_PORT}}")
.join(this.rawConfig.server.next_port.toString());
dynamicConfig = dynamicConfig
.split("{{.EXTERNAL_PORT}}")
.join(this.rawConfig.server.external_port.toString());
// write thiese to the traefik directory
const traefikConfigPath = path.join(
traefikPath,
"traefik_config.yml"
);
const dynamicConfigPath = path.join(
traefikPath,
"dynamic_config.yml"
);
fs.writeFileSync(traefikConfigPath, traefikConfig, "utf8");
fs.writeFileSync(dynamicConfigPath, dynamicConfig, "utf8");
console.log("Traefik configuration files created");
} catch (e) {
console.log(
"Failed to generate the Traefik configuration files. Please create them manually."
);
console.error(e);
}
}
}
export const config = new Config();

View File

@@ -1,6 +1,8 @@
import path from "path";
import { fileURLToPath } from "url";
import { existsSync } from "fs";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.0.0-beta.14";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

183
server/lib/ip.test.ts Normal file
View File

@@ -0,0 +1,183 @@
import { cidrToRange, findNextAvailableCidr } from "./ip";
/**
* Compares two objects for deep equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if objects are not equal
*/
export function assertEqualsObj<T>(actual: T, expected: T, message: string): void {
const actualStr = JSON.stringify(actual);
const expectedStr = JSON.stringify(expected);
if (actualStr !== expectedStr) {
throw new Error(`${message}\nExpected: ${expectedStr}\nActual: ${actualStr}`);
}
}
/**
* Compares two primitive values for equality
* @param actual The actual value to test
* @param expected The expected value to compare against
* @param message The message to display if assertion fails
* @throws Error if values are not equal
*/
export function assertEquals<T>(actual: T, expected: T, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${expected}\nActual: ${actual}`);
}
}
/**
* Tests if a function throws an expected error
* @param fn The function to test
* @param expectedError The expected error message or part of it
* @param message The message to display if assertion fails
* @throws Error if function doesn't throw or throws unexpected error
*/
export function assertThrows(
fn: () => void,
expectedError: string,
message: string
): void {
try {
fn();
throw new Error(`${message}: Expected to throw "${expectedError}"`);
} catch (error) {
if (!(error instanceof Error)) {
throw new Error(`${message}\nUnexpected error type: ${typeof error}`);
}
if (!error.message.includes(expectedError)) {
throw new Error(
`${message}\nExpected error: ${expectedError}\nActual error: ${error.message}`
);
}
}
}
// Test cases
function testFindNextAvailableCidr() {
console.log("Running findNextAvailableCidr tests...");
// Test 1: Basic IPv4 allocation
{
const existing = ["10.0.0.0/16", "10.1.0.0/16"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
assertEquals(result, "10.2.0.0/16", "Basic IPv4 allocation failed");
}
// Test 2: Finding gap between allocations
{
const existing = ["10.0.0.0/16", "10.2.0.0/16"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/8");
assertEquals(result, "10.1.0.0/16", "Finding gap between allocations failed");
}
// Test 3: No available space
{
const existing = ["10.0.0.0/8"];
const result = findNextAvailableCidr(existing, 8, "10.0.0.0/8");
assertEquals(result, null, "No available space test failed");
}
// // Test 4: IPv6 allocation
// {
// const existing = ["2001:db8::/32", "2001:db8:1::/32"];
// const result = findNextAvailableCidr(existing, 32, "2001:db8::/16");
// assertEquals(result, "2001:db8:2::/32", "Basic IPv6 allocation failed");
// }
// // Test 5: Mixed IP versions
// {
// const existing = ["10.0.0.0/16", "2001:db8::/32"];
// assertThrows(
// () => findNextAvailableCidr(existing, 16),
// "All CIDRs must be of the same IP version",
// "Mixed IP versions test failed"
// );
// }
// Test 6: Empty input
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 16);
assertEquals(result, null, "Empty input test failed");
}
// Test 7: Block size alignment
{
const existing = ["10.0.0.0/24"];
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
assertEquals(result, "10.0.1.0/24", "Block size alignment test failed");
}
// Test 8: Block size alignment
{
const existing: string[] = [];
const result = findNextAvailableCidr(existing, 24, "10.0.0.0/16");
assertEquals(result, "10.0.0.0/24", "Block size alignment test failed");
}
// Test 9: Large block size request
{
const existing = ["10.0.0.0/24", "10.0.1.0/24"];
const result = findNextAvailableCidr(existing, 16, "10.0.0.0/16");
assertEquals(result, null, "Large block size request test failed");
}
console.log("All findNextAvailableCidr tests passed!");
}
// function testCidrToRange() {
// console.log("Running cidrToRange tests...");
// // Test 1: Basic IPv4 conversion
// {
// const result = cidrToRange("192.168.0.0/24");
// assertEqualsObj(result, {
// start: BigInt("3232235520"),
// end: BigInt("3232235775")
// }, "Basic IPv4 conversion failed");
// }
// // Test 2: IPv6 conversion
// {
// const result = cidrToRange("2001:db8::/32");
// assertEqualsObj(result, {
// start: BigInt("42540766411282592856903984951653826560"),
// end: BigInt("42540766411282592875350729025363378175")
// }, "IPv6 conversion failed");
// }
// // Test 3: Invalid prefix length
// {
// assertThrows(
// () => cidrToRange("192.168.0.0/33"),
// "Invalid prefix length for IPv4",
// "Invalid IPv4 prefix test failed"
// );
// }
// // Test 4: Invalid IPv6 prefix
// {
// assertThrows(
// () => cidrToRange("2001:db8::/129"),
// "Invalid prefix length for IPv6",
// "Invalid IPv6 prefix test failed"
// );
// }
// console.log("All cidrToRange tests passed!");
// }
// Run all tests
try {
// testCidrToRange();
testFindNextAvailableCidr();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}

View File

@@ -3,58 +3,162 @@ interface IPRange {
end: bigint;
}
type IPVersion = 4 | 6;
/**
* Converts IP address string to BigInt for numerical operations
* Detects IP version from address string
*/
function detectIpVersion(ip: string): IPVersion {
return ip.includes(':') ? 6 : 4;
}
/**
* Converts IPv4 or IPv6 address string to BigInt for numerical operations
*/
function ipToBigInt(ip: string): bigint {
return ip.split('.')
.reduce((acc, octet) => BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(parseInt(octet))), BigInt(0));
const version = detectIpVersion(ip);
if (version === 4) {
return ip.split('.')
.reduce((acc, octet) => {
const num = parseInt(octet);
if (isNaN(num) || num < 0 || num > 255) {
throw new Error(`Invalid IPv4 octet: ${octet}`);
}
return BigInt.asUintN(64, (acc << BigInt(8)) + BigInt(num));
}, BigInt(0));
} else {
// Handle IPv6
// Expand :: notation
let fullAddress = ip;
if (ip.includes('::')) {
const parts = ip.split('::');
if (parts.length > 2) throw new Error('Invalid IPv6 address: multiple :: found');
const missing = 8 - (parts[0].split(':').length + parts[1].split(':').length);
const padding = Array(missing).fill('0').join(':');
fullAddress = `${parts[0]}:${padding}:${parts[1]}`;
}
return fullAddress.split(':')
.reduce((acc, hextet) => {
const num = parseInt(hextet || '0', 16);
if (isNaN(num) || num < 0 || num > 65535) {
throw new Error(`Invalid IPv6 hextet: ${hextet}`);
}
return BigInt.asUintN(128, (acc << BigInt(16)) + BigInt(num));
}, BigInt(0));
}
}
/**
* Converts BigInt to IP address string
*/
function bigIntToIp(num: bigint): string {
const octets: number[] = [];
for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
function bigIntToIp(num: bigint, version: IPVersion): string {
if (version === 4) {
const octets: number[] = [];
for (let i = 0; i < 4; i++) {
octets.unshift(Number(num & BigInt(255)));
num = num >> BigInt(8);
}
return octets.join('.');
} else {
const hextets: string[] = [];
for (let i = 0; i < 8; i++) {
hextets.unshift(Number(num & BigInt(65535)).toString(16).padStart(4, '0'));
num = num >> BigInt(16);
}
// Compress zero sequences
let maxZeroStart = -1;
let maxZeroLength = 0;
let currentZeroStart = -1;
let currentZeroLength = 0;
for (let i = 0; i < hextets.length; i++) {
if (hextets[i] === '0000') {
if (currentZeroStart === -1) currentZeroStart = i;
currentZeroLength++;
if (currentZeroLength > maxZeroLength) {
maxZeroLength = currentZeroLength;
maxZeroStart = currentZeroStart;
}
} else {
currentZeroStart = -1;
currentZeroLength = 0;
}
}
if (maxZeroLength > 1) {
hextets.splice(maxZeroStart, maxZeroLength, '');
if (maxZeroStart === 0) hextets.unshift('');
if (maxZeroStart + maxZeroLength === 8) hextets.push('');
}
return hextets.map(h => h === '0000' ? '0' : h.replace(/^0+/, '')).join(':');
}
return octets.join('.');
}
/**
* Converts CIDR to IP range
*/
function cidrToRange(cidr: string): IPRange {
export function cidrToRange(cidr: string): IPRange {
const [ip, prefix] = cidr.split('/');
const version = detectIpVersion(ip);
const prefixBits = parseInt(prefix);
const ipBigInt = ipToBigInt(ip);
const mask = BigInt.asUintN(64, (BigInt(1) << BigInt(32 - prefixBits)) - BigInt(1));
// Validate prefix length
const maxPrefix = version === 4 ? 32 : 128;
if (prefixBits < 0 || prefixBits > maxPrefix) {
throw new Error(`Invalid prefix length for IPv${version}: ${prefix}`);
}
const shiftBits = BigInt(maxPrefix - prefixBits);
const mask = BigInt.asUintN(version === 4 ? 64 : 128, (BigInt(1) << shiftBits) - BigInt(1));
const start = ipBigInt & ~mask;
const end = start | mask;
return { start, end };
}
/**
* Finds the next available CIDR block given existing allocations
* @param existingCidrs Array of existing CIDR blocks
* @param blockSize Desired prefix length for the new block (e.g., 24 for /24)
* @param startCidr Optional CIDR to start searching from (default: "0.0.0.0/0")
* @param blockSize Desired prefix length for the new block
* @param startCidr Optional CIDR to start searching from
* @returns Next available CIDR block or null if none found
*/
export function findNextAvailableCidr(
existingCidrs: string[],
blockSize: number,
startCidr: string = "0.0.0.0/0"
startCidr?: string
): string | null {
if (!startCidr && existingCidrs.length === 0) {
return null;
}
// If no existing CIDRs, use the IP version from startCidr
const version = startCidr
? detectIpVersion(startCidr.split('/')[0])
: 4; // Default to IPv4 if no startCidr provided
// Use appropriate default startCidr if none provided
startCidr = startCidr || (version === 4 ? "0.0.0.0/0" : "::/0");
// If there are existing CIDRs, ensure all are same version
if (existingCidrs.length > 0 &&
existingCidrs.some(cidr => detectIpVersion(cidr.split('/')[0]) !== version)) {
throw new Error('All CIDRs must be of the same IP version');
}
// Convert existing CIDRs to ranges and sort them
const existingRanges = existingCidrs
.map(cidr => cidrToRange(cidr))
.sort((a, b) => (a.start < b.start ? -1 : 1));
// Calculate block size
const blockSizeBigInt = BigInt(1) << BigInt(32 - blockSize);
const maxPrefix = version === 4 ? 32 : 128;
const blockSizeBigInt = BigInt(1) << BigInt(maxPrefix - blockSize);
// Start from the beginning of the given CIDR
let current = cidrToRange(startCidr).start;
@@ -63,7 +167,6 @@ export function findNextAvailableCidr(
// Iterate through existing ranges
for (let i = 0; i <= existingRanges.length; i++) {
const nextRange = existingRanges[i];
// Align current to block size
const alignedCurrent = current + ((blockSizeBigInt - (current % blockSizeBigInt)) % blockSizeBigInt);
@@ -74,7 +177,7 @@ export function findNextAvailableCidr(
// If we're at the end of existing ranges or found a gap
if (!nextRange || alignedCurrent + blockSizeBigInt - BigInt(1) < nextRange.start) {
return `${bigIntToIp(alignedCurrent)}/${blockSize}`;
return `${bigIntToIp(alignedCurrent, version)}/${blockSize}`;
}
// Move current pointer to after the current range
@@ -85,12 +188,19 @@ export function findNextAvailableCidr(
}
/**
* Checks if a given IP address is within a CIDR range
* @param ip IP address to check
* @param cidr CIDR range to check against
* @returns boolean indicating if IP is within the CIDR range
*/
* Checks if a given IP address is within a CIDR range
* @param ip IP address to check
* @param cidr CIDR range to check against
* @returns boolean indicating if IP is within the CIDR range
*/
export function isIpInCidr(ip: string, cidr: string): boolean {
const ipVersion = detectIpVersion(ip);
const cidrVersion = detectIpVersion(cidr.split('/')[0]);
if (ipVersion !== cidrVersion) {
throw new Error('IP address and CIDR must be of the same version');
}
const ipBigInt = ipToBigInt(ip);
const range = cidrToRange(cidr);
return ipBigInt >= range.start && ipBigInt <= range.end;

View File

@@ -1,16 +0,0 @@
import path from "path";
import { __DIRNAME } from "@server/lib/consts";
import fs from "fs";
export function loadAppVersion() {
const packageJsonPath = path.join("package.json");
let packageJson: any;
if (fs.existsSync && fs.existsSync(packageJsonPath)) {
const packageJsonContent = fs.readFileSync(packageJsonPath, "utf8");
packageJson = JSON.parse(packageJsonContent);
if (packageJson.version) {
return packageJson.version;
}
}
}

View File

@@ -8,3 +8,4 @@ export const subdomainSchema = z
)
.min(1, "Subdomain must be at least 1 character long")
.transform((val) => val.toLowerCase());

96
server/lib/validators.ts Normal file
View File

@@ -0,0 +1,96 @@
import z from "zod";
export function isValidCIDR(cidr: string): boolean {
return z.string().cidr().safeParse(cidr).success;
}
export function isValidIP(ip: string): boolean {
return z.string().ip().safeParse(ip).success;
}
export function isValidUrlGlobPattern(pattern: string): boolean {
// Remove leading slash if present
pattern = pattern.startsWith("/") ? pattern.slice(1) : pattern;
// Empty string is not valid
if (!pattern) {
return false;
}
// Split path into segments
const segments = pattern.split("/");
// Check each segment
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
// Empty segments are not allowed (double slashes), except at the end
if (!segment && i !== segments.length - 1) {
return false;
}
// If segment contains *, it must be exactly *
if (segment.includes("*") && segment !== "*") {
return false;
}
// Check each character in the segment
for (let j = 0; j < segment.length; j++) {
const char = segment[j];
// Check for percent-encoded sequences
if (char === "%" && j + 2 < segment.length) {
const hex1 = segment[j + 1];
const hex2 = segment[j + 2];
if (
!/^[0-9A-Fa-f]$/.test(hex1) ||
!/^[0-9A-Fa-f]$/.test(hex2)
) {
return false;
}
j += 2; // Skip the next two characters
continue;
}
// Allow:
// - unreserved (A-Z a-z 0-9 - . _ ~)
// - sub-delims (! $ & ' ( ) * + , ; =)
// - @ : for compatibility with some systems
if (!/^[A-Za-z0-9\-._~!$&'()*+,;=@:]$/.test(char)) {
return false;
}
}
}
return true;
}
export function isUrlValid(url: string | undefined) {
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
var pattern = new RegExp(
"^(https?:\\/\\/)?" + // protocol
"((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|" + // domain name
"((\\d{1,3}\\.){3}\\d{1,3}))" + // OR ip (v4) address
"(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*" + // port and path
"(\\?[;&a-z\\d%_.~+=-]*)?" + // query string
"(\\#[-a-z\\d_]*)?$",
"i"
);
return !!pattern.test(url);
}
export function isTargetValid(value: string | undefined) {
if (!value) return true;
const DOMAIN_REGEX =
/^[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?(?:\.[a-zA-Z0-9_](?:[a-zA-Z0-9-_]{0,61}[a-zA-Z0-9_])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
return DOMAIN_REGEX.test(value);
}

View File

@@ -4,7 +4,7 @@ import { resourceAccessToken, resources, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/lib/canUserAccessResource";
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
export async function verifyAccessTokenAccess(
req: Request,

View File

@@ -13,7 +13,7 @@ export async function verifyAdmin(
const userId = req.user?.userId;
const orgId = req.userOrgId;
if (!userId) {
if (!orgId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User does not have orgId")
);

View File

@@ -4,7 +4,7 @@ import { resources, targets, userOrgs } from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../lib/canUserAccessResource";
import { canUserAccessResource } from "../auth/canUserAccessResource";
export async function verifyTargetAccess(
req: Request,

View File

@@ -8,6 +8,7 @@ import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { verifySession } from "@server/auth/sessions/verifySession";
import { unauthorized } from "@server/auth/unauthorizedResponse";
import logger from "@server/logger";
export const verifySessionUserMiddleware = async (
req: any,
@@ -16,6 +17,9 @@ export const verifySessionUserMiddleware = async (
) => {
const { session, user } = await verifySession(req);
if (!session || !user) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(`User session not found. IP: ${req.ip}.`);
}
return next(unauthorized());
}
@@ -25,6 +29,9 @@ export const verifySessionUserMiddleware = async (
.where(eq(users.userId, user.userId));
if (!existingUser || !existingUser[0]) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(`User session not found. IP: ${req.ip}.`);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "User does not exist")
);

View File

@@ -79,6 +79,11 @@ export async function disable2fa(
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -20,7 +20,10 @@ import { verifySession } from "@server/auth/sessions/verifySession";
export const loginBodySchema = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
password: z.string(),
code: z.string().optional()
})
@@ -68,6 +71,11 @@ export async function login(
.from(users)
.where(eq(users.email, email));
if (!existingUserRes || !existingUserRes.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -83,6 +91,11 @@ export async function login(
existingUser.passwordHash
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Username or password incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -109,6 +122,11 @@ export async function login(
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -120,7 +138,8 @@ export async function login(
const token = generateSessionToken();
await createSession(token, existingUser.userId);
const cookie = serializeSessionCookie(token);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie);

View File

@@ -5,18 +5,23 @@ import response from "@server/lib/response";
import logger from "@server/logger";
import {
createBlankSessionTokenCookie,
invalidateSession,
SESSION_COOKIE_NAME
invalidateSession
} from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession";
import config from "@server/lib/config";
export async function logout(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
const sessionId = req.cookies[SESSION_COOKIE_NAME];
if (!sessionId) {
const { user, session } = await verifySession(req);
if (!user || !session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Log out failed because missing or invalid session. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -26,8 +31,14 @@ export async function logout(
}
try {
await invalidateSession(sessionId);
res.setHeader("Set-Cookie", createBlankSessionTokenCookie());
try {
await invalidateSession(session.sessionId);
} catch (error) {
logger.error("Failed to invalidate session", error)
}
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<null>(res, {
data: null,

View File

@@ -8,10 +8,8 @@ import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { encodeHex } from "oslo/encoding";
import { createDate } from "oslo";
import logger from "@server/logger";
import { generateIdFromEntropySize } from "@server/auth/sessions/app";
import { TimeSpan } from "oslo";
import config from "@server/lib/config";
import { sendEmail } from "@server/emails";
@@ -20,7 +18,10 @@ import { hashPassword } from "@server/auth/password";
export const requestPasswordResetBody = z
.object({
email: z.string().email()
email: z
.string()
.email()
.transform((v) => v.toLowerCase())
})
.strict();
@@ -63,10 +64,7 @@ export async function requestPasswordReset(
);
}
const token = generateRandomString(
8,
alphabet("0-9", "A-Z", "a-z")
);
const token = generateRandomString(8, alphabet("0-9", "A-Z", "a-z"));
await db.transaction(async (trx) => {
await trx
.delete(passwordResetTokens)
@@ -84,6 +82,12 @@ export async function requestPasswordReset(
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`;
if (!config.getRawConfig().email) {
logger.info(
`Password reset requested for ${email}. Token: ${token}.`
);
}
await sendEmail(
ResetPasswordCode({
email,
@@ -91,7 +95,7 @@ export async function requestPasswordReset(
link: url
}),
{
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
to: email,
subject: "Reset your password"
}

View File

@@ -19,7 +19,10 @@ import { passwordSchema } from "@server/auth/passwordSchema";
export const resetPasswordBody = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
token: z.string(), // reset secret code
newPassword: passwordSchema,
code: z.string().optional() // 2fa code
@@ -57,6 +60,11 @@ export async function resetPassword(
.where(eq(passwordResetTokens.email, email));
if (!resetRequest || !resetRequest.length) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -106,6 +114,11 @@ export async function resetPassword(
);
if (!validOTP) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -121,6 +134,11 @@ export async function resetPassword(
);
if (!isTokenValid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Password reset code is incorrect. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -131,8 +149,6 @@ export async function resetPassword(
const passwordHash = await hashPassword(newPassword);
await invalidateAllSessions(resetRequest[0].userId);
await db.transaction(async (trx) => {
await trx
.update(users)
@@ -144,11 +160,21 @@ export async function resetPassword(
.where(eq(passwordResetTokens.email, email));
});
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getRawConfig().email?.no_reply,
to: email,
subject: "Password Reset Confirmation"
});
try {
await invalidateAllSessions(resetRequest[0].userId);
} catch (e) {
logger.error("Failed to invalidate user sessions", e);
}
try {
await sendEmail(ConfirmPasswordReset({ email }), {
from: config.getNoReplyEmail(),
to: email,
subject: "Password Reset Confirmation"
});
} catch (e) {
logger.error("Failed to send password reset confirmation email", e);
}
return response<ResetPasswordResponse>(res, {
data: null,

View File

@@ -23,7 +23,10 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
import { passwordSchema } from "@server/auth/passwordSchema";
export const signupBodySchema = z.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
password: passwordSchema,
inviteToken: z.string().optional(),
inviteId: z.string().optional()
@@ -60,6 +63,11 @@ export async function signup(
if (config.getRawConfig().flags?.disable_signup_without_invite) {
if (!inviteToken || !inviteId) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Signup blocked without invite. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -84,6 +92,11 @@ export async function signup(
}
if (existingInvite.email !== email) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`User attempted to use an invite for another user. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -158,7 +171,8 @@ export async function signup(
const token = generateSessionToken();
await createSession(token, userId);
const cookie = serializeSessionCookie(token);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(token, isSecure);
res.appendHeader("Set-Cookie", cookie);
if (config.getRawConfig().flags?.require_email_verification) {
@@ -184,6 +198,11 @@ export async function signup(
});
} catch (e) {
if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Account already exists with that email. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -75,6 +75,11 @@ export async function verifyEmail(
.where(eq(users.userId, user.userId));
});
} else {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Email verification code incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -96,6 +96,11 @@ export async function verifyTotp(
}
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Two-factor authentication code is incorrect. Email: ${user.email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -0,0 +1,187 @@
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { resourceAccessToken, resources, sessions } from "@server/db/schema";
import db from "@server/db";
import { eq } from "drizzle-orm";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken, SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/app";
import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { response } from "@server/lib";
const exchangeSessionBodySchema = z.object({
requestToken: z.string(),
host: z.string(),
requestIp: z.string().optional()
});
export type ExchangeSessionBodySchema = z.infer<
typeof exchangeSessionBodySchema
>;
export type ExchangeSessionResponse = {
valid: boolean;
cookie?: string;
};
export async function exchangeSession(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Exchange session: Badger sent", req.body);
const parsedBody = exchangeSessionBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
try {
const { requestToken, host, requestIp } = parsedBody.data;
const clientIp = requestIp?.split(":")[0];
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with host ${host} not found`
)
);
}
const { resourceSession: requestSession } =
await validateResourceSessionToken(
requestToken,
resource.resourceId
);
if (!requestSession) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
if (!requestSession.isRequestToken) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Exchange token is invalid. Resource ID: ${resource.resourceId}. IP: ${clientIp}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid request token")
);
}
await db.delete(sessions).where(eq(sessions.sessionId, requestToken));
const token = generateSessionToken();
if (requestSession.userSessionId) {
const [res] = await db
.select()
.from(sessions)
.where(eq(sessions.sessionId, requestSession.userSessionId))
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
userSessionId: requestSession.userSessionId,
doNotExtend: false,
expiresAt: res.expiresAt,
sessionLength: SESSION_COOKIE_EXPIRES
});
}
} else if (requestSession.accessTokenId) {
const [res] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
requestSession.accessTokenId
)
)
.limit(1);
if (res) {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
accessTokenId: requestSession.accessTokenId,
doNotExtend: true,
expiresAt: res.expiresAt,
sessionLength: res.sessionLength
});
}
} else {
await createResourceSession({
token,
resourceId: resource.resourceId,
isRequestToken: false,
passwordId: requestSession.passwordId,
pincodeId: requestSession.pincodeId,
userSessionId: requestSession.userSessionId,
whitelistId: requestSession.whitelistId,
accessTokenId: requestSession.accessTokenId,
doNotExtend: false,
expiresAt: new Date(
Date.now() + SESSION_COOKIE_EXPIRES
).getTime(),
sessionLength: RESOURCE_SESSION_COOKIE_EXPIRES
});
}
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
);
logger.debug(JSON.stringify("Exchange cookie: " + cookie));
return response<ExchangeSessionResponse>(res, {
data: { valid: true, cookie },
success: true,
error: false,
message: "Session exchanged successfully",
status: HttpCode.OK
});
} catch (e) {
console.error(e);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to exchange session"
)
);
}
}

View File

@@ -1 +1,2 @@
export * from "./verifySession";
export * from "./exchangeSession";

View File

@@ -1,25 +1,43 @@
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { response } from "@server/lib/response";
import { validateSessionToken } from "@server/auth/sessions/app";
import { generateSessionToken } from "@server/auth/sessions/app";
import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import db from "@server/db";
import {
resourceAccessToken,
Resource,
ResourceAccessToken,
ResourcePassword,
resourcePassword,
ResourcePincode,
resourcePincode,
ResourceRule,
resourceRules,
resources,
resourceWhitelist,
User,
userOrgs
roleResources,
sessions,
userOrgs,
userResources,
users
} from "@server/db/schema";
import { and, eq } from "drizzle-orm";
import config from "@server/lib/config";
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
import { Resource, roleResources, userResources } from "@server/db/schema";
import { isIpInCidr } from "@server/lib/ip";
import { response } from "@server/lib/response";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import NodeCache from "node-cache";
import { z } from "zod";
import { fromError } from "zod-validation-error";
// We'll see if this speeds anything up
const cache = new NodeCache({
stdTTL: 5 // seconds
});
const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string()).optional(),
@@ -28,7 +46,9 @@ const verifyResourceSessionSchema = z.object({
host: z.string(),
path: z.string(),
method: z.string(),
tls: z.boolean()
accessToken: z.string().optional(),
tls: z.boolean(),
requestIp: z.string().optional()
});
export type VerifyResourceSessionSchema = z.infer<
@@ -45,7 +65,7 @@ export async function verifyResourceSession(
res: Response,
next: NextFunction
): Promise<any> {
logger.debug("Badger sent", req.body); // remove when done testing
logger.debug("Verify session: Badger sent", req.body); // remove when done testing
const parsedBody = verifyResourceSessionSchema.safeParse(req.body);
@@ -59,25 +79,56 @@ export async function verifyResourceSession(
}
try {
const { sessions, host, originalRequestURL } = parsedBody.data;
const {
sessions,
host,
originalRequestURL,
requestIp,
path,
accessToken: token
} = parsedBody.data;
const [result] = await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
const clientIp = requestIp?.split(":")[0];
const resource = result?.resources;
const pincode = result?.resourcePincode;
const password = result?.resourcePassword;
const resourceCacheKey = `resource:${host}`;
let resourceData:
| {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
}
| undefined = cache.get(resourceCacheKey);
if (!resourceData) {
const [result] = await db
.select()
.from(resources)
.leftJoin(
resourcePincode,
eq(resourcePincode.resourceId, resources.resourceId)
)
.leftJoin(
resourcePassword,
eq(resourcePassword.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, host))
.limit(1);
if (!result) {
logger.debug("Resource not found", host);
return notAllowed(res);
}
resourceData = {
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword
};
cache.set(resourceCacheKey, resourceData);
}
const { resource, pincode, password } = resourceData;
if (!resource) {
logger.debug("Resource not found", host);
@@ -101,42 +152,109 @@ export async function verifyResourceSession(
return allowed(res);
}
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(resource.resourceId)}?redirect=${encodeURIComponent(originalRequestURL)}`;
// check the rules
if (resource.applyRules) {
const action = await checkRules(
resource.resourceId,
clientIp,
path
);
if (!sessions) {
return notAllowed(res);
if (action == "ACCEPT") {
logger.debug("Resource allowed by rule");
return allowed(res);
} else if (action == "DROP") {
logger.debug("Resource denied by rule");
return notAllowed(res);
}
// otherwise its undefined and we pass
}
const sessionToken = sessions[config.getRawConfig().server.session_cookie_name];
const redirectUrl = `${config.getRawConfig().app.dashboard_url}/auth/resource/${encodeURIComponent(
resource.resourceId
)}?redirect=${encodeURIComponent(originalRequestURL)}`;
// check for unified login
if (sso && sessionToken) {
const { session, user } = await validateSessionToken(sessionToken);
if (session && user) {
const isAllowed = await isUserAllowedToAccessResource(
user,
resource
);
// check for access token
let validAccessToken: ResourceAccessToken | undefined;
if (token) {
const [accessTokenId, accessToken] = token.split(".");
const { valid, error, tokenItem } = await verifyResourceAccessToken(
{ resource, accessTokenId, accessToken }
);
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
if (error) {
logger.debug("Access token invalid: " + error);
}
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token is invalid. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
}
if (valid && tokenItem) {
validAccessToken = tokenItem;
if (!sessions) {
return await createAccessTokenSession(
res,
resource,
tokenItem
);
return allowed(res);
}
}
}
if (!sessions) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Missing resource sessions. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res);
}
const resourceSessionToken =
sessions[
`${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`
`${config.getRawConfig().server.session_cookie_name}${
resource.ssl ? "_s" : ""
}`
];
if (resourceSessionToken) {
const { resourceSession } = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: any = cache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
cache.set(sessionCacheKey, resourceSession);
}
if (resourceSession?.isRequestToken) {
logger.debug(
"Resource not allowed because session is a temporary request token"
);
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource session is an exchange token. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res);
}
if (resourceSession) {
if (pincode && resourceSession.pincodeId) {
@@ -169,10 +287,53 @@ export async function verifyResourceSession(
);
return allowed(res);
}
if (resourceSession.userSessionId && sso) {
const userAccessCacheKey = `userAccess:${
resourceSession.userSessionId
}:${resource.resourceId}`;
let isAllowed: boolean | undefined =
cache.get(userAccessCacheKey);
if (isAllowed === undefined) {
isAllowed = await isUserAllowedToAccessResource(
resourceSession.userSessionId,
resource
);
cache.set(userAccessCacheKey, isAllowed);
}
if (isAllowed) {
logger.debug(
"Resource allowed because user session is valid"
);
return allowed(res);
}
}
}
}
// At this point we have checked all sessions, but since the access token is
// valid, we should allow access and create a new session.
if (validAccessToken) {
return await createAccessTokenSession(
res,
resource,
validAccessToken
);
}
logger.debug("No more auth to check, resource not allowed");
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access not allowed. Resource ID: ${
resource.resourceId
}. IP: ${clientIp}.`
);
}
return notAllowed(res, redirectUrl);
} catch (e) {
console.error(e);
@@ -209,11 +370,59 @@ function allowed(res: Response) {
return response<VerifyUserResponse>(res, data);
}
async function createAccessTokenSession(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken
) {
const token = generateSessionToken();
await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = `${config.getRawConfig().server.session_cookie_name}`;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
return response<VerifyUserResponse>(res, {
data: { valid: true },
success: true,
error: false,
message: "Access allowed",
status: HttpCode.OK
});
}
async function isUserAllowedToAccessResource(
user: User,
userSessionId: string,
resource: Resource
): Promise<boolean> {
if (config.getRawConfig().flags?.require_email_verification && !user.emailVerified) {
const [res] = await db
.select()
.from(sessions)
.leftJoin(users, eq(users.userId, sessions.userId))
.where(eq(sessions.sessionId, userSessionId));
const user = res.user;
const session = res.session;
if (!user || !session) {
return false;
}
if (
config.getRawConfig().flags?.require_email_verification &&
!user.emailVerified
) {
return false;
}
@@ -264,3 +473,147 @@ async function isUserAllowedToAccessResource(
return false;
}
async function checkRules(
resourceId: number,
clientIp: string | undefined,
path: string | undefined
): Promise<"ACCEPT" | "DROP" | undefined> {
const ruleCacheKey = `rules:${resourceId}`;
let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey);
if (!rules) {
rules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
cache.set(ruleCacheKey, rules);
}
if (rules.length === 0) {
logger.debug("No rules found for resource", resourceId);
return;
}
// sort rules by priority in ascending order
rules = rules.sort((a, b) => a.priority - b.priority);
for (const rule of rules) {
if (!rule.enabled) {
continue;
}
if (
clientIp &&
rule.match == "CIDR" &&
isIpInCidr(clientIp, rule.value)
) {
return rule.action as any;
} else if (clientIp && rule.match == "IP" && clientIp == rule.value) {
return rule.action as any;
} else if (
path &&
rule.match == "PATH" &&
isPathAllowed(rule.value, path)
) {
return rule.action as any;
}
}
return;
}
function isPathAllowed(pattern: string, path: string): boolean {
logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`);
// Normalize and split paths into segments
const normalize = (p: string) => p.split("/").filter(Boolean);
const patternParts = normalize(pattern);
const pathParts = normalize(path);
logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`);
logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`);
// Recursive function to try different wildcard matches
function matchSegments(patternIndex: number, pathIndex: number): boolean {
const indent = " ".repeat(pathIndex); // Indent based on recursion depth
const currentPatternPart = patternParts[patternIndex];
const currentPathPart = pathParts[pathIndex];
logger.debug(
`${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})`
);
// If we've consumed all pattern parts, we should have consumed all path parts
if (patternIndex >= patternParts.length) {
const result = pathIndex >= pathParts.length;
logger.debug(
`${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}`
);
return result;
}
// If we've consumed all path parts but still have pattern parts
if (pathIndex >= pathParts.length) {
// The only way this can match is if all remaining pattern parts are wildcards
const remainingPattern = patternParts.slice(patternIndex);
const result = remainingPattern.every((p) => p === "*");
logger.debug(
`${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}`
);
return result;
}
// For wildcards, try consuming different numbers of path segments
if (currentPatternPart === "*") {
logger.debug(
`${indent}Found wildcard at pattern index ${patternIndex}`
);
// Try consuming 0 segments (skip the wildcard)
logger.debug(
`${indent}Trying to skip wildcard (consume 0 segments)`
);
if (matchSegments(patternIndex + 1, pathIndex)) {
logger.debug(
`${indent}Successfully matched by skipping wildcard`
);
return true;
}
// Try consuming current segment and recursively try rest
logger.debug(
`${indent}Trying to consume segment "${currentPathPart}" for wildcard`
);
if (matchSegments(patternIndex, pathIndex + 1)) {
logger.debug(
`${indent}Successfully matched by consuming segment for wildcard`
);
return true;
}
logger.debug(`${indent}Failed to match wildcard`);
return false;
}
// For regular segments, they must match exactly
if (currentPatternPart !== currentPathPart) {
logger.debug(
`${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"`
);
return false;
}
logger.debug(
`${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"`
);
// Move to next segments in both pattern and path
return matchSegments(patternIndex + 1, pathIndex + 1);
}
const result = matchSegments(0, 0);
logger.debug(`Final result: ${result}`);
return result;
}

View File

@@ -27,6 +27,8 @@ import { verifyUserHasAction } from "../middlewares/verifyUserHasAction";
import { ActionsEnum } from "@server/auth/actions";
import { verifyUserIsOrgOwner } from "../middlewares/verifyUserIsOrgOwner";
import { createNewt, getToken } from "./newt";
import rateLimit from "express-rate-limit";
import createHttpError from "http-errors";
// Root routes
export const unauthenticated = Router();
@@ -184,6 +186,32 @@ authenticated.get(
verifyUserHasAction(ActionsEnum.listTargets),
target.listTargets
);
authenticated.put(
"/resource/:resourceId/rule",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.createResourceRule),
resource.createResourceRule
);
authenticated.get(
"/resource/:resourceId/rules",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.listResourceRules),
resource.listResourceRules
);
authenticated.post(
"/resource/:resourceId/rule/:ruleId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResourceRule),
resource.updateResourceRule
);
authenticated.delete(
"/resource/:resourceId/rule/:ruleId",
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.deleteResourceRule),
resource.deleteResourceRule
);
authenticated.get(
"/target/:targetId",
verifyTargetAccess,
@@ -203,6 +231,7 @@ authenticated.delete(
target.deleteTarget
);
authenticated.put(
"/org/:orgId/role",
verifyOrgAccess,
@@ -308,6 +337,13 @@ authenticated.get(
resource.getResourceWhitelist
);
authenticated.post(
`/resource/:resourceId/transfer`,
verifyResourceAccess,
verifyUserHasAction(ActionsEnum.updateResource),
resource.transferResource
);
authenticated.post(
`/resource/:resourceId/access-token`,
verifyResourceAccess,
@@ -445,22 +481,61 @@ authRouter.post(
);
authRouter.post("/2fa/disable", verifySessionUserMiddleware, auth.disable2fa);
authRouter.post("/verify-email", verifySessionMiddleware, auth.verifyEmail);
authRouter.post(
"/verify-email/request",
verifySessionMiddleware,
rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
keyGenerator: (req) => `requestEmailVerificationCode:${req.body.email}`,
handler: (req, res, next) => {
const message = `You can only request an email verification code ${3} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
auth.requestEmailVerificationCode
);
// authRouter.post(
// "/change-password",
// verifySessionUserMiddleware,
// auth.changePassword
// );
authRouter.post("/reset-password/request", auth.requestPasswordReset);
authRouter.post(
"/reset-password/request",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 3,
keyGenerator: (req) => `requestPasswordReset:${req.body.email}`,
handler: (req, res, next) => {
const message = `You can only request a password reset ${3} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
auth.requestPasswordReset
);
authRouter.post("/reset-password/", auth.resetPassword);
authRouter.post("/resource/:resourceId/password", resource.authWithPassword);
authRouter.post("/resource/:resourceId/pincode", resource.authWithPincode);
authRouter.post("/resource/:resourceId/whitelist", resource.authWithWhitelist);
authRouter.post(
"/resource/:resourceId/whitelist",
rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
keyGenerator: (req) => `authWithWhitelist:${req.body.email}`,
handler: (req, res, next) => {
const message = `You can only request an email OTP ${10} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
}
}),
resource.authWithWhitelist
);
authRouter.post(
"/resource/:resourceId/access-token",
resource.authWithAccessToken

View File

@@ -11,6 +11,7 @@ import config from "@server/lib/config";
import { getUniqueExitNodeEndpointName } from '@server/db/names';
import { findNextAvailableCidr } from "@server/lib/ip";
import { fromError } from 'zod-validation-error';
import { getAllowedIps } from '../target/helpers';
// Define Zod schema for request validation
const getConfigSchema = z.object({
publicKey: z.string(),
@@ -50,7 +51,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
let exitNode;
if (exitNodeQuery.length === 0) {
const address = await getNextAvailableSubnet();
const listenPort = await getNextAvailablePort();
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port;
let subEndpoint = "";
if (config.getRawConfig().gerbil.use_subdomain) {
subEndpoint = await getUniqueExitNodeEndpointName();
@@ -81,22 +84,9 @@ export async function getConfig(req: Request, res: Response, next: NextFunction)
});
const peers = await Promise.all(sitesRes.map(async (site) => {
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId),
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId),
});
return targetsRes.map(target => `${target.ip}/32`);
}));
return {
publicKey: site.pubKey,
allowedIps: targetIps.flat(),
allowedIps: await getAllowedIps(site.siteId)
};
}));

View File

@@ -1,9 +1,11 @@
import { Router } from "express";
import * as gerbil from "@server/routers/gerbil";
import * as badger from "@server/routers/badger";
import * as traefik from "@server/routers/traefik";
import * as resource from "./resource";
import * as badger from "./badger";
import * as auth from "@server/routers/auth";
import HttpCode from "@server/types/HttpCode";
import { verifyResourceAccess, verifySessionUserMiddleware } from "@server/middlewares";
// Root routes
const internalRouter = Router();
@@ -13,9 +15,17 @@ internalRouter.get("/", (_, res) => {
});
internalRouter.get("/traefik-config", traefik.traefikConfigProvider);
internalRouter.get(
"/resource-session/:resourceId/:token",
auth.checkResourceSession,
auth.checkResourceSession
);
internalRouter.post(
`/resource/:resourceId/get-exchange-token`,
verifySessionUserMiddleware,
verifyResourceAccess,
resource.getExchangeToken
);
// Gerbil routes
@@ -30,5 +40,6 @@ const badgerRouter = Router();
internalRouter.use("/badger", badgerRouter);
badgerRouter.post("/verify-session", badger.verifyResourceSession);
badgerRouter.post("/exchange-session", badger.exchangeSession);
export default internalRouter;

View File

@@ -1,6 +1,4 @@
import {
generateSessionToken,
} from "@server/auth/sessions/app";
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import { newts } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
@@ -10,8 +8,13 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import { createNewtSession, validateNewtSessionToken } from "@server/auth/sessions/newt";
import {
createNewtSession,
validateNewtSessionToken
} from "@server/auth/sessions/newt";
import { verifyPassword } from "@server/auth/password";
import logger from "@server/logger";
import config from "@server/lib/config";
export const newtGetTokenBodySchema = z.object({
newtId: z.string(),
@@ -43,6 +46,11 @@ export async function getToken(
if (token) {
const { session, newt } = await validateNewtSessionToken(token);
if (session) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt session already valid. Newt ID: ${newtId}. IP: ${req.ip}.`
);
}
return response<null>(res, {
data: null,
success: true,
@@ -73,6 +81,11 @@ export async function getToken(
existingNewt.secretHash
);
if (!validSecret) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Newt id or secret is incorrect. Newt: ID ${newtId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect")
);

View File

@@ -1,7 +1,13 @@
import db from "@server/db";
import { MessageHandler } from "../ws";
import { exitNodes, resources, sites, targets } from "@server/db/schema";
import { eq, inArray } from "drizzle-orm";
import {
exitNodes,
resources,
sites,
Target,
targets
} from "@server/db/schema";
import { eq, and, sql } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger";
@@ -69,37 +75,68 @@ export const handleRegisterMessage: MessageHandler = async (context) => {
allowedIps: [site.subnet]
});
const siteResources = await db
.select()
const allResources = await db
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
'targetId', ${targets.targetId},
'ip', ${targets.ip},
'method', ${targets.method},
'port', ${targets.port},
'internalPort', ${targets.internalPort},
'enabled', ${targets.enabled}
))`.as("targets")
})
.from(resources)
.where(eq(resources.siteId, siteId));
// get the targets from the resourceIds
const siteTargets = await db
.select()
.from(targets)
.where(
inArray(
targets.resourceId,
siteResources.map((resource) => resource.resourceId)
.leftJoin(
targets,
and(
eq(targets.resourceId, resources.resourceId),
eq(targets.enabled, true)
)
);
)
.where(eq(resources.siteId, siteId))
.groupBy(resources.resourceId);
const udpTargets = siteTargets
.filter((target) => target.protocol === "udp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
let tcpTargets: string[] = [];
let udpTargets: string[] = [];
const tcpTargets = siteTargets
.filter((target) => target.protocol === "tcp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
for (const resource of allResources) {
const targets = JSON.parse(resource.targets);
if (!targets || targets.length === 0) {
continue;
}
if (resource.protocol === "tcp") {
tcpTargets = tcpTargets.concat(
targets.map(
(target: Target) =>
`${
target.internalPort ? target.internalPort + ":" : ""
}${target.ip}:${target.port}`
)
);
} else {
udpTargets = tcpTargets.concat(
targets.map(
(target: Target) =>
`${
target.internalPort ? target.internalPort + ":" : ""
}${target.ip}:${target.port}`
)
);
}
}
return {
message: {

View File

@@ -1,73 +1,44 @@
import { Target } from "@server/db/schema";
import { sendToClient } from "../ws";
export async function addTargets(newtId: string, targets: Target[]): Promise<void> {
export function addTargets(
newtId: string,
targets: Target[],
protocol: string
) {
//create a list of udp and tcp targets
const udpTargets = targets
.filter((target) => target.protocol === "udp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
const payloadTargets = targets.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
const tcpTargets = targets
.filter((target) => target.protocol === "tcp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
if (udpTargets.length > 0) {
const payload = {
type: `newt/udp/add`,
data: {
targets: udpTargets,
},
};
sendToClient(newtId, payload);
}
if (tcpTargets.length > 0) {
const payload = {
type: `newt/tcp/add`,
data: {
targets: tcpTargets,
},
};
sendToClient(newtId, payload);
}
const payload = {
type: `newt/${protocol}/add`,
data: {
targets: payloadTargets
}
};
sendToClient(newtId, payload);
}
export async function removeTargets(newtId: string, targets: Target[]): Promise<void> {
export function removeTargets(
newtId: string,
targets: Target[],
protocol: string
) {
//create a list of udp and tcp targets
const udpTargets = targets
.filter((target) => target.protocol === "udp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
const payloadTargets = targets.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${
target.ip
}:${target.port}`;
});
const tcpTargets = targets
.filter((target) => target.protocol === "tcp")
.map((target) => {
return `${target.internalPort ? target.internalPort + ":" : ""}${target.ip}:${target.port}`;
});
if (udpTargets.length > 0) {
const payload = {
type: `newt/udp/remove`,
data: {
targets: udpTargets,
},
};
sendToClient(newtId, payload);
}
if (tcpTargets.length > 0) {
const payload = {
type: `newt/tcp/remove`,
data: {
targets: tcpTargets,
},
};
sendToClient(newtId, payload);
}
const payload = {
type: `newt/${protocol}/remove`,
data: {
targets: payloadTargets
}
};
sendToClient(newtId, payload);
}

View File

@@ -1,22 +1,17 @@
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import { resourceAccessToken, resources } from "@server/db/schema";
import { resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, and } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import { verify } from "@node-rs/argon2";
import { isWithinExpirationDate } from "oslo";
import { verifyPassword } from "@server/auth/password";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import config from "@server/lib/config";
const authWithAccessTokenBodySchema = z
.object({
@@ -69,58 +64,43 @@ export async function authWithAccessToken(
const { accessToken, accessTokenId } = parsedBody.data;
try {
const [result] = await db
const [resource] = await db
.select()
.from(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.resourceId, resourceId),
eq(resourceAccessToken.accessTokenId, accessTokenId)
)
)
.leftJoin(
resources,
eq(resources.resourceId, resourceAccessToken.resourceId)
)
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
const resource = result?.resources;
const tokenItem = result?.resourceAccessToken;
if (!tokenItem) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Access token does not exist for resource"
)
)
);
}
if (!resource) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const validCode = await verifyPassword(accessToken, tokenItem.tokenHash);
const { valid, error, tokenItem } = await verifyResourceAccessToken({
resource,
accessTokenId,
accessToken
});
if (!validCode) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Invalid access token")
);
}
if (
tokenItem.expiresAt &&
!isWithinExpirationDate(new Date(tokenItem.expiresAt))
) {
if (!valid) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Access token has expired"
error || "Invalid access token"
)
);
}
if (!tokenItem || !resource) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Access token does not exist for resource"
)
);
}
@@ -130,13 +110,11 @@ export async function authWithAccessToken(
resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithAccessTokenResponse>(res, {
data: {

View File

@@ -9,13 +9,10 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import { verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
export const authWithPasswordBodySchema = z
.object({
@@ -84,7 +81,7 @@ export async function authWithPassword(
if (!org) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Resource does not exist")
createHttpError(HttpCode.BAD_REQUEST, "Org does not exist")
);
}
@@ -111,6 +108,11 @@ export async function authWithPassword(
definedPassword.passwordHash
);
if (!validPassword) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password")
);
@@ -120,11 +122,12 @@ export async function authWithPassword(
await createResourceSession({
resourceId,
token,
passwordId: definedPassword.passwordId
passwordId: definedPassword.passwordId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPasswordResponse>(res, {
data: {

View File

@@ -1,29 +1,17 @@
import { verify } from "@node-rs/argon2";
import { generateSessionToken } from "@server/auth/sessions/app";
import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePincode,
resources,
resourceWhitelist
} from "@server/db/schema";
import { orgs, resourcePincode, resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { and, eq } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import { createResourceSession } from "@server/auth/sessions/resource";
import logger from "@server/logger";
import config from "@server/lib/config";
import { AuthWithPasswordResponse } from "./authWithPassword";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import { verifyPassword } from "@server/auth/password";
import config from "@server/lib/config";
export const authWithPincodeBodySchema = z
.object({
@@ -109,19 +97,21 @@ export async function authWithPincode(
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Resource has no pincode protection"
)
"Resource has no pincode protection"
)
);
}
const validPincode = verifyPassword(
const validPincode = await verifyPassword(
pincode,
definedPincode.pincodeHash
);
if (!validPincode) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN")
);
@@ -131,11 +121,12 @@ export async function authWithPincode(
await createResourceSession({
resourceId,
token,
pincodeId: definedPincode.pincodeId
pincodeId: definedPincode.pincodeId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithPincodeResponse>(res, {
data: {

View File

@@ -3,7 +3,6 @@ import db from "@server/db";
import {
orgs,
resourceOtp,
resourcePassword,
resources,
resourceWhitelist
} from "@server/db/schema";
@@ -14,17 +13,17 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import {
createResourceSession,
serializeResourceSessionCookie
} from "@server/auth/sessions/resource";
import config from "@server/lib/config";
import { createResourceSession } from "@server/auth/sessions/resource";
import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp";
import logger from "@server/logger";
import config from "@server/lib/config";
const authWithWhitelistBodySchema = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
otp: z.string().optional()
})
.strict();
@@ -90,20 +89,53 @@ export async function authWithWhitelist(
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1);
const resource = result?.resources;
const org = result?.orgs;
const whitelistedEmail = result?.resourceWhitelist;
let resource = result?.resources;
let org = result?.orgs;
let whitelistedEmail = result?.resourceWhitelist;
if (!whitelistedEmail) {
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
// if email is not found, check for wildcard email
const wildcard = "*@" + email.split("@")[1];
logger.debug("Checking for wildcard email: " + wildcard);
const [result] = await db
.select()
.from(resourceWhitelist)
.where(
and(
eq(resourceWhitelist.resourceId, resourceId),
eq(resourceWhitelist.email, wildcard)
)
)
);
.leftJoin(
resources,
eq(resources.resourceId, resourceWhitelist.resourceId)
)
.leftJoin(orgs, eq(orgs.orgId, resources.orgId))
.limit(1);
resource = result?.resources;
org = result?.orgs;
whitelistedEmail = result?.resourceWhitelist;
// if wildcard is still not found, return unauthorized
if (!whitelistedEmail) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Email is not whitelisted. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
createHttpError(
HttpCode.BAD_REQUEST,
"Email is not whitelisted"
)
)
);
}
}
if (!org) {
@@ -125,6 +157,11 @@ export async function authWithWhitelist(
otp
);
if (!isValidCode) {
if (config.getRawConfig().app.log_failed_attempts) {
logger.info(
`Resource email otp incorrect. Resource ID: ${resource.resourceId}. Email: ${email}. IP: ${req.ip}.`
);
}
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Incorrect OTP")
);
@@ -175,11 +212,12 @@ export async function authWithWhitelist(
await createResourceSession({
resourceId,
token,
whitelistId: whitelistedEmail.whitelistId
whitelistId: whitelistedEmail.whitelistId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
const cookieName = `${config.getRawConfig().server.resource_session_cookie_name}_${resource.resourceId}`;
const cookie = serializeResourceSessionCookie(cookieName, token);
res.appendHeader("Set-Cookie", cookie);
return response<AuthWithWhitelistResponse>(res, {
data: {

View File

@@ -16,8 +16,9 @@ import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm";
import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import logger from "@server/logger";
import { subdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
const createResourceParamsSchema = z
.object({
@@ -28,10 +29,80 @@ const createResourceParamsSchema = z
const createResourceSchema = z
.object({
subdomain: z.string().optional(),
name: z.string().min(1).max(255),
subdomain: subdomainSchema
siteId: z.number(),
http: z.boolean(),
protocol: z.string(),
proxyPort: z.number().optional(),
isBaseDomain: z.boolean().optional()
})
.strict();
.refine(
(data) => {
if (!data.http) {
return z
.number()
.int()
.min(1)
.max(65535)
.safeParse(data.proxyPort).success;
}
return true;
},
{
message: "Invalid port number",
path: ["proxyPort"]
}
)
.refine(
(data) => {
if (data.http && !data.isBaseDomain) {
return subdomainSchema.safeParse(data.subdomain).success;
}
return true;
},
{
message: "Invalid subdomain",
path: ["subdomain"]
}
)
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) {
return false;
}
}
return true;
},
{
message: "Proxy port cannot be set"
}
)
// .refine(
// (data) => {
// if (data.proxyPort === 443 || data.proxyPort === 80) {
// return false;
// }
// return true;
// },
// {
// message: "Port 80 and 443 are reserved for http and https resources"
// }
// )
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
);
export type CreateResourceResponse = Resource;
@@ -51,7 +122,7 @@ export async function createResource(
);
}
let { name, subdomain } = parsedBody.data;
let { name, subdomain, protocol, proxyPort, http, isBaseDomain } = parsedBody.data;
// Validate request params
const parsedParams = createResourceParamsSchema.safeParse(req.params);
@@ -88,17 +159,64 @@ export async function createResource(
);
}
const fullDomain = `${subdomain}.${org[0].domain}`;
let fullDomain = "";
if (isBaseDomain) {
fullDomain = org[0].domain;
} else {
fullDomain = `${subdomain}.${org[0].domain}`;
}
// if http is false check to see if there is already a resource with the same port and protocol
if (!http) {
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, protocol),
eq(resources.proxyPort, proxyPort!)
)
);
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
} else {
// make sure the full domain is unique
const existingResource = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingResource.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
await db.transaction(async (trx) => {
const newResource = await trx
.insert(resources)
.values({
siteId,
fullDomain,
fullDomain: http ? fullDomain : null,
orgId,
name,
subdomain,
ssl: true
http,
protocol,
proxyPort,
ssl: true,
isBaseDomain
})
.returning();
@@ -135,18 +253,6 @@ export async function createResource(
});
});
} catch (error) {
if (
error instanceof SqliteError &&
error.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that subdomain already exists"
)
);
}
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")

View File

@@ -0,0 +1,145 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
const createResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]),
match: z.enum(["CIDR", "IP", "PATH"]),
value: z.string().min(1),
priority: z.number().int(),
enabled: z.boolean().optional()
})
.strict();
const createResourceRuleParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export async function createResourceRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = createResourceRuleSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { action, match, value, priority, enabled } = parsedBody.data;
const parsedParams = createResourceRuleParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
// Verify that the referenced resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot create rule for non-http resource"
)
);
}
if (match === "CIDR") {
if (!isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
}
} else if (match === "IP") {
if (!isValidIP(value)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
);
}
} else if (match === "PATH") {
if (!isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
}
// Create the new resource rule
const [newRule] = await db
.insert(resourceRules)
.values({
resourceId,
action,
match,
value,
priority,
enabled
})
.returning();
return response(res, {
data: newRule,
success: true,
error: false,
message: "Resource rule created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers";
// Define Zod schema for request parameters validation
const deleteResourceSchema = z
@@ -75,25 +76,9 @@ export async function deleteResource(
if (site.pubKey) {
if (site.type == "wireguard") {
// TODO: is this all inefficient?
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
allowedIps: await getAllowedIps(site.siteId)
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
@@ -103,7 +88,7 @@ export async function deleteResource(
.where(eq(newts.siteId, site.siteId))
.limit(1);
removeTargets(newt.newtId, targetsToBeRemoved);
removeTargets(newt.newtId, targetsToBeRemoved, deletedResource.protocol);
}
}

View File

@@ -0,0 +1,71 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
const deleteResourceRuleSchema = z
.object({
ruleId: z
.string()
.transform(Number)
.pipe(z.number().int().positive()),
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export async function deleteResourceRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = deleteResourceRuleSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { ruleId } = parsedParams.data;
// Delete the rule and return the deleted record
const [deletedRule] = await db
.delete(resourceRules)
.where(eq(resourceRules.ruleId, ruleId))
.returning();
if (!deletedRule) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource rule with ID ${ruleId} not found`
)
);
}
return response(res, {
data: null,
success: true,
error: false,
message: "Resource rule deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -0,0 +1,109 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import { createResourceSession } from "@server/auth/sessions/resource";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { generateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import {
encodeHexLowerCase
} from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { response } from "@server/lib";
const getExchangeTokenParams = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
export type GetExchangeTokenResponse = {
requestToken: string;
};
export async function getExchangeToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = getExchangeTokenParams.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const resource = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (resource.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const ssoSession =
req.cookies[config.getRawConfig().server.session_cookie_name];
if (!ssoSession) {
logger.debug(ssoSession);
return next(
createHttpError(
HttpCode.UNAUTHORIZED,
"Missing SSO session cookie"
)
);
}
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(ssoSession))
);
const token = generateSessionToken();
await createResourceSession({
resourceId,
token,
userSessionId: sessionId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
logger.debug("Request token created successfully");
return response<GetExchangeTokenResponse>(res, {
data: {
requestToken: token
},
success: true,
error: false,
message: "Request token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -16,3 +16,9 @@ export * from "./setResourceWhitelist";
export * from "./getResourceWhitelist";
export * from "./authWithWhitelist";
export * from "./authWithAccessToken";
export * from "./transferResource";
export * from "./getExchangeToken";
export * from "./createResourceRule";
export * from "./deleteResourceRule";
export * from "./listResourceRules";
export * from "./updateResourceRule";

View File

@@ -0,0 +1,139 @@
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db/schema";
import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response";
import { eq, sql } from "drizzle-orm";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { z } from "zod";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
const listResourceRulesParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
const listResourceRulesSchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
function queryResourceRules(resourceId: number) {
let baseQuery = db
.select({
ruleId: resourceRules.ruleId,
resourceId: resourceRules.resourceId,
action: resourceRules.action,
match: resourceRules.match,
value: resourceRules.value,
priority: resourceRules.priority,
enabled: resourceRules.enabled
})
.from(resourceRules)
.leftJoin(resources, eq(resourceRules.resourceId, resources.resourceId))
.where(eq(resourceRules.resourceId, resourceId));
return baseQuery;
}
export type ListResourceRulesResponse = {
rules: Awaited<ReturnType<typeof queryResourceRules>>;
pagination: { total: number; limit: number; offset: number };
};
export async function listResourceRules(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedQuery = listResourceRulesSchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error)
)
);
}
const { limit, offset } = parsedQuery.data;
const parsedParams = listResourceRulesParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error)
)
);
}
const { resourceId } = parsedParams.data;
// Verify the resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const baseQuery = queryResourceRules(resourceId);
let countQuery = db
.select({ count: sql<number>`cast(count(*) as integer)` })
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
let rulesList = await baseQuery.limit(limit).offset(offset);
const totalCountResult = await countQuery;
const totalCount = totalCountResult[0].count;
// sort rules list by the priority in ascending order
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
return response<ListResourceRulesResponse>(res, {
data: {
rules: rulesList,
pagination: {
total: totalCount,
limit,
offset
}
},
success: true,
error: false,
message: "Resource rules retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -63,7 +63,10 @@ function queryResources(
passwordId: resourcePassword.passwordId,
pincodeId: resourcePincode.pincodeId,
sso: resources.sso,
whitelist: resources.emailWhitelistEnabled
whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))
@@ -93,7 +96,10 @@ function queryResources(
passwordId: resourcePassword.passwordId,
sso: resources.sso,
pincodeId: resourcePincode.pincodeId,
whitelist: resources.emailWhitelistEnabled
whitelist: resources.emailWhitelistEnabled,
http: resources.http,
protocol: resources.protocol,
proxyPort: resources.proxyPort
})
.from(resources)
.leftJoin(sites, eq(resources.siteId, sites.siteId))

View File

@@ -11,7 +11,20 @@ import { and, eq } from "drizzle-orm";
const setResourceWhitelistBodySchema = z
.object({
emails: z.array(z.string().email()).max(50)
emails: z
.array(
z
.string()
.email()
.or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
message:
"Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
})
.strict();

View File

@@ -0,0 +1,192 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { newts, resources, sites, targets } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets, removeTargets } from "../newt/targets";
import { getAllowedIps } from "../target/helpers";
const transferResourceParamsSchema = z
.object({
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
const transferResourceBodySchema = z
.object({
siteId: z.number().int().positive()
})
.strict();
export async function transferResource(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = transferResourceParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = transferResourceBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { siteId } = parsedBody.data;
const [oldResource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!oldResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (oldResource.siteId === siteId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Resource is already assigned to this site`
)
);
}
const [newSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!newSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found`
)
);
}
const [oldSite] = await db
.select()
.from(sites)
.where(eq(sites.siteId, oldResource.siteId))
.limit(1);
if (!oldSite) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${oldResource.siteId} not found`
)
);
}
const [updatedResource] = await db
.update(resources)
.set({ siteId })
.where(eq(resources.resourceId, resourceId))
.returning();
if (!updatedResource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const resourceTargets = await db
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
if (resourceTargets.length > 0) {
////// REMOVE THE TARGETS FROM THE OLD SITE //////
if (oldSite.pubKey) {
if (oldSite.type == "wireguard") {
await addPeer(oldSite.exitNodeId!, {
publicKey: oldSite.pubKey,
allowedIps: await getAllowedIps(oldSite.siteId)
});
} else if (oldSite.type == "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, oldSite.siteId))
.limit(1);
removeTargets(
newt.newtId,
resourceTargets,
updatedResource.protocol
);
}
}
////// ADD THE TARGETS TO THE NEW SITE //////
if (newSite.pubKey) {
if (newSite.type == "wireguard") {
await addPeer(newSite.exitNodeId!, {
publicKey: newSite.pubKey,
allowedIps: await getAllowedIps(newSite.siteId)
});
} else if (newSite.type == "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, newSite.siteId))
.limit(1);
addTargets(
newt.newtId,
resourceTargets,
updatedResource.protocol
);
}
}
}
return response(res, {
data: updatedResource,
success: true,
error: false,
message: "Resource transferred successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -2,13 +2,14 @@ import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { orgs, resources, sites } from "@server/db/schema";
import { eq, or } from "drizzle-orm";
import { eq, or, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/schemas/subdomainSchema";
import config from "@server/lib/config";
import { subdomainSchema } from "@server/lib/schemas";
const updateResourceParamsSchema = z
.object({
@@ -26,13 +27,50 @@ const updateResourceBodySchema = z
ssl: z.boolean().optional(),
sso: z.boolean().optional(),
blockAccess: z.boolean().optional(),
emailWhitelistEnabled: z.boolean().optional()
// siteId: z.number(),
proxyPort: z.number().int().min(1).max(65535).optional(),
emailWhitelistEnabled: z.boolean().optional(),
isBaseDomain: z.boolean().optional(),
applyRules: z.boolean().optional(),
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
})
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_raw_resources) {
if (data.proxyPort !== undefined) {
return false;
}
}
return true;
},
{ message: "Cannot update proxyPort" }
)
// .refine(
// (data) => {
// if (data.proxyPort === 443 || data.proxyPort === 80) {
// return false;
// }
// return true;
// },
// {
// message: "Port 80 and 443 are reserved for http and https resources"
// }
// )
.refine(
(data) => {
if (!config.getRawConfig().flags?.allow_base_domain_resources) {
if (data.isBaseDomain) {
return false;
}
}
return true;
},
{
message: "Base domain resources are not allowed"
}
);
export async function updateResource(
req: Request,
@@ -63,13 +101,16 @@ export async function updateResource(
const { resourceId } = parsedParams.data;
const updateData = parsedBody.data;
const resource = await db
const [result] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.leftJoin(orgs, eq(resources.orgId, orgs.orgId));
if (resource.length === 0) {
const resource = result.resources;
const org = result.orgs;
if (!resource || !org) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -78,7 +119,55 @@ export async function updateResource(
);
}
if (!resource[0].orgs?.domain) {
if (updateData.subdomain) {
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot update subdomain for non-http resource"
)
);
}
const valid = subdomainSchema.safeParse(
updateData.subdomain
).success;
if (!valid) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subdomain provided"
)
);
}
}
if (updateData.proxyPort) {
const proxyPort = updateData.proxyPort;
const existingResource = await db
.select()
.from(resources)
.where(
and(
eq(resources.protocol, resource.protocol),
eq(resources.proxyPort, proxyPort!)
)
);
if (
existingResource.length > 0 &&
existingResource[0].resourceId !== resourceId
) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that protocol and port already exists"
)
);
}
}
if (!org?.domain) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
@@ -87,15 +176,38 @@ export async function updateResource(
);
}
const fullDomain = updateData.subdomain
? `${updateData.subdomain}.${resource[0].orgs.domain}`
: undefined;
let fullDomain: string | undefined;
if (updateData.isBaseDomain) {
fullDomain = org.domain;
} else if (updateData.subdomain) {
fullDomain = `${updateData.subdomain}.${org.domain}`;
}
const updatePayload = {
...updateData,
...(fullDomain && { fullDomain })
};
if (
fullDomain &&
(updatePayload.subdomain !== undefined ||
updatePayload.isBaseDomain !== undefined)
) {
const [existingDomain] = await db
.select()
.from(resources)
.where(eq(resources.fullDomain, fullDomain));
if (existingDomain && existingDomain.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource with that domain already exists"
)
);
}
}
const updatedResource = await db
.update(resources)
.set(updatePayload)

View File

@@ -0,0 +1,179 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resourceRules, resources } from "@server/db/schema";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import {
isValidCIDR,
isValidIP,
isValidUrlGlobPattern
} from "@server/lib/validators";
// Define Zod schema for request parameters validation
const updateResourceRuleParamsSchema = z
.object({
ruleId: z.string().transform(Number).pipe(z.number().int().positive()),
resourceId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
})
.strict();
// Define Zod schema for request body validation
const updateResourceRuleSchema = z
.object({
action: z.enum(["ACCEPT", "DROP"]).optional(),
match: z.enum(["CIDR", "IP", "PATH"]).optional(),
value: z.string().min(1).optional(),
priority: z.number().int(),
enabled: z.boolean().optional()
})
.strict()
.refine((data) => Object.keys(data).length > 0, {
message: "At least one field must be provided for update"
});
export async function updateResourceRule(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
// Validate path parameters
const parsedParams = updateResourceRuleParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
// Validate request body
const parsedBody = updateResourceRuleSchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { ruleId, resourceId } = parsedParams.data;
const updateData = parsedBody.data;
// Verify that the resource exists
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
if (!resource.http) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Cannot create rule for non-http resource"
)
);
}
// Verify that the rule exists and belongs to the specified resource
const [existingRule] = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.ruleId, ruleId))
.limit(1);
if (!existingRule) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource rule with ID ${ruleId} not found`
)
);
}
if (existingRule.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
)
);
}
const match = updateData.match || existingRule.match;
const { value } = updateData;
if (value !== undefined) {
if (match === "CIDR") {
if (!isValidCIDR(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid CIDR provided"
)
);
}
} else if (match === "IP") {
if (!isValidIP(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid IP provided"
)
);
}
} else if (match === "PATH") {
if (!isValidUrlGlobPattern(value)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid URL glob pattern provided"
)
);
}
}
}
// Update the rule
const [updatedRule] = await db
.update(resourceRules)
.set(updateData)
.where(eq(resourceRules.ruleId, ruleId))
.returning();
return response(res, {
data: updatedRule,
success: true,
error: false,
message: "Resource rule updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View File

@@ -24,7 +24,7 @@ const createSiteParamsSchema = z
const createSiteSchema = z
.object({
name: z.string().min(1).max(255),
exitNodeId: z.number().int().positive(),
exitNodeId: z.number().int().positive().optional(),
// subdomain: z
// .string()
// .min(1)
@@ -32,7 +32,7 @@ const createSiteSchema = z
// .transform((val) => val.toLowerCase())
// .optional(),
pubKey: z.string().optional(),
subnet: z.string(),
subnet: z.string().optional(),
newtId: z.string().optional(),
secret: z.string().optional(),
type: z.string()
@@ -82,28 +82,46 @@ export async function createSite(
const niceId = await getUniqueSiteName(orgId);
let payload: any = {
orgId,
exitNodeId,
name,
niceId,
subnet,
type
};
if (pubKey && type == "wireguard") {
// we dont add the pubKey for newts because the newt will generate it
payload = {
...payload,
pubKey
};
}
await db.transaction(async (trx) => {
const [newSite] = await trx
.insert(sites)
.values(payload)
.returning();
let newSite: Site;
if (exitNodeId) {
// we are creating a site with an exit node (tunneled)
if (!subnet) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Subnet is required for tunneled sites"
)
);
}
[newSite] = await trx
.insert(sites)
.values({
orgId,
exitNodeId,
name,
niceId,
subnet,
type,
...(pubKey && type == "wireguard" && { pubKey })
})
.returning();
} else {
// we are creating a site with no tunneling
[newSite] = await trx
.insert(sites)
.values({
orgId,
name,
niceId,
type,
subnet: "0.0.0.0/0"
})
.returning();
}
const adminRole = await trx
.select()
@@ -149,6 +167,16 @@ export async function createSite(
)
);
}
if (!exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Exit node ID is required for wireguard sites"
)
);
}
await addPeer(exitNodeId, {
publicKey: pubKey,
allowedIps: []

View File

@@ -7,38 +7,12 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { addPeer } from "../gerbil/peers";
import { eq, and } from "drizzle-orm";
import { isIpInCidr } from "@server/lib/ip";
import { fromError } from "zod-validation-error";
import { addTargets } from "../newt/targets";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { eq } from "drizzle-orm";
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
const createTargetParamsSchema = z
.object({
@@ -51,10 +25,9 @@ const createTargetParamsSchema = z
const createTargetSchema = z
.object({
ip: domainSchema,
method: z.string().min(1).max(10),
ip: z.string().refine(isTargetValid),
method: z.string().optional().nullable(),
port: z.number().int().min(1).max(65535),
protocol: z.string().optional(),
enabled: z.boolean().default(true)
})
.strict();
@@ -93,9 +66,7 @@ export async function createTarget(
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId));
@@ -123,88 +94,68 @@ export async function createTarget(
);
}
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port
for (let i = 40000; i < 65535; i++) {
if (!targetInternalPorts.includes(i)) {
internalPort = i;
break;
let newTarget: Target[] = [];
if (site.type == "local") {
newTarget = await db
.insert(targets)
.values({
resourceId,
...targetData
})
.returning();
} else {
// make sure the target is within the site subnet
if (
site.type == "wireguard" &&
!isIpInCidr(targetData.ip, site.subnet!)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Target IP is not within the site subnet`
)
);
}
}
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!);
const newTarget = await db
.insert(targets)
.values({
resourceId,
protocol: "tcp", // hard code for now
internalPort,
...targetData
})
.returning();
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
// add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`);
newTarget = await db
.insert(targets)
.values({
resourceId,
internalPort,
...targetData
})
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
// add the new target to the targetIps array
targetIps.push(`${targetData.ip}/32`);
addTargets(newt.newtId, newTarget);
if (site.pubKey) {
if (site.type == "wireguard") {
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, newTarget, resource.protocol);
}
}
}

View File

@@ -10,6 +10,7 @@ import logger from "@server/logger";
import { addPeer } from "../gerbil/peers";
import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets";
import { getAllowedIps } from "./helpers";
const deleteTargetSchema = z
.object({
@@ -50,9 +51,7 @@ export async function deleteTarget(
}
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, deletedTarget.resourceId!));
@@ -82,25 +81,9 @@ export async function deleteTarget(
if (site.pubKey) {
if (site.type == "wireguard") {
// TODO: is this all inefficient?
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
allowedIps: await getAllowedIps(site.siteId)
});
} else if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
@@ -110,7 +93,7 @@ export async function deleteTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
removeTargets(newt.newtId, [deletedTarget]);
removeTargets(newt.newtId, [deletedTarget], resource.protocol);
}
}

View File

@@ -0,0 +1,66 @@
import { db } from "@server/db";
import { resources, targets } from "@server/db/schema";
import { eq } from "drizzle-orm";
let currentBannedPorts: number[] = [];
export async function pickPort(siteId: number): Promise<{
internalPort: number;
targetIps: string[];
}> {
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, siteId)
});
// TODO: is this all inefficient?
// Fetch targets for all resources of this site
let targetIps: string[] = [];
let targetInternalPorts: number[] = [];
await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
targetsRes.forEach((target) => {
targetIps.push(`${target.ip}/32`);
if (target.internalPort) {
targetInternalPorts.push(target.internalPort);
}
});
})
);
let internalPort!: number;
// pick a port random port from 40000 to 65535 that is not in use
for (let i = 0; i < 1000; i++) {
internalPort = Math.floor(Math.random() * 25535) + 40000;
if (
!targetInternalPorts.includes(internalPort) &&
!currentBannedPorts.includes(internalPort)
) {
break;
}
}
currentBannedPorts.push(internalPort);
return { internalPort, targetIps };
}
export async function getAllowedIps(siteId: number) {
// TODO: is this all inefficient?
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
return targetIps.flat();
}

View File

@@ -40,7 +40,6 @@ function queryTargets(resourceId: number) {
ip: targets.ip,
method: targets.method,
port: targets.port,
protocol: targets.protocol,
enabled: targets.enabled,
resourceId: targets.resourceId
// resourceName: resources.name,

View File

@@ -10,34 +10,8 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { addPeer } from "../gerbil/peers";
import { addTargets } from "../newt/targets";
// Regular expressions for validation
const DOMAIN_REGEX =
/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
const IPV4_REGEX =
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
const IPV6_REGEX = /^(?:[A-F0-9]{1,4}:){7}[A-F0-9]{1,4}$/i;
// Schema for domain names and IP addresses
const domainSchema = z
.string()
.min(1, "Domain cannot be empty")
.max(255, "Domain name too long")
.refine(
(value) => {
// Check if it's a valid IP address (v4 or v6)
if (IPV4_REGEX.test(value) || IPV6_REGEX.test(value)) {
return true;
}
// Check if it's a valid domain name
return DOMAIN_REGEX.test(value);
},
{
message: "Invalid domain name or IP address format",
path: ["domain"]
}
);
import { pickPort } from "./helpers";
import { isTargetValid } from "@server/lib/validators";
const updateTargetParamsSchema = z
.object({
@@ -47,8 +21,8 @@ const updateTargetParamsSchema = z
const updateTargetBodySchema = z
.object({
ip: domainSchema.optional(),
method: z.string().min(1).max(10).optional(),
ip: z.string().refine(isTargetValid),
method: z.string().min(1).max(10).optional().nullable(),
port: z.number().int().min(1).max(65535).optional(),
enabled: z.boolean().optional()
})
@@ -84,15 +58,14 @@ export async function updateTarget(
}
const { targetId } = parsedParams.data;
const updateData = parsedBody.data;
const [updatedTarget] = await db
.update(targets)
.set(updateData)
const [target] = await db
.select()
.from(targets)
.where(eq(targets.targetId, targetId))
.returning();
.limit(1);
if (!updatedTarget) {
if (!target) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
@@ -103,17 +76,15 @@ export async function updateTarget(
// get the resource
const [resource] = await db
.select({
siteId: resources.siteId
})
.select()
.from(resources)
.where(eq(resources.resourceId, updatedTarget.resourceId!));
.where(eq(resources.resourceId, target.resourceId!));
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${updatedTarget.resourceId} not found`
`Resource with ID ${target.resourceId} not found`
)
);
}
@@ -132,24 +103,29 @@ export async function updateTarget(
)
);
}
const { internalPort, targetIps } = await pickPort(site.siteId!);
if (!internalPort) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`No available internal port`
)
);
}
const [updatedTarget] = await db
.update(targets)
.set({
...parsedBody.data,
internalPort
})
.where(eq(targets.targetId, targetId))
.returning();
if (site.pubKey) {
if (site.type == "wireguard") {
// TODO: is this all inefficient?
// Fetch resources for this site
const resourcesRes = await db.query.resources.findMany({
where: eq(resources.siteId, site.siteId)
});
// Fetch targets for all resources of this site
const targetIps = await Promise.all(
resourcesRes.map(async (resource) => {
const targetsRes = await db.query.targets.findMany({
where: eq(targets.resourceId, resource.resourceId)
});
return targetsRes.map((target) => `${target.ip}/32`);
})
);
await addPeer(site.exitNodeId!, {
publicKey: site.pubKey,
allowedIps: targetIps.flat()
@@ -162,7 +138,7 @@ export async function updateTarget(
.where(eq(newts.siteId, site.siteId))
.limit(1);
addTargets(newt.newtId, [updatedTarget]);
addTargets(newt.newtId, [updatedTarget], resource.protocol);
}
}
return response(res, {

View File

@@ -1,163 +1,295 @@
import { Request, Response } from "express";
import db from "@server/db";
import * as schema from "@server/db/schema";
import { and, eq, isNotNull } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode";
import config from "@server/lib/config";
import { orgs, resources, sites, Target, targets } from "@server/db/schema";
import { sql } from "drizzle-orm";
export async function traefikConfigProvider(
_: Request,
res: Response,
res: Response
): Promise<any> {
try {
const all = await db
.select()
.from(schema.targets)
.innerJoin(
schema.resources,
eq(schema.targets.resourceId, schema.resources.resourceId),
)
.innerJoin(
schema.orgs,
eq(schema.resources.orgId, schema.orgs.orgId),
)
.innerJoin(
schema.sites,
eq(schema.sites.siteId, schema.resources.siteId),
)
.where(
const allResources = await db
.select({
// Resource fields
resourceId: resources.resourceId,
subdomain: resources.subdomain,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
blockAccess: resources.blockAccess,
sso: resources.sso,
emailWhitelistEnabled: resources.emailWhitelistEnabled,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
isBaseDomain: resources.isBaseDomain,
// Site fields
site: {
siteId: sites.siteId,
type: sites.type,
subnet: sites.subnet
},
// Org fields
org: {
orgId: orgs.orgId,
domain: orgs.domain
},
// Targets as a subquery
targets: sql<string>`json_group_array(json_object(
'targetId', ${targets.targetId},
'ip', ${targets.ip},
'method', ${targets.method},
'port', ${targets.port},
'internalPort', ${targets.internalPort},
'enabled', ${targets.enabled}
))`.as("targets")
})
.from(resources)
.innerJoin(sites, eq(sites.siteId, resources.siteId))
.innerJoin(orgs, eq(resources.orgId, orgs.orgId))
.leftJoin(
targets,
and(
eq(schema.targets.enabled, true),
isNotNull(schema.resources.subdomain),
isNotNull(schema.orgs.domain),
),
);
eq(targets.resourceId, resources.resourceId),
eq(targets.enabled, true)
)
)
.groupBy(resources.resourceId);
if (!all.length) {
if (!allResources.length) {
return res.status(HttpCode.OK).json({});
}
const badgerMiddlewareName = "badger";
const redirectMiddlewareName = "redirect-to-https";
const redirectHttpsMiddlewareName = "redirect-to-https";
const http: any = {
routers: {},
services: {},
middlewares: {
[badgerMiddlewareName]: {
plugin: {
[badgerMiddlewareName]: {
apiBaseUrl: new URL(
"/api/v1",
`http://${config.getRawConfig().server.internal_hostname}:${config.getRawConfig().server.internal_port}`,
).href,
resourceSessionCookieName:
config.getRawConfig().server.resource_session_cookie_name,
userSessionCookieName:
config.getRawConfig().server.session_cookie_name,
},
const config_output: any = {
http: {
middlewares: {
[badgerMiddlewareName]: {
plugin: {
[badgerMiddlewareName]: {
apiBaseUrl: new URL(
"/api/v1",
`http://${config.getRawConfig().server.internal_hostname}:${
config.getRawConfig().server
.internal_port
}`
).href,
userSessionCookieName:
config.getRawConfig().server
.session_cookie_name,
accessTokenQueryParam:
config.getRawConfig().server
.resource_access_token_param,
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param
}
}
},
},
[redirectMiddlewareName]: {
redirectScheme: {
scheme: "https",
permanent: true,
},
},
},
[redirectHttpsMiddlewareName]: {
redirectScheme: {
scheme: "https"
}
}
}
}
};
for (const item of all) {
const target = item.targets;
const resource = item.resources;
const site = item.sites;
const org = item.orgs;
const routerName = `${target.targetId}-router`;
const serviceName = `${target.targetId}-service`;
for (const resource of allResources) {
const targets = JSON.parse(resource.targets);
const site = resource.site;
const org = resource.org;
if (!resource || !resource.subdomain) {
if (!org.domain) {
continue;
}
if (!org || !org.domain) {
continue;
}
const routerName = `${resource.resourceId}-router`;
const serviceName = `${resource.resourceId}-service`;
const fullDomain = `${resource.fullDomain}`;
const fullDomain = `${resource.subdomain}.${org.domain}`;
if (resource.http) {
// HTTP configuration remains the same
if (!resource.subdomain && !resource.isBaseDomain) {
continue;
}
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
// add routers and services empty objects if they don't exist
if (!config_output.http.routers) {
config_output.http.routers = {};
}
const tls = {
certResolver: config.getRawConfig().traefik.cert_resolver,
...(config.getRawConfig().traefik.prefer_wildcard_cert
? {
domains: [
{
main: wildCard,
},
],
}
: {}),
};
if (!config_output.http.services) {
config_output.http.services = {};
}
http.routers![routerName] = {
entryPoints: [
resource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint,
],
middlewares: [badgerMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {}),
};
const domainParts = fullDomain.split(".");
let wildCard;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (resource.ssl) {
// this is a redirect router; all it does is redirect to the https version if tls is enabled
http.routers![routerName + "-redirect"] = {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectMiddlewareName],
const tls = {
certResolver: config.getRawConfig().traefik.cert_resolver,
...(config.getRawConfig().traefik.prefer_wildcard_cert
? {
domains: [
{
main: wildCard
}
]
}
: {})
};
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
config_output.http.routers![routerName] = {
entryPoints: [
resource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: [
badgerMiddlewareName,
...additionalMiddlewares
],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`,
...(resource.ssl ? { tls } : {})
};
}
if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
http.services![serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${ip}:${target.internalPort}`,
},
if (resource.ssl) {
config_output.http.routers![routerName + "-redirect"] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
},
middlewares: [redirectHttpsMiddlewareName],
service: serviceName,
rule: `Host(\`${fullDomain}\`)`
};
}
config_output.http.services![serviceName] = {
loadBalancer: {
servers: targets
.filter((target: Target) => {
if (!target.enabled) {
return false;
}
if (
site.type === "local" ||
site.type === "wireguard"
) {
if (
!target.ip ||
!target.port ||
!target.method
) {
return false;
}
} else if (site.type === "newt") {
if (
!target.internalPort ||
!target.method
) {
return false;
}
}
return true;
})
.map((target: Target) => {
if (
site.type === "local" ||
site.type === "wireguard"
) {
return {
url: `${target.method}://${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
return {
url: `${target.method}://${ip}:${target.internalPort}`
};
}
})
}
};
} else if (site.type === "wireguard") {
http.services![serviceName] = {
} else {
// Non-HTTP (TCP/UDP) configuration
const protocol = resource.protocol.toLowerCase();
const port = resource.proxyPort;
if (!port) {
continue;
}
if (!config_output[protocol]) {
config_output[protocol] = {
routers: {},
services: {}
};
}
config_output[protocol].routers[routerName] = {
entryPoints: [`${protocol}-${port}`],
service: serviceName,
...(protocol === "tcp" ? { rule: "HostSNI(`*`)" } : {})
};
config_output[protocol].services[serviceName] = {
loadBalancer: {
servers: [
{
url: `${target.method}://${target.ip}:${target.port}`,
},
],
},
servers: targets
.filter((target: Target) => {
if (!target.enabled) {
return false;
}
if (
site.type === "local" ||
site.type === "wireguard"
) {
if (!target.ip || !target.port) {
return false;
}
} else if (site.type === "newt") {
if (!target.internalPort) {
return false;
}
}
return true;
})
.map((target: Target) => {
if (
site.type === "local" ||
site.type === "wireguard"
) {
return {
address: `${target.ip}:${target.port}`
};
} else if (site.type === "newt") {
const ip = site.subnet.split("/")[0];
return {
address: `${ip}:${target.internalPort}`
};
}
})
}
};
}
}
return res.status(HttpCode.OK).json({ http });
return res.status(HttpCode.OK).json(config_output);
} catch (e) {
logger.error(`Failed to build traefik config: ${e}`);
logger.error(`Failed to build Traefik config: ${e}`);
return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
error: "Failed to build traefik config",
error: "Failed to build Traefik config"
});
}
}

View File

@@ -1 +1 @@
export * from "./getTraefikConfig";
export * from "./getTraefikConfig";

View File

@@ -23,7 +23,10 @@ const inviteUserParamsSchema = z
const inviteUserBodySchema = z
.object({
email: z.string().email(),
email: z
.string()
.email()
.transform((v) => v.toLowerCase()),
roleId: z.number(),
validHours: z.number().gt(0).lte(168),
sendEmail: z.boolean().optional()
@@ -165,7 +168,7 @@ export async function inviteUser(
}),
{
to: email,
from: config.getRawConfig().email?.no_reply,
from: config.getNoReplyEmail(),
subject: "You're invited to join a Fossorial organization"
}
);

View File

@@ -7,6 +7,7 @@ import logger from "@server/logger";
export async function copyInConfig() {
const domain = config.getBaseDomain();
const endpoint = config.getRawConfig().gerbil.base_endpoint;
const listenPort = config.getRawConfig().gerbil.start_port;
// update the domain on all of the orgs where the domain is not equal to the new domain
// TODO: eventually each org could have a unique domain that we do not want to overwrite, so this will be unnecessary
@@ -14,13 +15,20 @@ export async function copyInConfig() {
// TODO: eventually each exit node could have a different endpoint
await db.update(exitNodes).set({ endpoint }).where(ne(exitNodes.endpoint, endpoint));
// TODO: eventually each exit node could have a different port
await db.update(exitNodes).set({ listenPort }).where(ne(exitNodes.listenPort, listenPort));
// update all resources fullDomain to use the new domain
await db.transaction(async (trx) => {
const allResources = await trx.select().from(resources);
for (const resource of allResources) {
const fullDomain = `${resource.subdomain}.${domain}`;
let fullDomain = "";
if (resource.isBaseDomain) {
fullDomain = domain;
} else {
fullDomain = `${resource.subdomain}.${domain}`;
}
await trx
.update(resources)
.set({ fullDomain })

View File

@@ -3,12 +3,18 @@ import db, { exists } from "@server/db";
import path from "path";
import semver from "semver";
import { versionMigrations } from "@server/db/schema";
import { desc } from "drizzle-orm";
import { __DIRNAME } from "@server/lib/consts";
import { loadAppVersion } from "@server/lib/loadAppVersion";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { SqliteError } from "better-sqlite3";
import fs from "fs";
import m1 from "./scripts/1.0.0-beta1";
import m2 from "./scripts/1.0.0-beta2";
import m3 from "./scripts/1.0.0-beta3";
import m4 from "./scripts/1.0.0-beta5";
import m5 from "./scripts/1.0.0-beta6";
import m6 from "./scripts/1.0.0-beta9";
import m7 from "./scripts/1.0.0-beta10";
import m8 from "./scripts/1.0.0-beta12";
import m13 from "./scripts/1.0.0-beta13";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -17,61 +23,103 @@ import m3 from "./scripts/1.0.0-beta3";
const migrations = [
{ version: "1.0.0-beta.1", run: m1 },
{ version: "1.0.0-beta.2", run: m2 },
{ version: "1.0.0-beta.3", run: m3 }
{ version: "1.0.0-beta.3", run: m3 },
{ version: "1.0.0-beta.5", run: m4 },
{ version: "1.0.0-beta.6", run: m5 },
{ version: "1.0.0-beta.9", run: m6 },
{ version: "1.0.0-beta.10", run: m7 },
{ version: "1.0.0-beta.12", run: m8 },
{ version: "1.0.0-beta.13", run: m13 }
// Add new migrations here as they are created
] as const;
// Run the migrations
await runMigrations();
await run();
export async function runMigrations() {
const appVersion = loadAppVersion();
if (!appVersion) {
throw new Error("APP_VERSION is not set in the environment");
async function run() {
// backup the database
backupDb();
// run the migrations
await runMigrations();
}
function backupDb() {
// make dir config/db/backups
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const backupsDir = path.join(dbDir, "backups");
// check if the backups directory exists and create it if it doesn't
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
if (exists) {
await executeScripts();
} else {
console.log("Running migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
export async function runMigrations() {
try {
const appVersion = APP_VERSION;
if (exists) {
await executeScripts();
} else {
console.log("Running migrations...");
try {
migrate(db, {
migrationsFolder: path.join(__DIRNAME, "init") // put here during the docker build
});
console.log("Migrations completed successfully.");
} catch (error) {
console.error("Error running migrations:", error);
}
await db
.insert(versionMigrations)
.values({
version: appVersion,
executedAt: Date.now()
})
.execute();
}
} catch (e) {
console.error("Error running migrations:", e);
await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
);
}
}
async function executeScripts() {
try {
// Get the last executed version from the database
const lastExecuted = await db
.select()
.from(versionMigrations)
.orderBy(desc(versionMigrations.version))
.limit(1);
const startVersion = lastExecuted[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const lastExecuted = await db.select().from(versionMigrations);
// Filter and sort migrations
const pendingMigrations = migrations
.filter((migration) => semver.gt(migration.version, startVersion))
.sort((a, b) => semver.compare(a.version, b.version));
const pendingMigrations = lastExecuted
.map((m) => m)
.sort((a, b) => semver.compare(b.version, a.version));
const startVersion = pendingMigrations[0]?.version ?? "0.0.0";
console.log(`Starting migrations from version ${startVersion}`);
const migrationsToRun = migrations.filter((migration) =>
semver.gt(migration.version, startVersion)
);
console.log(
"Migrations to run:",
migrationsToRun.map((m) => m.version).join(", ")
);
// Run migrations in order
for (const migration of pendingMigrations) {
for (const migration of migrationsToRun) {
console.log(`Running migration ${migration.version}`);
try {
@@ -89,12 +137,19 @@ async function executeScripts() {
console.log(
`Successfully completed migration ${migration.version}`
);
} catch (error) {
} catch (e) {
if (
e instanceof SqliteError &&
e.code === "SQLITE_CONSTRAINT_UNIQUE"
) {
console.error("Migration has already run! Skipping...");
continue;
}
console.error(
`Failed to run migration ${migration.version}:`,
error
e
);
throw error; // Re-throw to stop migration process
throw e; // Re-throw to stop migration process
}
}

View File

@@ -0,0 +1,45 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.10...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
delete rawConfig.server.secure_cookies;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Removed deprecated config option: secure_cookies.`);
} catch (e) {
console.log(
`Was unable to remove deprecated config option: secure_cookies. Error: ${e}`
);
return;
}
console.log("Done.");
}

View File

@@ -0,0 +1,62 @@
import db from "@server/db";
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.12...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
if (!rawConfig.flags) {
rawConfig.flags = {};
}
rawConfig.flags.allow_base_domain_resources = true;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
console.log(`Added new config option: allow_base_domain_resources`);
} catch (e) {
console.log(
`Unable to add new config option: allow_base_domain_resources. This is not critical.`
);
console.error(e);
}
try {
db.transaction((trx) => {
trx.run(sql`ALTER TABLE 'resources' ADD 'isBaseDomain' integer;`);
});
console.log(`Added new column: isBaseDomain`);
} catch (e) {
console.log("Unable to add new column: isBaseDomain");
throw e;
}
console.log("Done.");
}

View File

@@ -0,0 +1,33 @@
import db from "@server/db";
import { sql } from "drizzle-orm";
const version = "1.0.0-beta.13";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
db.transaction((trx) => {
trx.run(sql`CREATE TABLE resourceRules (
ruleId integer PRIMARY KEY AUTOINCREMENT NOT NULL,
resourceId integer NOT NULL,
priority integer NOT NULL,
enabled integer DEFAULT true NOT NULL,
action text NOT NULL,
match text NOT NULL,
value text NOT NULL,
FOREIGN KEY (resourceId) REFERENCES resources(resourceId) ON UPDATE no action ON DELETE cascade
);`);
trx.run(
sql`ALTER TABLE resources ADD applyRules integer DEFAULT false NOT NULL;`
);
});
console.log(`Added new table and column: resourceRules, applyRules`);
} catch (e) {
console.log("Unable to add new table and column: resourceRules, applyRules");
throw e;
}
console.log(`${version} migration complete`);
}

View File

@@ -0,0 +1,101 @@
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.5...");
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
// Validate the structure
if (!rawConfig.server) {
throw new Error(`Invalid config file: server is missing.`);
}
// Update the config
rawConfig.server.resource_access_token_param = "p_token";
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
// then try to update badger in traefik config
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
// read the traefik file
// look for the badger middleware
// set the version to v1.0.0-beta.2
/*
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "v1.0.0-beta.2"
*/
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.0.0-beta.2";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.0.0-beta.2."
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually."
);
console.error(e);
}
console.log("Done.");
}

View File

@@ -0,0 +1,52 @@
import { configFilePath1, configFilePath2 } from "@server/lib/consts";
import fs from "fs";
import yaml from "js-yaml";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.6...");
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
// Validate the structure
if (!rawConfig.server) {
throw new Error(`Invalid config file: server is missing.`);
}
// Update the config
rawConfig.server.cors = {
origins: [rawConfig.app.dashboard_url],
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"],
headers: ["X-CSRF-Token", "Content-Type"],
credentials: false
};
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (error) {
console.log("We were unable to add CORS to your config file. Please add it manually.")
console.error(error)
}
console.log("Done.");
}

View File

@@ -0,0 +1,291 @@
import db from "@server/db";
import {
emailVerificationCodes,
passwordResetTokens,
resourceOtp,
resources,
resourceWhitelist,
targets,
userInvites,
users
} from "@server/db/schema";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { eq, sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import { z } from "zod";
import { fromZodError } from "zod-validation-error";
export default async function migration() {
console.log("Running setup script 1.0.0-beta.9...");
// make dir config/db/backups
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const backupsDir = path.join(dbDir, "backups");
// check if the backups directory exists and create it if it doesn't
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
await db.transaction(async (trx) => {
try {
// Determine which config file exists
const filePaths = [configFilePath1, configFilePath2];
let filePath = "";
for (const path of filePaths) {
if (fs.existsSync(path)) {
filePath = path;
break;
}
}
if (!filePath) {
throw new Error(
`No config file found (expected config.yml or config.yaml).`
);
}
// Read and parse the YAML file
let rawConfig: any;
const fileContents = fs.readFileSync(filePath, "utf8");
rawConfig = yaml.load(fileContents);
rawConfig.server.resource_session_request_param =
"p_session_request";
rawConfig.server.session_cookie_name = "p_session_token"; // rename to prevent conflicts
delete rawConfig.server.resource_session_cookie_name;
if (!rawConfig.flags) {
rawConfig.flags = {};
}
rawConfig.flags.allow_raw_resources = true;
// Write the updated YAML back to the file
const updatedYaml = yaml.dump(rawConfig);
fs.writeFileSync(filePath, updatedYaml, "utf8");
} catch (e) {
console.log(
`Failed to add resource_session_request_param to config. Please add it manually. https://docs.fossorial.io/Pangolin/Configuration/config`
);
trx.rollback();
return;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
// Define schema for traefik config validation
const schema = z.object({
entryPoints: z
.object({
websecure: z
.object({
address: z.string(),
transport: z
.object({
respondingTimeouts: z.object({
readTimeout: z.string()
})
})
.optional()
})
.optional()
})
.optional(),
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
let parsedConfig: any = schema.safeParse(traefikConfig);
if (parsedConfig.success) {
// Ensure websecure entrypoint exists
if (traefikConfig.entryPoints?.websecure) {
// Add transport configuration
traefikConfig.entryPoints.websecure.transport = {
respondingTimeouts: {
readTimeout: "30m"
}
};
}
traefikConfig.experimental.plugins.badger.version =
"v1.0.0-beta.3";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log("Updated Badger version in Traefik config.");
} else {
console.log(fromZodError(parsedConfig.error));
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
);
}
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually to at least v1.0.0-beta.3. https://github.com/fosrl/badger"
);
trx.rollback();
return;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"dynamic_config.yml"
);
const schema = z.object({
http: z.object({
middlewares: z.object({
"redirect-to-https": z.object({
redirectScheme: z.object({
scheme: z.string(),
permanent: z.boolean()
})
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
let parsedConfig: any = schema.safeParse(traefikConfig);
if (parsedConfig.success) {
// delete permanent from redirect-to-https middleware
delete traefikConfig.http.middlewares["redirect-to-https"].redirectScheme.permanent;
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log("Deleted permanent from redirect-to-https middleware.");
} else {
console.log(fromZodError(parsedConfig.error));
console.log(
"We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually."
);
}
} catch (e) {
console.log(
"We were unable to delete the permanent field from the redirect-to-https middleware in your Traefik configuration. Please delete it manually. Note that this is not a critical change but recommended."
);
}
trx.run(sql`UPDATE ${users} SET email = LOWER(email);`);
trx.run(
sql`UPDATE ${emailVerificationCodes} SET email = LOWER(email);`
);
trx.run(sql`UPDATE ${passwordResetTokens} SET email = LOWER(email);`);
trx.run(sql`UPDATE ${userInvites} SET email = LOWER(email);`);
trx.run(sql`UPDATE ${resourceWhitelist} SET email = LOWER(email);`);
trx.run(sql`UPDATE ${resourceOtp} SET email = LOWER(email);`);
const resourcesAll = await trx
.select({
resourceId: resources.resourceId,
fullDomain: resources.fullDomain,
subdomain: resources.subdomain
})
.from(resources);
trx.run(`DROP INDEX resources_fullDomain_unique;`);
trx.run(`ALTER TABLE resources
DROP COLUMN fullDomain;
`);
trx.run(`ALTER TABLE resources
DROP COLUMN subdomain;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN fullDomain TEXT;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN subdomain TEXT;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN http INTEGER DEFAULT true NOT NULL;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN protocol TEXT DEFAULT 'tcp' NOT NULL;
`);
trx.run(sql`ALTER TABLE resources
ADD COLUMN proxyPort INTEGER;
`);
// write the new fullDomain and subdomain values back to the database
for (const resource of resourcesAll) {
await trx
.update(resources)
.set({
fullDomain: resource.fullDomain,
subdomain: resource.subdomain
})
.where(eq(resources.resourceId, resource.resourceId));
}
const targetsAll = await trx
.select({
targetId: targets.targetId,
method: targets.method
})
.from(targets);
trx.run(`ALTER TABLE targets
DROP COLUMN method;
`);
trx.run(`ALTER TABLE targets
DROP COLUMN protocol;
`);
trx.run(sql`ALTER TABLE targets
ADD COLUMN method TEXT;
`);
// write the new method and protocol values back to the database
for (const target of targetsAll) {
await trx
.update(targets)
.set({
method: target.method
})
.where(eq(targets.targetId, target.targetId));
}
trx.run(
sql`ALTER TABLE 'resourceSessions' ADD 'isRequestToken' integer;`
);
trx.run(
sql`ALTER TABLE 'resourceSessions' ADD 'userSessionId' text REFERENCES session(id);`
);
});
console.log("Done.");
}

View File

@@ -69,6 +69,8 @@ export async function setupServerAdmin() {
const userId = generateId(15);
await trx.update(users).set({ serverAdmin: false });
await db.insert(users).values({
userId: userId,
email: email,

Some files were not shown because too many files have changed in this diff Show More