227 Commits

Author SHA1 Message Date
René Preuß
91b0b98e85 Update bin 2022-07-07 22:32:53 +02:00
René Preuß
814eb964d5 Fork expose 2022-07-07 22:27:23 +02:00
Marcel Pociot
da82980286 Fix PHP8.1 issue in 2.2.1 2022-07-04 13:46:51 +02:00
Marcel Pociot
9f03b8e5ac Add basic auth to cwd sharing, bump version 2022-07-04 13:29:21 +02:00
Marcel Pociot
2d3b10b63d Fix regression issue and readd basic auth support (#328) 2022-07-04 13:26:21 +02:00
Marcel Pociot
5d99a0d7d8 bump 2022-03-17 12:06:23 +01:00
Marcel Pociot
ecacc69d34 Merge branch 'master' of github.com:beyondcode/expose 2022-03-17 12:05:45 +01:00
Marcel Pociot
729d1fc817 Fixes #288 2022-03-17 12:05:38 +01:00
Marcel Pociot
177088dcf5 Merge pull request #312 from beyondcode/analysis-QMG5rw
Apply fixes from StyleCI
2022-03-17 11:58:42 +01:00
StyleCI Bot
c44f14d28a Apply fixes from StyleCI 2022-03-17 10:58:36 +00:00
Marcel Pociot
e079a6320c Remove internal dashboard link for performance reasons 2022-03-17 11:58:10 +01:00
Marcel Pociot
c828c3c0d2 Bump version 2022-03-14 10:18:15 +01:00
Marcel Pociot
9aa6d5d3f9 wip 2022-03-04 17:44:48 +01:00
Marcel Pociot
119d0826e4 Merge branch 'master' of github.com:beyondcode/expose 2022-03-04 17:35:57 +01:00
Marcel Pociot
c2b0b62a8b Post connection info and user to endpoint 2022-03-04 17:35:20 +01:00
Marcel Pociot
428356badb Merge pull request #309 from beyondcode/analysis-VrJ6R3
Apply fixes from StyleCI
2022-03-04 15:53:46 +01:00
StyleCI Bot
e375d21e4c Apply fixes from StyleCI 2022-03-04 14:53:40 +00:00
Marcel Pociot
4e2eda036a Added the ability to retrieve welcome messags from APIs 2022-03-04 14:06:13 +01:00
Marcel Pociot
7fadb687cc Allow specifying local config files when starting the server 2022-03-04 12:21:44 +01:00
Marcel Pociot
3cc290998d wip 2022-03-04 12:05:28 +01:00
Marcel Pociot
117424cf0e wip 2022-02-24 12:58:39 +01:00
Marcel Pociot
83f49d49c2 Merge branch 'master' into update-cli-output 2022-02-23 17:39:03 +01:00
Marcel Pociot
26541d4af9 Merge branch 'master' of github.com:beyondcode/expose 2022-02-23 17:35:02 +01:00
Marcel Pociot
00b379c417 Use new phar updater dependency 2022-02-23 17:34:58 +01:00
Marcel Pociot
1f09672b51 Merge pull request #307 from beyondcode/analysis-9mEOD5
Apply fixes from StyleCI
2022-02-23 12:44:26 +01:00
StyleCI Bot
cd625e4840 Apply fixes from StyleCI 2022-02-23 11:44:20 +00:00
Marcel Pociot
813f742c20 Fix redirect issue when using custom domains 2022-02-23 12:43:54 +01:00
Marcel Pociot
89c9fa6742 wip 2022-02-23 12:38:42 +01:00
Markus Lilienberg
3aa4847d33 Always return '$this' in 'registerStatisticsCollector' (#292) 2022-02-12 12:08:20 +01:00
Matthieu Mota
76ce21aebb Drop php 7.4 on Docker (#300) 2022-02-12 12:07:40 +01:00
Marcel Pociot
816652e527 Remove PHP 7.4 support 2022-02-08 11:19:15 +01:00
Marcel Pociot
a199aa8576 wip 2022-02-08 11:16:57 +01:00
Marcel Pociot
92c4c2ffe1 Merge branch 'master' of github.com:beyondcode/expose 2022-02-07 13:12:27 +01:00
Marcel Pociot
9304b93775 Use Tailwind Play CDN 2022-02-07 13:12:13 +01:00
Marcel Pociot
f2793bcef9 Run tests for 8.1 2022-02-07 12:59:18 +01:00
Marcel Pociot
8b7df07b27 Merge branch 'master' of github.com:beyondcode/expose 2022-02-07 12:58:22 +01:00
Marcel Pociot
10e431cb26 PHP 8.1 compatible release 2022-02-07 12:58:07 +01:00
Sebastian Schlein
408e9e470e Update README.md 2022-02-03 12:38:01 +01:00
Marcel Pociot
9dd82bf1dc Merge pull request #294 from beyondcode/analysis-NAn06g
Apply fixes from StyleCI
2021-12-21 15:50:56 +01:00
Marcel Pociot
fd7f4ee43b Apply fixes from StyleCI 2021-12-21 14:50:50 +00:00
Marcel Pociot
22c2f090e2 Add WebHook connection callback 2021-12-21 15:50:28 +01:00
Marcel Pociot
d34f6d1300 perform connection callbacks 2021-12-21 15:37:04 +01:00
Marcel Pociot
12411c4fb5 PHP 8.1 support preparations 2021-12-16 16:41:50 +01:00
Marcel Pociot
42044f35d9 PHP 8.1 support preparations 2021-12-16 16:41:26 +01:00
Marcel Pociot
c8c47e8bf6 Allow disconnect using subdomain and server host 2021-11-26 16:37:28 +01:00
Marcel Pociot
e292b1ad3d Merge branch 'master' of github.com:beyondcode/phunnel 2021-11-10 16:47:27 +01:00
Marcel Pociot
e169a3a7c9 wip 2021-11-10 16:47:19 +01:00
Marcel Pociot
f67cc87f75 Merge pull request #286 from beyondcode/analysis-NAG0rG
Apply fixes from StyleCI
2021-11-10 16:44:09 +01:00
Marcel Pociot
70416dcb18 Apply fixes from StyleCI 2021-11-10 15:44:02 +00:00
Marcel Pociot
b9b07c9664 Add the ability to log users and their shared subdomains 2021-11-10 16:43:23 +01:00
Marcel Pociot
97993318e7 Bump version 2021-06-23 15:34:45 +02:00
Marcel Pociot
074051c4d1 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-23 15:34:18 +02:00
Marcel Pociot
5a1f3ab2ff Disable ssl verification when resolving server endpoints 2021-06-23 15:34:14 +02:00
Phil E. Taylor
3dd0148895 s/form/div (#252) 2021-06-22 13:49:07 +02:00
Marcel Pociot
d83104567d Merge pull request #247 from beyondcode/analysis-nN72Av
Apply fixes from StyleCI
2021-06-22 10:29:00 +02:00
Marcel Pociot
9b3398db8f Apply fixes from StyleCI 2021-06-22 08:28:53 +00:00
Marcel Pociot
48c759a7d9 wip 2021-06-22 10:28:32 +02:00
Marcel Pociot
71ce328eb0 wip 2021-06-22 10:19:53 +02:00
Marcel Pociot
490365fe14 try timer 2021-06-22 10:18:31 +02:00
Sebastian Schlein
7797814ebf Merge branch 'master' of github.com:beyondcode/expose into master 2021-06-22 09:05:08 +02:00
Sebastian Schlein
e553cbb957 improve server docs 2021-06-22 09:04:22 +02:00
Marcel Pociot
19afa3cdea Update README.md 2021-06-22 09:01:36 +02:00
Marcel Pociot
90acf38b08 wip 2021-06-21 23:42:43 +02:00
Marcel Pociot
1450342fcc Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-21 23:35:28 +02:00
Marcel Pociot
4cbabeaff3 wip 2021-06-21 23:35:19 +02:00
Sebastian Schlein
1947d1daab Improve packagist description 2021-06-21 23:15:30 +02:00
Marcel Pociot
8dd1254555 Update run-tests.yml 2021-06-21 23:07:14 +02:00
Marcel Pociot
147da22c0d Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-21 23:03:21 +02:00
Marcel Pociot
cacdf1e268 wip 2021-06-21 23:02:35 +02:00
Marcel Pociot
5470fe8432 Update run-tests.yml 2021-06-21 23:00:31 +02:00
Marcel Pociot
d8f482bf57 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-21 22:53:39 +02:00
Marcel Pociot
079c880fd1 wip 2021-06-21 22:53:33 +02:00
Marcel Pociot
780a25df91 Update run-tests.yml 2021-06-21 22:53:24 +02:00
Marcel Pociot
bdc4493ff8 wip 2021-06-21 22:47:09 +02:00
Marcel Pociot
cbe5c3014f wip 2021-06-21 22:45:45 +02:00
Marcel Pociot
d3151fd12b wip 2021-06-21 22:43:12 +02:00
Marcel Pociot
73d0421c88 Update run-tests.yml 2021-06-21 22:43:03 +02:00
Marcel Pociot
77e0b17151 wip 2021-06-21 22:35:21 +02:00
Marcel Pociot
136e435403 wip 2021-06-21 22:32:37 +02:00
Marcel Pociot
3a9d4fb6b6 wip 2021-06-21 22:28:52 +02:00
Marcel Pociot
c81cca6e0e Update run-tests.yml 2021-06-21 22:21:48 +02:00
Marcel Pociot
856163e267 Create run-tests.yml 2021-06-21 22:18:21 +02:00
Sebastian Schlein
c2845a3e13 Merge branch 'master' of github.com:beyondcode/expose into master 2021-06-21 21:55:07 +02:00
Sebastian Schlein
8d9500abeb improve docs 2021-06-21 21:54:46 +02:00
Marcel Pociot
bbeaa1f0f1 wip 2021-06-21 21:50:45 +02:00
Marcel Pociot
f72c0d546b wip 2021-06-21 21:31:35 +02:00
Marcel Pociot
9943132704 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-21 16:41:40 +02:00
Marcel Pociot
1a78982dcb Update build 2021-06-21 16:41:32 +02:00
Marcel Pociot
dc29623bb4 Merge pull request #246 from beyondcode/analysis-KZ9gPj
Apply fixes from StyleCI
2021-06-21 16:34:28 +02:00
Marcel Pociot
bb87ef0adf Apply fixes from StyleCI 2021-06-21 14:34:21 +00:00
Marcel Pociot
4163975022 add docs 2021-06-21 16:33:57 +02:00
Marcel Pociot
0b07c3b2a3 wip 2021-06-21 10:12:04 +02:00
Marcel Pociot
c395ec16ae wip 2021-06-21 10:09:12 +02:00
Marcel Pociot
a33aaccc84 wip 2021-06-21 10:06:18 +02:00
Marcel Pociot
dfc26570b2 wip 2021-06-20 11:44:53 +02:00
Sebastian Schlein
19b6f35c48 Add command to list available servers (#245)
* Add command to list available servers

* Apply fixes from StyleCI

Co-authored-by: Marcel Pociot <mpociot@users.noreply.github.com>
Co-authored-by: Marcel Pociot <m.pociot@gmail.com>
2021-06-18 14:51:53 +02:00
Di
7ff697a09d Updated homepage & 404 (#243) 2021-06-18 13:23:19 +02:00
Marcel Pociot
84936ae63f Remove share files command for now 2021-06-18 13:13:51 +02:00
Marcel Pociot
c8171de2d2 send expose version when sharing tcp ports 2021-06-18 13:13:14 +02:00
Marcel Pociot
520a5afb1f wip 2021-06-17 17:56:12 +02:00
Marcel Pociot
5ad9f01e55 wip 2021-06-17 15:33:37 +02:00
Marcel Pociot
4aecb04397 Merge pull request #242 from beyondcode/analysis-YjpGoA
Apply fixes from StyleCI
2021-06-17 13:18:15 +02:00
Marcel Pociot
d22e63701f Apply fixes from StyleCI 2021-06-17 11:18:08 +00:00
Marcel Pociot
ddf99cd7c8 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-17 13:17:39 +02:00
Marcel Pociot
19606a78af wip 2021-06-17 13:17:32 +02:00
Marcel Pociot
7742658527 Add site details api route 2021-06-17 10:59:33 +02:00
Marcel Pociot
c9cb29ed35 Pass client version to server 2021-06-16 21:00:13 +02:00
Sebastian Schlein
37349493ab improve Docker setup docs 2021-06-14 16:51:33 +02:00
Sebastian Schlein
d610705af7 Merge branch 'master' of github.com:beyondcode/expose into master 2021-06-14 16:49:35 +02:00
Sebastian Schlein
b9200e3790 improve docs in preparation for the Expose 2 launch 2021-06-14 16:49:29 +02:00
Marcel Pociot
fd66366438 Merge pull request #238 from beyondcode/analysis-1bJgkv
Apply fixes from StyleCI
2021-06-14 15:28:18 +02:00
Marcel Pociot
361f5f0b0d Apply fixes from StyleCI 2021-06-14 13:28:10 +00:00
Marcel Pociot
1d97d63d2b wip 2021-06-14 15:27:53 +02:00
Marcel Pociot
95098c180d do not follow redirects 2021-06-14 15:25:58 +02:00
Marcel Pociot
6ffd5274b3 wip 2021-06-14 13:14:20 +02:00
Marcel Pociot
7c78f7e2b1 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-14 11:59:59 +02:00
Marcel Pociot
4d80b14551 Remove subdomain reserve check from server 2021-06-14 11:59:51 +02:00
Marcel Pociot
98482a6ce2 Merge pull request #237 from beyondcode/analysis-NA9joy
Apply fixes from StyleCI
2021-06-14 11:50:44 +02:00
Marcel Pociot
c531d41e03 Apply fixes from StyleCI 2021-06-14 09:50:37 +00:00
Marcel Pociot
1f2e21548c Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-14 11:50:11 +02:00
Marcel Pociot
aa08029fc3 Remove subdomain reserve check from server 2021-06-14 11:50:03 +02:00
Marcel Pociot
afebe13f00 Update installation.md 2021-06-11 16:23:08 +02:00
Sebastian Schlein
1fdcc50d4a Merge branch 'master' of github.com:beyondcode/expose into master 2021-06-11 16:09:48 +02:00
Sebastian Schlein
c06bcb7119 Improve docs 2021-06-11 16:09:40 +02:00
Marcel Pociot
eefd74e82c Merge pull request #234 from beyondcode/analysis-M1RaKK
Apply fixes from StyleCI
2021-06-11 15:17:21 +02:00
Marcel Pociot
de9b85df49 Apply fixes from StyleCI 2021-06-11 13:17:14 +00:00
Marcel Pociot
8be8aff802 Improve subdomain detection 2021-06-11 15:16:52 +02:00
Marcel Pociot
21a9117dd6 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-11 12:54:44 +02:00
Marcel Pociot
351253cc19 wip 2021-06-11 12:54:39 +02:00
Marcel Pociot
6b9fee9326 Update TunnelMessageController.php 2021-06-10 21:10:50 +02:00
Marcel Pociot
2a439371be Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-10 10:15:59 +02:00
Marcel Pociot
fb05f23124 wip 2021-06-10 10:15:53 +02:00
r3vit
f565241740 Replace documentation link in home and 404 page with direct link to official docs beyondco.de (#222) 2021-06-08 12:58:45 +02:00
Matthieu Mota
8c5b52769e Fix docker entrypoint (#174) 2021-06-08 12:58:29 +02:00
Marcel Pociot
98ced10737 Merge pull request #226 from beyondcode/analysis-e7nkya
Apply fixes from StyleCI
2021-06-07 21:02:56 +02:00
Marcel Pociot
cf74165479 Apply fixes from StyleCI 2021-06-07 19:02:49 +00:00
Marcel Pociot
4131b6abb7 Add ability to specify auth tokens when creating new users 2021-06-07 21:02:26 +02:00
Marcel Pociot
8664d7ea80 Merge pull request #221 from beyondcode/analysis-gOJRbw
Apply fixes from StyleCI
2021-06-02 12:34:58 +02:00
Marcel Pociot
d4dbdba4c6 Apply fixes from StyleCI 2021-06-02 10:34:52 +00:00
Marcel Pociot
b9719ea420 wip 2021-06-02 12:34:43 +02:00
Marcel Pociot
c3896e0ba2 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-02 12:05:49 +02:00
Marcel Pociot
078a656a21 wip 2021-06-02 12:05:44 +02:00
Marcel Pociot
e8de146dc5 Merge pull request #220 from beyondcode/analysis-vQg6BN
Apply fixes from StyleCI
2021-06-02 11:59:13 +02:00
Marcel Pociot
8d8297cf71 Apply fixes from StyleCI 2021-06-02 09:59:06 +00:00
Marcel Pociot
01843173bc wip 2021-06-02 11:58:57 +02:00
Marcel Pociot
2c90707e28 Merge pull request #219 from beyondcode/analysis-EAlD0M
Apply fixes from StyleCI
2021-06-02 10:22:09 +02:00
Marcel Pociot
01ce0d09e3 Apply fixes from StyleCI 2021-06-02 08:22:02 +00:00
Marcel Pociot
2c8804cff3 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-02 10:21:40 +02:00
Marcel Pociot
74ac9d2d1a Custom domain support 2021-06-02 10:21:36 +02:00
Marcel Pociot
e2da5652e5 Merge pull request #218 from beyondcode/analysis-0gBW2e
Apply fixes from StyleCI
2021-06-01 21:19:44 +02:00
Marcel Pociot
6d391c9246 Apply fixes from StyleCI 2021-06-01 19:19:38 +00:00
Marcel Pociot
c23af81668 Merge branch 'master' of github.com:beyondcode/phunnel 2021-06-01 21:19:24 +02:00
Marcel Pociot
44b100b340 Added custom server host ability 2021-06-01 21:19:12 +02:00
Marcel Pociot
a83a57ca34 Merge pull request #217 from beyondcode/analysis-OM4brW
Apply fixes from StyleCI
2021-06-01 20:26:53 +02:00
Marcel Pociot
fc5ac1c53f Apply fixes from StyleCI 2021-06-01 18:26:46 +00:00
Marcel Pociot
5e54d0a80f API modifications 2021-06-01 20:26:23 +02:00
Marcel Pociot
a29874e221 Merge pull request #216 from beyondcode/analysis-pe7Rxk
Apply fixes from StyleCI
2021-05-31 16:39:44 +02:00
Marcel Pociot
6b02eafc87 Apply fixes from StyleCI 2021-05-31 14:39:37 +00:00
Marcel Pociot
9623793df5 Merge branch 'master' of github.com:beyondcode/phunnel 2021-05-31 16:39:22 +02:00
Marcel Pociot
62aa85f092 wip 2021-05-31 16:34:43 +02:00
Marcel Pociot
3de6ee1a1e Merge pull request #215 from beyondcode/analysis-vQgLjy
Apply fixes from StyleCI
2021-05-31 16:19:58 +02:00
Marcel Pociot
bbbabcebaf Apply fixes from StyleCI 2021-05-31 14:19:51 +00:00
Marcel Pociot
3c07660c2c wip 2021-05-31 16:19:43 +02:00
Marcel Pociot
21e47bde81 Merge pull request #214 from beyondcode/analysis-ajYQxW
Apply fixes from StyleCI
2021-05-31 14:48:25 +02:00
Marcel Pociot
ad2ef94958 Apply fixes from StyleCI 2021-05-31 12:48:18 +00:00
Marcel Pociot
5de11e90f7 Merge branch 'master' of github.com:beyondcode/phunnel 2021-05-31 14:47:52 +02:00
Marcel Pociot
9444d1aacb Add statistic tracking 2021-05-31 14:47:48 +02:00
Marcel Pociot
de0ada67e3 Merge pull request #212 from beyondcode/analysis-bQVQ7A
Apply fixes from StyleCI
2021-05-28 16:52:32 +02:00
Marcel Pociot
400361dd71 Apply fixes from StyleCI 2021-05-28 14:52:25 +00:00
Marcel Pociot
7f6be8cae2 Merge branch 'master' of github.com:beyondcode/phunnel 2021-05-28 16:51:58 +02:00
Marcel Pociot
a3d1735b6e Allow specifying maximum connection counts per user 2021-05-28 16:51:48 +02:00
Marcel Pociot
b44d5f9c46 Merge pull request #211 from beyondcode/analysis-lKln1Q
Apply fixes from StyleCI
2021-05-21 17:21:20 +02:00
Marcel Pociot
6c0aa790e5 Apply fixes from StyleCI 2021-05-21 15:21:13 +00:00
Marcel Pociot
717e8cf05c wip 2021-05-21 17:20:48 +02:00
Marcel Pociot
9220e83798 Merge branch 'master' of github.com:beyondcode/phunnel 2021-05-19 20:53:35 +02:00
Marcel Pociot
9e67b5ef5d Ensure that migrations are sorted properly 2021-05-19 20:52:56 +02:00
Marcel Pociot
2faacd58c5 Merge pull request #210 from beyondcode/analysis-9mYvR5
Apply fixes from StyleCI
2021-05-19 20:21:50 +02:00
Marcel Pociot
9342a5ce36 Apply fixes from StyleCI 2021-05-19 18:21:43 +00:00
Marcel Pociot
db57f83bdf Dashboard UI updates, allow multiple expose servers, exclude subdomains 2021-05-19 20:21:19 +02:00
Marcel Pociot
c1f7125f72 Get rid of deprecated method calls 2021-05-19 12:10:13 +02:00
Marcel Pociot
96fa7c653f Merge branch 'dashboard-modifications' into share-files 2021-05-19 11:57:25 +02:00
Marcel Pociot
44dca53687 Make server configurable when sharing files. Fix react/http compatibility issues 2021-05-19 11:35:27 +02:00
Marcel Pociot
60af8bce19 Merge branch 'master' into share-files 2021-05-18 12:42:28 +02:00
Marcel Pociot
60ce7816a2 wip 2021-05-18 12:39:12 +02:00
Suraj Kumar Shrestha
6e9c3503e3 fix: update port mapping (#169)
Fixes https://github.com/beyondcode/expose/issues/166
2021-03-31 07:43:15 +02:00
Dennis Koch
8d5a4410f7 feat: Config option to set default protocol to https (#110)
* feat: Config option to set default protocol to https

* style: Fix for StyleCI
2021-03-31 07:41:34 +02:00
Christian Holladay
cca03ab8b2 Update Cuzzle to 3.1 (#192) 2021-03-25 22:05:32 +01:00
Marcel Pociot
a9699eb254 Use config auth when sharing TCP ports 2021-03-05 09:14:03 +01:00
Marcel Pociot
21ed707718 Merge pull request #190 from beyondcode/analysis-0g9RRM
Apply fixes from StyleCI
2021-03-02 16:40:23 +01:00
Marcel Pociot
9687c92463 Apply fixes from StyleCI 2021-03-02 15:40:15 +00:00
Marcel Pociot
5c141986fe Allow overriding DNS server. Set DNS if internal docker host is being shared 2021-03-02 16:39:51 +01:00
Marcel Pociot
986428fe00 Add PHP 8 compatible build 2021-01-14 13:23:45 +01:00
Marcel Pociot
2934731c7a Add PHP8 compatibility to v1.x (#177)
* Add PHP8 compatible requirements
* Readd DNS to allow resolving local shared domains
2021-01-14 13:20:59 +01:00
Marcel Pociot
e2e9edf769 Fix tests 2021-01-08 21:35:00 +01:00
Vaggelis Yfantis
06c1758916 PHP 8.0 Support (#163)
* PHP v8 Support

* move namshi/cuzzle to octoper/cuzzle

* build expose binary
2021-01-08 21:29:14 +01:00
Marcel Pociot
ff232d9ef4 Merge branch '1.0' of github.com:beyondcode/phunnel into 1.0 2020-12-07 23:31:08 +01:00
Marcel Pociot
5b8cc4d985 Allow specifying server host and port in share command 2020-12-07 23:30:57 +01:00
Marcel Pociot
26a805c552 Merge pull request #164 from beyondcode/analysis-gOmLGw
Apply fixes from StyleCI
2020-12-05 00:34:31 +01:00
Marcel Pociot
28cc353c30 Apply fixes from StyleCI 2020-12-04 23:34:24 +00:00
Marcel Pociot
7bfb618d93 wip 2020-12-05 00:34:01 +01:00
Siebe Vanden Eynden
f6d04777e1 Allow custom config file path (#145)
* allow custom config file path

* Update configuration.md
2020-12-04 22:45:29 +01:00
Tii
bded9f754e Added command line options for server-host and server-port (#147)
* Added server options

* Restored box.json

* Reverted build and versioning...

* Please the style gods
2020-12-04 22:44:25 +01:00
Tii
c92d4b258c Removed fixed IP address for DNS (#148) 2020-12-04 22:39:57 +01:00
Marcel Pociot
eb8d1f4f91 Merge pull request #154 from beyondcode/analysis-5ZodwW
Apply fixes from StyleCI
2020-11-01 20:34:40 +01:00
Marcel Pociot
da39fb8ad8 Apply fixes from StyleCI 2020-11-01 19:34:33 +00:00
Marcel Pociot
5b7a80bb0c Merge branch 'master' of github.com:beyondcode/phunnel 2020-11-01 20:34:21 +01:00
Marcel Pociot
548c29772a Merge branch 'share-files' of github.com:beyondcode/phunnel into share-files 2020-11-01 20:34:06 +01:00
Marcel Pociot
844a3cd15a Rename page title 2020-11-01 20:33:56 +01:00
Marcel Pociot
f5c009eadd Merge pull request #153 from beyondcode/analysis-7ao7E3
Apply fixes from StyleCI
2020-11-01 17:47:36 +01:00
Marcel Pociot
7459c0189b Apply fixes from StyleCI 2020-11-01 16:47:29 +00:00
Merkhad Luigton
8b8426cd3b make expose directly executable in the docker container (#149) 2020-11-01 17:47:21 +01:00
Marcel Pociot
e773dfa689 Merge pull request #151 from beyondcode/analysis-64ov5W
Apply fixes from StyleCI
2020-11-01 17:44:14 +01:00
Marcel Pociot
c56f05c030 Apply fixes from StyleCI 2020-11-01 16:44:06 +00:00
Marcel Pociot
ce945e1326 Add fileserver support 2020-11-01 17:43:42 +01:00
Marcel Pociot
880259657f Rewrite location header 2020-10-25 23:40:45 +01:00
Marcel Pociot
538c7da446 Better memory management for binary responses. Fixes #140 2020-10-17 21:07:29 +02:00
Marcel Pociot
26de32d375 Allow users to reserve subdomains (#131) 2020-09-09 21:57:42 +02:00
Marcel Pociot
2f57fa1952 Add ability to expose TCP connections (#123) 2020-09-08 16:27:39 +02:00
Marcel Pociot
c8cfe7b8b4 Merge pull request #121 from beyondcode/add_flag_to_users_to_allow_custom_subdomains
Add a new flag to users to allow the specification of custom subdomains
2020-09-07 20:27:03 +02:00
Marcel Pociot
ab316f6bdc Merge pull request #117 from beyondcode/dependabot/composer/symfony/http-kernel-5.1.5
Bump symfony/http-kernel from 5.1.2 to 5.1.5
2020-09-07 20:24:17 +02:00
Marcel Pociot
b071e81b1d Merge pull request #120 from beyondcode/analysis-NAg54v
Apply fixes from StyleCI
2020-09-07 20:20:20 +02:00
Marcel Pociot
d9ab55f308 Apply fixes from StyleCI 2020-09-07 18:20:13 +00:00
Marcel Pociot
0ebe6a4ce4 Add a new flag to users to allow the specification of custom subdomains 2020-09-07 20:19:56 +02:00
Marcel Pociot
faa3309c70 Merge pull request #119 from beyondcode/associate-sites-with-auth-tokens
Associate shared sites with auth tokens
2020-09-07 13:55:52 +02:00
dependabot[bot]
74236b6863 Bump symfony/http-kernel from 5.1.2 to 5.1.5
Bumps [symfony/http-kernel](https://github.com/symfony/http-kernel) from 5.1.2 to 5.1.5.
- [Release notes](https://github.com/symfony/http-kernel/releases)
- [Changelog](https://github.com/symfony/http-kernel/blob/master/CHANGELOG.md)
- [Commits](https://github.com/symfony/http-kernel/compare/v5.1.2...v5.1.5)

Signed-off-by: dependabot[bot] <support@github.com>
2020-09-04 14:30:06 +00:00
Marcel Pociot
b1d23e1f75 Merge pull request #112 from leemcd56/master
Removed padraic/phar-updater
2020-09-04 16:26:20 +02:00
Nathanael McDaniel
e52659bf59 Removed padraic/phar-updater 2020-08-26 14:47:01 -05:00
138 changed files with 6433 additions and 9183 deletions

36
.github/workflows/run-tests.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Tests
on: [push, pull_request]
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest]
php: [8.0, 8.1]
stability: [prefer-stable]
name: P${{ matrix.php }} - ${{ matrix.stability }} - ${{ matrix.os }}
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo
coverage: none
- name: Setup problem matchers
run: |
echo "::add-matcher::${{ runner.tool_cache }}/php.json"
echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json"
- name: Install dependencies
run: composer update --${{ matrix.stability }} --prefer-dist --no-interaction
- name: Execute tests
run: vendor/bin/phpunit

View File

@@ -1,4 +1,4 @@
FROM php:7.4-cli
FROM php:8.0-cli
RUN apt-get update
RUN apt-get install -y git libzip-dev zip
@@ -20,4 +20,6 @@ ENV username=username
ENV password=password
ENV exposeConfigPath=/src/config/expose.php
CMD sed -i "s|username|${username}|g" ${exposeConfigPath} && sed -i "s|password|${password}|g" ${exposeConfigPath} && php expose serve ${domain} --port ${port} --validateAuthTokens
COPY docker-entrypoint.sh /usr/bin/
RUN chmod 755 /usr/bin/docker-entrypoint.sh
ENTRYPOINT ["docker-entrypoint.sh"]

View File

@@ -1,4 +1,4 @@
![](https://beyondco.de/img/docs/expose/img/card.png)
![](https://expose.dev/images/expose/og_card.png)
# Expose
@@ -6,11 +6,17 @@
[![Quality Score](https://img.shields.io/scrutinizer/g/beyondcode/expose.svg?style=flat-square)](https://scrutinizer-ci.com/g/beyondcode/expose)
[![Total Downloads](https://img.shields.io/packagist/dt/beyondcode/expose.svg?style=flat-square)](https://packagist.org/packages/beyondcode/expose)
A completely open-source ngrok alternative - written in pure PHP.
An open-source ngrok alternative - written in PHP.
## ⭐️ Managed Expose & Expose Pro ⭐️
You can use a managed version with our proprietary platform and our free (EU) test server at the [official website](https://expose.dev). Upgrade to Expose Pro to use our global server network with your own custom domains and get high-speed tunnels all over the world.
[Create an account](https://expose.dev)
## Documentation
For installation instructions, in-depth usage and deployment details, please take a look at the [official documentation](https://beyondco.de/docs/expose/).
For installation instructions of your own server, in-depth usage and deployment details, please take a look at the [official documentation](https://expose.dev/docs).
### Security

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Client\Callbacks;
use App\Server\Connections\ControlConnection;
use Clue\React\Buzz\Browser;
class WebHookConnectionCallback
{
/** @var Browser */
protected $browser;
public function __construct(Browser $browser)
{
$this->browser = $browser;
}
public function handle(ControlConnection $connection)
{
$this->browser->post(config('expose.admin.connection_callbacks.webhook.url'), [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
'X-Signature' => $this->generateWebhookSigningSecret($connection),
], json_encode($connection->toArray()));
}
protected function generateWebhookSigningSecret(ControlConnection $connection)
{
return hash_hmac('sha256', $connection->client_id, config('expose.admin.connection_callbacks.webhook.secret'));
}
}

View File

@@ -31,6 +31,10 @@ class Client
/** @var int */
protected $timeConnected = 0;
/** @var bool */
protected $shouldExit = true;
public static $user = [];
public static $subdomains = [];
public function __construct(LoopInterface $loop, Configuration $configuration, CliRequestLogger $logger)
@@ -40,15 +44,25 @@ class Client
$this->logger = $logger;
}
public function share(string $sharedUrl, array $subdomains = [])
public function shouldExit($shouldExit = true)
{
$this->shouldExit = $shouldExit;
}
public function share(string $sharedUrl, array $subdomains = [], $serverHost = null)
{
$sharedUrl = $this->prepareSharedUrl($sharedUrl);
foreach ($subdomains as $subdomain) {
$this->connectToServer($sharedUrl, $subdomain, config('expose.auth_token'));
$this->connectToServer($sharedUrl, $subdomain, $serverHost, $this->configuration->auth());
}
}
public function sharePort(int $port)
{
$this->connectToServerAndShareTcp($port, $this->configuration->auth());
}
protected function prepareSharedUrl(string $sharedUrl): string
{
if (! $parsedUrl = parse_url($sharedUrl)) {
@@ -67,34 +81,34 @@ class Client
return $url;
}
public function connectToServer(string $sharedUrl, $subdomain, $authToken = ''): PromiseInterface
public function connectToServer(string $sharedUrl, $subdomain, $serverHost = null, $authToken = ''): PromiseInterface
{
$deferred = new Deferred();
$promise = $deferred->promise();
$exposeVersion = config('app.version');
$wsProtocol = $this->configuration->port() === 443 ? 'wss' : 'ws';
connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}", [], [
connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}&version={$exposeVersion}", [], [
'X-Expose-Control' => 'enabled',
], $this->loop)
->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $deferred, $authToken) {
->then(function (WebSocket $clientConnection) use ($sharedUrl, $subdomain, $serverHost, $deferred, $authToken) {
$this->connectionRetries = 0;
$connection = ControlConnection::create($clientConnection);
$connection->authenticate($sharedUrl, $subdomain);
$connection->authenticate($sharedUrl, $subdomain, $serverHost);
$clientConnection->on('close', function () use ($sharedUrl, $subdomain, $authToken) {
$clientConnection->on('close', function () use ($sharedUrl, $subdomain, $serverHost, $authToken) {
$this->logger->error('Connection to server closed.');
$this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken);
$this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $serverHost, $authToken) {
$this->connectToServer($sharedUrl, $subdomain, $serverHost, $authToken);
});
});
$connection->on('authenticationFailed', function ($data) use ($deferred) {
$this->logger->error($data->message);
$this->exit($deferred);
});
$this->attachCommonConnectionListeners($connection, $deferred);
$connection->on('subdomainTaken', function ($data) use ($deferred) {
$this->logger->error($data->message);
@@ -102,41 +116,32 @@ class Client
$this->exit($deferred);
});
$connection->on('setMaximumConnectionLength', function ($data) {
$timeoutSection = $this->logger->getOutput()->section();
$this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) {
$this->timeConnected++;
$secondsRemaining = $data->length * 60 - $this->timeConnected;
$remaining = Carbon::now()->diff(Carbon::now()->addSeconds($secondsRemaining));
$timeoutSection->clear();
$timeoutSection->writeln('Remaining time: '.$remaining->format('%H:%I:%S'));
});
});
$connection->on('authenticated', function ($data) use ($deferred, $sharedUrl) {
$httpProtocol = $this->configuration->port() === 443 ? 'https' : 'http';
$host = $this->configuration->host();
if ($httpProtocol !== 'https') {
$host .= ":{$this->configuration->port()}";
}
$httpPort = $httpProtocol === 'https' ? '' : ":{$this->configuration->port()}";
$host = $data->server_host ?? $this->configuration->host();
$this->configuration->setServerHost($host);
$this->logger->info($data->message);
$this->logger->info("Local-URL:\t\t{$sharedUrl}");
$this->logger->info("Dashboard-URL:\t\thttp://127.0.0.1:".config()->get('expose.dashboard_port'));
$this->logger->info("Expose-URL:\t\t{$httpProtocol}://{$data->subdomain}.{$host}");
$this->logger->info("Shared URL:\t\t<options=bold>{$sharedUrl}</>");
$this->logger->info("Dashboard:\t\t<options=bold>http://127.0.0.1:".config()->get('expose.dashboard_port').'</>');
$this->logger->info("Public HTTP:\t\t<options=bold>http://{$data->subdomain}.{$host}{$httpPort}</>");
$this->logger->info("Public HTTPS:\t\t<options=bold>https://{$data->subdomain}.{$host}</>");
$this->logger->line('');
static::$subdomains[] = "{$httpProtocol}://{$data->subdomain}.{$host}";
static::$user = $data->user ?? ['can_specify_subdomains' => 0];
$deferred->resolve($data);
});
}, function (\Exception $e) use ($deferred, $sharedUrl, $subdomain, $authToken) {
if ($this->connectionRetries > 0) {
$this->retryConnectionOrExit($sharedUrl, $subdomain, $authToken);
$this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $authToken) {
$this->connectToServer($sharedUrl, $subdomain, $authToken);
});
return;
}
@@ -149,24 +154,117 @@ class Client
return $promise;
}
public function connectToServerAndShareTcp(int $port, $authToken = ''): PromiseInterface
{
$deferred = new Deferred();
$promise = $deferred->promise();
$wsProtocol = $this->configuration->port() === 443 ? 'wss' : 'ws';
$exposeVersion = config('app.version');
connect($wsProtocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control?authToken={$authToken}&version={$exposeVersion}", [], [
'X-Expose-Control' => 'enabled',
], $this->loop)
->then(function (WebSocket $clientConnection) use ($port, $deferred, $authToken) {
$this->connectionRetries = 0;
$connection = ControlConnection::create($clientConnection);
$connection->authenticateTcp($port);
$this->attachCommonConnectionListeners($connection, $deferred);
$clientConnection->on('close', function () use ($port, $authToken) {
$this->logger->error('Connection to server closed.');
$this->retryConnectionOrExit(function () use ($port, $authToken) {
$this->connectToServerAndShareTcp($port, $authToken);
});
});
$connection->on('authenticated', function ($data) use ($deferred, $port) {
$host = $this->configuration->host();
$this->logger->info($data->message);
$this->logger->info("Local-Port:\t\t<options=bold>{$port}</>");
$this->logger->info("Shared-Port:\t\t<options=bold>{$data->shared_port}</>");
$this->logger->info("Expose-URL:\t\t<options=bold>tcp://{$host}:{$data->shared_port}</>");
$this->logger->line('');
$deferred->resolve($data);
});
}, function (\Exception $e) use ($deferred, $port, $authToken) {
if ($this->connectionRetries > 0) {
$this->retryConnectionOrExit(function () use ($port, $authToken) {
$this->connectToServerAndShareTcp($port, $authToken);
});
return;
}
$this->logger->error('Could not connect to the server.');
$this->logger->error($e->getMessage());
$this->exit($deferred);
});
return $promise;
}
protected function attachCommonConnectionListeners(ControlConnection $connection, Deferred $deferred)
{
$connection->on('info', function ($data) {
$this->logger->info($data->message);
});
$connection->on('warning', function ($data) {
$this->logger->warn($data->message);
});
$connection->on('error', function ($data) {
$this->logger->error($data->message);
});
$connection->on('authenticationFailed', function ($data) use ($deferred) {
$this->logger->error($data->message);
$this->exit($deferred);
});
$connection->on('setMaximumConnectionLength', function ($data) {
$timeoutSection = $this->logger->getOutput()->section();
$this->loop->addPeriodicTimer(1, function () use ($data, $timeoutSection) {
$this->timeConnected++;
$secondsRemaining = $data->length * 60 - $this->timeConnected;
$remaining = Carbon::now()->diff(Carbon::now()->addSeconds($secondsRemaining));
$timeoutSection->clear();
$timeoutSection->writeln('Remaining time: '.$remaining->format('%H:%I:%S'));
});
});
}
protected function exit(Deferred $deferred)
{
$deferred->reject();
$this->loop->futureTick(function () {
exit(1);
if ($this->shouldExit) {
exit(1);
}
});
}
protected function retryConnectionOrExit(string $sharedUrl, $subdomain, $authToken = '')
protected function retryConnectionOrExit(callable $retry)
{
$this->connectionRetries++;
if ($this->connectionRetries <= static::MAX_CONNECTION_RETRIES) {
$this->loop->addTimer($this->connectionRetries, function () use ($sharedUrl, $subdomain, $authToken) {
$this->loop->addTimer($this->connectionRetries, function () use ($retry) {
$this->logger->info("Retrying connection ({$this->connectionRetries}/".static::MAX_CONNECTION_RETRIES.')');
$this->connectToServer($sharedUrl, $subdomain, $authToken);
$retry();
});
} else {
exit(1);

View File

@@ -7,19 +7,27 @@ class Configuration
/** @var string */
protected $host;
/** @var string */
protected $serverHost;
/** @var int */
protected $port;
/** @var string|null */
protected $auth;
public function __construct(string $host, int $port, ?string $auth = null)
/** @var string|null */
protected $basicAuth;
public function __construct(string $host, int $port, ?string $auth = null, ?string $basicAuth = null)
{
$this->host = $host;
$this->serverHost = $this->host = $host;
$this->port = $port;
$this->auth = $auth;
$this->basicAuth = $basicAuth;
}
public function host(): string
@@ -27,13 +35,40 @@ class Configuration
return $this->host;
}
public function serverHost(): string
{
return $this->serverHost;
}
public function setServerHost($serverHost)
{
$this->serverHost = $serverHost;
}
public function auth(): ?string
{
return $this->auth;
}
public function basicAuth(): ?string
{
return $this->basicAuth;
}
public function port(): int
{
return intval($this->port);
}
public function getUrl(string $subdomain): string
{
$httpProtocol = $this->port() === 443 ? 'https' : 'http';
$host = $this->serverHost();
if ($httpProtocol !== 'https') {
$host .= ":{$this->port()}";
}
return "{$subdomain}.{$host}";
}
}

View File

@@ -52,17 +52,35 @@ class ControlConnection
$this->proxyManager->createProxy($this->clientId, $data);
}
public function authenticate(string $sharedHost, string $subdomain)
public function createTcpProxy($data)
{
$this->proxyManager->createTcpProxy($this->clientId, $data);
}
public function authenticate(string $sharedHost, string $subdomain, $serverHost = null)
{
$this->socket->send(json_encode([
'event' => 'authenticate',
'data' => [
'type' => 'http',
'host' => $sharedHost,
'server_host' => $serverHost,
'subdomain' => empty($subdomain) ? null : $subdomain,
],
]));
}
public function authenticateTcp(int $port)
{
$this->socket->send(json_encode([
'event' => 'authenticate',
'data' => [
'type' => 'tcp',
'port' => $port,
],
]));
}
public function ping()
{
$this->socket->send(json_encode([

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Client\Exceptions;
class InvalidServerProvided extends \Exception
{
public function __construct($server)
{
$message = "No such server {$server}.";
parent::__construct($message);
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Client;
use App\Client\Fileserver\Fileserver;
use App\Client\Http\Controllers\AttachDataToLogController;
use App\Client\Http\Controllers\ClearLogsController;
use App\Client\Http\Controllers\CreateTunnelController;
@@ -27,12 +28,18 @@ class Factory
/** @var string */
protected $auth = '';
/** @var string */
protected $basicAuth;
/** @var \React\EventLoop\LoopInterface */
protected $loop;
/** @var App */
protected $app;
/** @var Fileserver */
protected $fileserver;
/** @var RouteGenerator */
protected $router;
@@ -63,6 +70,13 @@ class Factory
return $this;
}
public function setBasicAuth(?string $basicAuth)
{
$this->basicAuth = $basicAuth;
return $this;
}
public function setLoop(LoopInterface $loop)
{
$this->loop = $loop;
@@ -73,7 +87,7 @@ class Factory
protected function bindConfiguration()
{
app()->singleton(Configuration::class, function ($app) {
return new Configuration($this->host, $this->port, $this->auth);
return new Configuration($this->host, $this->port, $this->auth, $this->basicAuth);
});
}
@@ -102,9 +116,25 @@ class Factory
return $this;
}
public function share($sharedUrl, $subdomain = null)
public function share($sharedUrl, $subdomain = null, $serverHost = null)
{
app('expose.client')->share($sharedUrl, $subdomain);
app('expose.client')->share($sharedUrl, $subdomain, $serverHost);
return $this;
}
public function sharePort(int $port)
{
app('expose.client')->sharePort($port);
return $this;
}
public function shareFolder(string $folder, string $name, $subdomain = null, $serverHost = null)
{
$host = $this->createFileServer($folder, $name);
$this->share($host, $subdomain, $serverHost);
return $this;
}
@@ -120,40 +150,54 @@ class Factory
$this->router->post('/api/logs/{request_id}/data', AttachDataToLogController::class);
$this->router->get('/api/logs/clear', ClearLogsController::class);
$this->app->route('/socket', new WsServer(new Socket()), ['*']);
$this->app->route('/socket', new WsServer(new Socket()), ['*'], '');
foreach ($this->router->getRoutes()->all() as $name => $route) {
$this->app->routes->add($name, $route);
}
}
protected function detectNextFreeDashboardPort($port = 4040): int
protected function detectNextAvailablePort($startPort = 4040): int
{
while (is_resource(@fsockopen('127.0.0.1', $port))) {
$port++;
while (is_resource(@fsockopen('127.0.0.1', $startPort))) {
$startPort++;
}
return $port;
return $startPort;
}
public function createHttpServer()
{
$dashboardPort = $this->detectNextFreeDashboardPort();
$dashboardPort = $this->detectNextAvailablePort();
config()->set('expose.dashboard_port', $dashboardPort);
$this->app = new App('127.0.0.1', $dashboardPort, '0.0.0.0', $this->loop);
$this->app = new App('0.0.0.0', $dashboardPort, '0.0.0.0', $this->loop);
$this->addRoutes();
return $this;
}
public function createFileServer(string $folder, string $name)
{
$port = $this->detectNextAvailablePort(8090);
$this->fileserver = new Fileserver($folder, $name, $port, '0.0.0.0', $this->loop);
return "127.0.0.1:{$port}";
}
public function getApp(): App
{
return $this->app;
}
public function getFileserver(): Fileserver
{
return $this->fileserver;
}
public function run()
{
$this->loop->run();

View File

@@ -0,0 +1,136 @@
<?php
namespace App\Client\Fileserver;
use App\Http\Controllers\Concerns\LoadsViews;
use App\Http\QueryParameters;
use GuzzleHttp\Psr7\ServerRequest;
use Illuminate\Http\Request;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\Http\Message\Response;
use React\Stream\ReadableResourceStream;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\Iterator\FilenameFilterIterator;
class ConnectionHandler
{
use LoadsViews;
/** @var string */
protected $rootFolder;
/** @var string */
protected $name;
/** @var LoopInterface */
protected $loop;
public function __construct(string $rootFolder, string $name, LoopInterface $loop)
{
$this->rootFolder = $rootFolder;
$this->name = $name;
$this->loop = $loop;
}
public function handle(ServerRequestInterface $request)
{
$request = $this->createLaravelRequest($request);
$targetPath = realpath($this->rootFolder.DIRECTORY_SEPARATOR.$request->path());
if (! $this->isValidTarget($targetPath)) {
return new Response(404);
}
if (is_dir($targetPath)) {
// Directory listing
$directoryContent = Finder::create()
->depth(0)
->sort(function ($a, $b) {
return strcmp(strtolower($a->getRealpath()), strtolower($b->getRealpath()));
})
->in($targetPath);
if ($this->name !== '') {
$directoryContent->name($this->name);
}
$parentPath = explode('/', $request->path());
array_pop($parentPath);
$parentPath = implode('/', $parentPath);
return new Response(
200,
['Content-Type' => 'text/html'],
$this->getView(null, 'client.fileserver', [
'currentPath' => $request->path(),
'parentPath' => $parentPath,
'directory' => $targetPath,
'directoryContent' => $directoryContent,
])
);
}
if (is_file($targetPath)) {
return new Response(
200,
['Content-Type' => mime_content_type($targetPath)],
new ReadableResourceStream(fopen($targetPath, 'r'), $this->loop)
);
}
}
protected function isValidTarget(string $targetPath): bool
{
if (! file_exists($targetPath)) {
return false;
}
if ($this->name !== '') {
$filter = new class(basename($targetPath), [$this->name]) extends FilenameFilterIterator
{
protected $filename;
public function __construct(string $filename, array $matchPatterns)
{
$this->filename = $filename;
foreach ($matchPatterns as $pattern) {
$this->matchRegexps[] = $this->toRegex($pattern);
}
}
public function accept()
{
return $this->isAccepted($this->filename);
}
};
return $filter->accept();
}
return true;
}
protected function createLaravelRequest(ServerRequestInterface $request): Request
{
try {
parse_str($request->getBody(), $bodyParameters);
} catch (\Throwable $e) {
$bodyParameters = [];
}
$serverRequest = (new ServerRequest(
$request->getMethod(),
$request->getUri(),
$request->getHeaders(),
$request->getBody(),
$request->getProtocolVersion(),
))
->withQueryParams(QueryParameters::create($request)->all())
->withParsedBody($bodyParameters);
return Request::createFromBase((new HttpFoundationFactory)->createRequest($serverRequest));
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Client\Fileserver;
use Psr\Http\Message\ServerRequestInterface;
use React\EventLoop\LoopInterface;
use React\Http\Server;
use React\Socket\Server as SocketServer;
class Fileserver
{
/** @var SocketServer */
protected $socket;
public function __construct($rootFolder, $name, $port, $address, LoopInterface $loop)
{
$server = new Server($loop, function (ServerRequestInterface $request) use ($rootFolder, $name, $loop) {
return (new ConnectionHandler($rootFolder, $name, $loop))->handle($request);
});
$this->socket = new SocketServer("{$address}:{$port}", $loop);
$server->listen($this->socket);
}
public function getSocket(): SocketServer
{
return $this->socket;
}
}

View File

@@ -4,8 +4,8 @@ namespace App\Client\Http\Controllers;
use App\Http\Controllers\Controller;
use App\Logger\RequestLogger;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
@@ -28,11 +28,11 @@ class AttachDataToLogController extends Controller
$this->requestLogger->pushLoggedRequest($loggedRequest);
$httpConnection->send(str(new Response(200)));
$httpConnection->send(Message::toString(new Response(200)));
return;
}
$httpConnection->send(str(new Response(404)));
$httpConnection->send(Message::toString(new Response(404)));
}
}

View File

@@ -3,8 +3,8 @@
namespace App\Client\Http\Controllers;
use App\Http\Controllers\Controller;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
@@ -20,7 +20,7 @@ class CreateTunnelController extends Controller
$httpConnection->send(respond_json($data));
$httpConnection->close();
}, function () use ($httpConnection) {
$httpConnection->send(str(new Response(500)));
$httpConnection->send(Message::toString(new Response(500)));
$httpConnection->close();
});
}

View File

@@ -12,6 +12,7 @@ class DashboardController extends Controller
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$httpConnection->send(respond_html($this->getView($httpConnection, 'client.dashboard', [
'user' => Client::$user,
'subdomains' => Client::$subdomains,
'max_logs'=> config()->get('expose.max_logged_requests', 10),
])));

View File

@@ -5,8 +5,8 @@ namespace App\Client\Http\Controllers;
use App\Http\Controllers\Controller;
use App\WebSockets\Socket;
use Exception;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
@@ -23,9 +23,9 @@ class PushLogsToDashboardController extends Controller
$webSocketConnection->send($request->getContent());
}
$httpConnection->send(str(new Response(200)));
$httpConnection->send(Message::toString(new Response(200)));
} catch (Exception $e) {
$httpConnection->send(str(new Response(500, [], $e->getMessage())));
$httpConnection->send(Message::toString(new Response(500, [], $e->getMessage())));
}
}
}

View File

@@ -5,8 +5,8 @@ namespace App\Client\Http\Controllers;
use App\Client\Http\HttpClient;
use App\Http\Controllers\Controller;
use App\Logger\RequestLogger;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
@@ -29,13 +29,15 @@ class ReplayLogController extends Controller
$loggedRequest = $this->requestLogger->findLoggedRequest($request->get('log'));
if (is_null($loggedRequest)) {
$httpConnection->send(str(new Response(404)));
$httpConnection->send(Message::toString(new Response(404)));
return;
}
$this->httpClient->performRequest($loggedRequest->getRequestData());
$loggedRequest->refreshId();
$httpConnection->send(str(new Response(200)));
$this->httpClient->performRequest($loggedRequest->getRequest()->toString());
$httpConnection->send(Message::toString(new Response(200)));
}
}

View File

@@ -2,11 +2,12 @@
namespace App\Client\Http;
use App\Client\Configuration;
use App\Client\Http\Modifiers\CheckBasicAuthentication;
use App\Logger\RequestLogger;
use Clue\React\Buzz\Browser;
use GuzzleHttp\Psr7\Message;
use function GuzzleHttp\Psr7\parse_request;
use function GuzzleHttp\Psr7\str;
use Laminas\Http\Request;
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
@@ -26,19 +27,26 @@ class HttpClient
/** @var Request */
protected $request;
protected $connectionData;
/** @var array */
protected $modifiers = [
CheckBasicAuthentication::class,
];
/** @var Configuration */
protected $configuration;
public function __construct(LoopInterface $loop, RequestLogger $logger)
public function __construct(LoopInterface $loop, RequestLogger $logger, Configuration $configuration)
{
$this->loop = $loop;
$this->logger = $logger;
$this->configuration = $configuration;
}
public function performRequest(string $requestData, WebSocket $proxyConnection = null, string $requestId = null)
public function performRequest(string $requestData, WebSocket $proxyConnection = null, $connectionData = null)
{
$this->connectionData = $connectionData;
$this->request = $this->parseRequest($requestData);
$this->logger->logRequest($requestData, $this->request);
@@ -66,7 +74,7 @@ class HttpClient
protected function createConnector(): Connector
{
return new Connector($this->loop, [
'dns' => '127.0.0.1',
'dns' => config('expose.dns', '127.0.0.1'),
'tls' => [
'verify_peer' => false,
'verify_peer_name' => false,
@@ -77,15 +85,19 @@ class HttpClient
protected function sendRequestToApplication(RequestInterface $request, $proxyConnection = null)
{
(new Browser($this->loop, $this->createConnector()))
->withOptions([
'followRedirects' => false,
'obeySuccessCode' => false,
'streaming' => true,
])
->send($request)
->withFollowRedirects(false)
->withRejectErrorResponse(false)
->requestStreaming(
$request->getMethod(),
$request->getUri(),
$request->getHeaders(),
$request->getBody()
)
->then(function (ResponseInterface $response) use ($proxyConnection) {
if (! isset($response->buffer)) {
$response->buffer = str($response);
$response = $this->rewriteResponseHeaders($response);
$response->buffer = Message::toString($response);
}
$this->sendChunkToServer($response->buffer, $proxyConnection);
@@ -93,7 +105,7 @@ class HttpClient
/* @var $body \React\Stream\ReadableStreamInterface */
$body = $response->getBody();
$this->logResponse(str($response));
$this->logResponse(Message::toString($response));
$body->on('data', function ($chunk) use ($proxyConnection, $response) {
$response->buffer .= $chunk;
@@ -126,4 +138,25 @@ class HttpClient
{
return Request::fromString($data);
}
protected function rewriteResponseHeaders(ResponseInterface $response)
{
if (! $response->hasHeader('Location')) {
return $response;
}
$location = $response->getHeaderLine('Location');
if (! strstr($location, $this->connectionData->host)) {
return $response;
}
$location = str_replace(
$this->connectionData->host,
$this->configuration->getUrl($this->connectionData->subdomain),
$location
);
return $response->withHeader('Location', $location);
}
}

View File

@@ -3,8 +3,8 @@
namespace App\Client\Http\Modifiers;
use App\Client\Configuration;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Support\Arr;
use Psr\Http\Message\RequestInterface;
use Ratchet\Client\WebSocket;
@@ -29,7 +29,7 @@ class CheckBasicAuthentication
if (is_null($username)) {
$proxyConnection->send(
str(new Response(401, [
Message::toString(new Response(401, [
'WWW-Authenticate' => 'Basic realm=Expose',
], 'Unauthorized'))
);
@@ -89,7 +89,7 @@ class CheckBasicAuthentication
protected function getCredentials()
{
try {
$credentials = explode(':', $this->configuration->auth());
$credentials = explode(':', $this->configuration->basicAuth());
return [
$credentials[0] => $credentials[1],

View File

@@ -5,7 +5,9 @@ namespace App\Client;
use App\Client\Http\HttpClient;
use function Ratchet\Client\connect;
use Ratchet\Client\WebSocket;
use Ratchet\RFC6455\Messaging\Frame;
use React\EventLoop\LoopInterface;
use React\Socket\Connector;
class ProxyManager
{
@@ -30,7 +32,7 @@ class ProxyManager
], $this->loop)
->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) {
$proxyConnection->on('message', function ($message) use ($proxyConnection, $connectionData) {
$this->performRequest($proxyConnection, $connectionData->request_id, (string) $message);
$this->performRequest($proxyConnection, (string) $message, $connectionData);
});
$proxyConnection->send(json_encode([
@@ -43,8 +45,39 @@ class ProxyManager
});
}
protected function performRequest(WebSocket $proxyConnection, $requestId, string $requestData)
public function createTcpProxy(string $clientId, $connectionData)
{
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $requestId);
$protocol = $this->configuration->port() === 443 ? 'wss' : 'ws';
connect($protocol."://{$this->configuration->host()}:{$this->configuration->port()}/expose/control", [], [
'X-Expose-Control' => 'enabled',
], $this->loop)
->then(function (WebSocket $proxyConnection) use ($clientId, $connectionData) {
$connector = new Connector($this->loop);
$connector->connect('127.0.0.1:'.$connectionData->port)->then(function ($connection) use ($proxyConnection) {
$connection->on('data', function ($data) use ($proxyConnection) {
$binaryMsg = new Frame($data, true, Frame::OP_BINARY);
$proxyConnection->send($binaryMsg);
});
$proxyConnection->on('message', function ($message) use ($connection) {
$connection->write($message);
});
});
$proxyConnection->send(json_encode([
'event' => 'registerTcpProxy',
'data' => [
'tcp_request_id' => $connectionData->tcp_request_id ?? null,
'client_id' => $clientId,
],
]));
});
}
protected function performRequest(WebSocket $proxyConnection, string $requestData, $connectionData)
{
app(HttpClient::class)->performRequest((string) $requestData, $proxyConnection, $connectionData);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Client\Support;
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Name;
use PhpParser\NodeVisitorAbstract;
class ClearDomainNodeVisitor extends NodeVisitorAbstract
{
public function enterNode(Node $node)
{
if ($node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'default_domain') {
$node->value = new ConstFetch(
new Name('null')
);
return $node;
}
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace App\Client\Support;
use Symfony\Component\Console\Formatter\OutputFormatterInterface;
use Symfony\Component\Console\Helper\Helper;
use Symfony\Component\Console\Output\StreamOutput;
use Symfony\Component\Console\Terminal;
/**
* @author Pierre du Plessis <pdples@gmail.com>
* @author Gabriel Ostrolucký <gabriel.ostrolucky@gmail.com>
*/
class ConsoleSectionOutput extends StreamOutput
{
private $content = [];
private $lines = 0;
private $sections;
private $terminal;
/**
* @param resource $stream
* @param \Symfony\Component\Console\Output\ConsoleSectionOutput[] $sections
*/
public function __construct($stream, array &$sections, int $verbosity, bool $decorated, OutputFormatterInterface $formatter)
{
parent::__construct($stream, $verbosity, $decorated, $formatter);
array_unshift($sections, $this);
$this->sections = &$sections;
$this->terminal = new Terminal();
}
/**
* Clears previous output for this section.
*
* @param int $lines Number of lines to clear. If null, then the entire output of this section is cleared
*/
public function clear(int $lines = null)
{
if (empty($this->content) || ! $this->isDecorated()) {
return;
}
if ($lines) {
array_splice($this->content, -($lines * 2)); // Multiply lines by 2 to cater for each new line added between content
} else {
$lines = $this->lines;
$this->content = [];
}
$this->lines -= $lines;
parent::doWrite($this->popStreamContentUntilCurrentSection($lines), false);
}
/**
* Overwrites the previous output with a new message.
*
* @param array|string $message
*/
public function overwrite($message)
{
$this->clear();
$this->writeln($message);
}
public function getContent(): string
{
return implode('', $this->content);
}
/**
* @internal
*/
public function addContent(string $input)
{
foreach (explode(\PHP_EOL, $input) as $lineContent) {
$this->lines += ceil($this->getDisplayLength($lineContent) / $this->terminal->getWidth()) ?: 1;
$this->content[] = $lineContent;
$this->content[] = \PHP_EOL;
}
}
/**
* {@inheritdoc}
*/
protected function doWrite(string $message, bool $newline)
{
if (! $this->isDecorated()) {
parent::doWrite($message, $newline);
return;
}
$erasedContent = $this->popStreamContentUntilCurrentSection();
$this->addContent($message);
parent::doWrite($message, true);
parent::doWrite($erasedContent, false);
}
/**
* At initial stage, cursor is at the end of stream output. This method makes cursor crawl upwards until it hits
* current section. Then it erases content it crawled through. Optionally, it erases part of current section too.
*/
private function popStreamContentUntilCurrentSection(int $numberOfLinesToClearFromCurrentSection = 0): string
{
$numberOfLinesToClear = $numberOfLinesToClearFromCurrentSection;
$erasedContent = [];
foreach ($this->sections as $section) {
if ($section === $this) {
break;
}
$numberOfLinesToClear += $section->lines;
$erasedContent[] = $section->getContent();
}
if ($numberOfLinesToClear > 0) {
// move cursor up n lines
parent::doWrite(sprintf("\x1b[%dA", $numberOfLinesToClear), false);
// erase to end of screen
parent::doWrite("\x1b[0J", false);
}
return implode('', array_reverse($erasedContent));
}
private function getDisplayLength(string $text): int
{
$cleanedText = Helper::removeDecoration($this->getFormatter(), str_replace("\t", ' ', $text));
$cleanedText = preg_replace('/]8;;(.*)]8;;/m', '', $cleanedText);
return Helper::width($cleanedText);
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Client\Support;
use PhpParser\Node;
use PhpParser\Node\Scalar\String_;
use PhpParser\NodeVisitorAbstract;
class DefaultDomainNodeVisitor extends NodeVisitorAbstract
{
/** @var string */
protected $domain;
public function __construct(string $domain)
{
$this->domain = $domain;
}
public function leaveNode(Node $node)
{
if ($node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'default_domain') {
$node->value = new String_($this->domain);
return $node;
}
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace App\Client\Support;
use PhpParser\Node;
use PhpParser\Node\Scalar\String_;
use PhpParser\NodeVisitorAbstract;
class DefaultServerNodeVisitor extends NodeVisitorAbstract
{
/** @var string */
protected $server;
public function __construct(string $server)
{
$this->server = $server;
}
public function enterNode(Node $node)
{
if ($node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'default_server') {
$node->value = new String_($this->server);
return $node;
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Client\Support;
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Name;
use PhpParser\NodeVisitorAbstract;
class InsertDefaultDomainNodeVisitor extends NodeVisitorAbstract
{
public function leaveNode(Node $node)
{
if ($node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'auth_token') {
$defaultDomainNode = new Node\Expr\ArrayItem(
new ConstFetch(
new Name('null')
),
new Node\Scalar\String_('default_domain')
);
return [
$node,
$defaultDomainNode,
];
}
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Client\Support;
use PhpParser\Node;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Name;
use PhpParser\NodeVisitorAbstract;
class InsertDefaultServerNodeVisitor extends NodeVisitorAbstract
{
public function leaveNode(Node $node)
{
if ($node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'auth_token') {
$defaultServerNode = new Node\Expr\ArrayItem(
new ConstFetch(
new Name('null')
),
new Node\Scalar\String_('default_server')
);
return [
$node,
$defaultServerNode,
];
}
}
}

View File

@@ -3,6 +3,7 @@
namespace App\Client\Support;
use PhpParser\Node;
use PhpParser\Node\Scalar\String_;
use PhpParser\NodeVisitorAbstract;
class TokenNodeVisitor extends NodeVisitorAbstract
@@ -18,7 +19,7 @@ class TokenNodeVisitor extends NodeVisitorAbstract
public function enterNode(Node $node)
{
if ($node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'auth_token') {
$node->value->value = $this->token;
$node->value = new String_($this->token);
return $node;
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Commands;
use App\Client\Support\ClearDomainNodeVisitor;
use App\Client\Support\InsertDefaultDomainNodeVisitor;
use Illuminate\Console\Command;
use PhpParser\Lexer\Emulative;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\CloningVisitor;
use PhpParser\Parser\Php7;
use PhpParser\PrettyPrinter\Standard;
class ClearDefaultDomainCommand extends Command
{
protected $signature = 'default-domain:clear';
protected $description = 'Clear the default domain to use with Expose.';
public function handle()
{
$this->info('Clearing the default Expose domain.');
$configFile = implode(DIRECTORY_SEPARATOR, [
$_SERVER['HOME'] ?? $_SERVER['USERPROFILE'],
'.expose',
'config.php',
]);
if (! file_exists($configFile)) {
@mkdir(dirname($configFile), 0777, true);
$updatedConfigFile = $this->modifyConfigurationFile(base_path('config/expose.php'));
} else {
$updatedConfigFile = $this->modifyConfigurationFile($configFile);
}
file_put_contents($configFile, $updatedConfigFile);
}
protected function modifyConfigurationFile(string $configFile)
{
$lexer = new Emulative([
'usedAttributes' => [
'comments',
'startLine', 'endLine',
'startTokenPos', 'endTokenPos',
],
]);
$parser = new Php7($lexer);
$oldStmts = $parser->parse(file_get_contents($configFile));
$oldTokens = $lexer->getTokens();
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new CloningVisitor());
$newStmts = $nodeTraverser->traverse($oldStmts);
$nodeFinder = new NodeFinder;
$defaultDomainNode = $nodeFinder->findFirst($newStmts, function (Node $node) {
return $node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'default_domain';
});
if (is_null($defaultDomainNode)) {
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new InsertDefaultDomainNodeVisitor());
$newStmts = $nodeTraverser->traverse($newStmts);
}
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new ClearDomainNodeVisitor());
$newStmts = $nodeTraverser->traverse($newStmts);
$prettyPrinter = new Standard();
return $prettyPrinter->printFormatPreserving($newStmts, $oldStmts, $oldTokens);
}
}

View File

@@ -3,20 +3,35 @@
namespace App\Commands;
use App\Server\Factory;
use InvalidArgumentException;
use LaravelZero\Framework\Commands\Command;
use React\EventLoop\LoopInterface;
class ServeCommand extends Command
{
protected $signature = 'serve {hostname=localhost} {host=0.0.0.0} {--validateAuthTokens} {--port=8080}';
protected $signature = 'serve {hostname=localhost} {host=0.0.0.0} {--validateAuthTokens} {--port=8080} {--config=}';
protected $description = 'Start the expose server';
protected function loadConfiguration(string $configFile)
{
$configFile = realpath($configFile);
throw_if(! file_exists($configFile), new InvalidArgumentException("Invalid config file {$configFile}"));
$localConfig = require $configFile;
config()->set('expose', $localConfig);
}
public function handle()
{
/** @var LoopInterface $loop */
$loop = app(LoopInterface::class);
if ($this->option('config')) {
$this->loadConfiguration($this->option('config'));
}
$loop->futureTick(function () {
$this->info('Expose server running on port '.$this->option('port').'.');
});

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Commands;
use App\Client\Exceptions\InvalidServerProvided;
use App\Logger\CliRequestLogger;
use Illuminate\Console\Parser;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use LaravelZero\Framework\Commands\Command;
use Symfony\Component\Console\Output\ConsoleOutput;
abstract class ServerAwareCommand extends Command
{
const DEFAULT_HOSTNAME = 'bitinflow.dev';
const DEFAULT_PORT = 443;
const DEFAULT_SERVER_ENDPOINT = 'https://expose.dev/api/servers';
public function __construct()
{
parent::__construct();
$inheritedSignature = '{--server=} {--server-host=} {--server-port=}';
$this->getDefinition()->addOptions(Parser::parse($inheritedSignature)[2]);
$this->configureConnectionLogger();
}
protected function configureConnectionLogger()
{
app()->singleton(CliRequestLogger::class, function () {
return new CliRequestLogger(new ConsoleOutput());
});
return $this;
}
protected function getServerHost()
{
if ($this->option('server-host')) {
return $this->option('server-host');
}
/**
* Try to find the server in the servers array.
* If no array exists at all (when upgrading from v1),
* always return bitinflow.dev.
*/
if (config('expose.servers') === null) {
return static::DEFAULT_HOSTNAME;
}
$server = $this->option('server') ?? config('expose.default_server');
$host = config('expose.servers.'.$server.'.host');
if (! is_null($host)) {
return $host;
}
return $this->lookupRemoteServerHost($server);
}
protected function getServerPort()
{
if ($this->option('server-port')) {
return $this->option('server-port');
}
/**
* Try to find the server in the servers array.
* If no array exists at all (when upgrading from v1),
* always return bitinflow.dev.
*/
if (config('expose.servers') === null) {
return static::DEFAULT_PORT;
}
$server = $this->option('server') ?? config('expose.default_server');
$host = config('expose.servers.'.$server.'.port');
if (! is_null($host)) {
return $host;
}
return $this->lookupRemoteServerPort($server);
}
protected function lookupRemoteServers()
{
try {
return Http::withOptions([
'verify' => false,
])->get(config('expose.server_endpoint', static::DEFAULT_SERVER_ENDPOINT))->json();
} catch (\Throwable $e) {
return [];
}
}
protected function lookupRemoteServerHost($server)
{
$servers = $this->lookupRemoteServers();
$host = Arr::get($servers, $server.'.host');
throw_if(is_null($host), new InvalidServerProvided($server));
return $host;
}
protected function lookupRemoteServerPort($server)
{
$servers = $this->lookupRemoteServers();
$port = Arr::get($servers, $server.'.port');
throw_if(is_null($port), new InvalidServerProvided($server));
return $port;
}
}

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Commands;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use LaravelZero\Framework\Commands\Command;
class ServerListCommand extends Command
{
const DEFAULT_SERVER_ENDPOINT = 'https://expose.dev/api/servers';
protected $signature = 'servers';
protected $description = 'Set or retrieve the default server to use with Expose.';
public function handle()
{
$servers = collect($this->lookupRemoteServers())->map(function ($server) {
return [
'key' => $server['key'],
'region' => $server['region'],
'plan' => Str::ucfirst($server['plan']),
];
});
$this->info('You can connect to a specific server with the --server=key option or set this server as default with the default-server command.');
$this->info('');
$this->table(['Key', 'Region', 'Type'], $servers);
}
protected function lookupRemoteServers()
{
try {
return Http::withOptions([
'verify' => false,
])->get(config('expose.server_endpoint', static::DEFAULT_SERVER_ENDPOINT))->json();
} catch (\Throwable $e) {
return [];
}
}
}

View File

@@ -0,0 +1,98 @@
<?php
namespace App\Commands;
use App\Client\Support\DefaultDomainNodeVisitor;
use App\Client\Support\DefaultServerNodeVisitor;
use App\Client\Support\InsertDefaultDomainNodeVisitor;
use Illuminate\Console\Command;
use PhpParser\Lexer\Emulative;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\CloningVisitor;
use PhpParser\Parser\Php7;
use PhpParser\PrettyPrinter\Standard;
class SetDefaultDomainCommand extends Command
{
protected $signature = 'default-domain {domain?} {--server=}';
protected $description = 'Set or retrieve the default domain to use with Expose.';
public function handle()
{
$domain = $this->argument('domain');
$server = $this->option('server');
if (! is_null($domain)) {
$this->info('Setting the Expose default domain to "'.$domain.'"');
$configFile = implode(DIRECTORY_SEPARATOR, [
$_SERVER['HOME'] ?? $_SERVER['USERPROFILE'],
'.expose',
'config.php',
]);
if (! file_exists($configFile)) {
@mkdir(dirname($configFile), 0777, true);
$updatedConfigFile = $this->modifyConfigurationFile(base_path('config/expose.php'), $domain, $server);
} else {
$updatedConfigFile = $this->modifyConfigurationFile($configFile, $domain, $server);
}
file_put_contents($configFile, $updatedConfigFile);
return;
}
if (is_null($domain = config('expose.default_domain'))) {
$this->info('There is no default domain specified.');
} else {
$this->info('Current default domain: '.$domain);
}
}
protected function modifyConfigurationFile(string $configFile, string $domain, ?string $server)
{
$lexer = new Emulative([
'usedAttributes' => [
'comments',
'startLine', 'endLine',
'startTokenPos', 'endTokenPos',
],
]);
$parser = new Php7($lexer);
$oldStmts = $parser->parse(file_get_contents($configFile));
$oldTokens = $lexer->getTokens();
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new CloningVisitor());
$newStmts = $nodeTraverser->traverse($oldStmts);
$nodeFinder = new NodeFinder;
$defaultDomainNode = $nodeFinder->findFirst($newStmts, function (Node $node) {
return $node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'default_domain';
});
if (is_null($defaultDomainNode)) {
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new InsertDefaultDomainNodeVisitor());
$newStmts = $nodeTraverser->traverse($newStmts);
}
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new DefaultDomainNodeVisitor($domain));
if (! is_null($server)) {
$nodeTraverser->addVisitor(new DefaultServerNodeVisitor($server));
}
$newStmts = $nodeTraverser->traverse($newStmts);
$prettyPrinter = new Standard();
return $prettyPrinter->printFormatPreserving($newStmts, $oldStmts, $oldTokens);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Commands;
use App\Client\Support\DefaultServerNodeVisitor;
use App\Client\Support\InsertDefaultServerNodeVisitor;
use Illuminate\Console\Command;
use PhpParser\Lexer\Emulative;
use PhpParser\Node;
use PhpParser\NodeFinder;
use PhpParser\NodeTraverser;
use PhpParser\NodeVisitor\CloningVisitor;
use PhpParser\Parser\Php7;
use PhpParser\PrettyPrinter\Standard;
class SetDefaultServerCommand extends Command
{
protected $signature = 'default-server {server?}';
protected $description = 'Set or retrieve the default server to use with Expose.';
public function handle()
{
$server = $this->argument('server');
if (! is_null($server)) {
$this->info('Setting the Expose default server to "'.$server.'"');
$configFile = implode(DIRECTORY_SEPARATOR, [
$_SERVER['HOME'] ?? $_SERVER['USERPROFILE'],
'.expose',
'config.php',
]);
if (! file_exists($configFile)) {
@mkdir(dirname($configFile), 0777, true);
$updatedConfigFile = $this->modifyConfigurationFile(base_path('config/expose.php'), $server);
} else {
$updatedConfigFile = $this->modifyConfigurationFile($configFile, $server);
}
file_put_contents($configFile, $updatedConfigFile);
return;
}
if (is_null($server = config('expose.default_server'))) {
$this->info('There is no default server specified.');
} else {
$this->info('Current default server: '.$server);
}
}
protected function modifyConfigurationFile(string $configFile, string $server)
{
$lexer = new Emulative([
'usedAttributes' => [
'comments',
'startLine', 'endLine',
'startTokenPos', 'endTokenPos',
],
]);
$parser = new Php7($lexer);
$oldStmts = $parser->parse(file_get_contents($configFile));
$oldTokens = $lexer->getTokens();
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new CloningVisitor());
$newStmts = $nodeTraverser->traverse($oldStmts);
$nodeFinder = new NodeFinder;
$defaultServerNode = $nodeFinder->findFirst($newStmts, function (Node $node) {
return $node instanceof Node\Expr\ArrayItem && $node->key && $node->key->value === 'default_server';
});
if (is_null($defaultServerNode)) {
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new InsertDefaultServerNodeVisitor());
$newStmts = $nodeTraverser->traverse($newStmts);
}
$nodeTraverser = new NodeTraverser;
$nodeTraverser->addVisitor(new DefaultServerNodeVisitor($server));
$newStmts = $nodeTraverser->traverse($newStmts);
$prettyPrinter = new Standard();
return $prettyPrinter->printFormatPreserving($newStmts, $oldStmts, $oldTokens);
}
}

View File

@@ -3,37 +3,63 @@
namespace App\Commands;
use App\Client\Factory;
use App\Logger\CliRequestLogger;
use LaravelZero\Framework\Commands\Command;
use Illuminate\Support\Str;
use React\EventLoop\LoopInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
class ShareCommand extends Command
class ShareCommand extends ServerAwareCommand
{
protected $signature = 'share {host} {--subdomain=} {--auth=}';
protected $signature = 'share {host} {--subdomain=} {--auth=} {--basicAuth=} {--dns=} {--domain=}';
protected $description = 'Share a local url with a remote expose server';
protected function configureConnectionLogger()
{
app()->bind(CliRequestLogger::class, function () {
return new CliRequestLogger(new ConsoleOutput());
});
return $this;
}
public function handle()
{
$this->configureConnectionLogger();
$auth = $this->option('auth') ?? config('expose.auth_token', '');
$this->info('Using auth token: '.$auth, OutputInterface::VERBOSITY_DEBUG);
if (strstr($this->argument('host'), 'host.docker.internal')) {
config(['expose.dns' => true]);
}
if ($this->option('dns') !== null) {
config(['expose.dns' => empty($this->option('dns')) ? true : $this->option('dns')]);
}
$domain = config('expose.default_domain');
if (! is_null($this->option('server'))) {
$domain = null;
}
if (! is_null($this->option('domain'))) {
$domain = $this->option('domain');
}
if (! is_null($this->option('subdomain'))) {
$subdomains = explode(',', $this->option('subdomain'));
$this->info('Trying to use custom domain: '.$subdomains[0].PHP_EOL, OutputInterface::VERBOSITY_VERBOSE);
} else {
$host = Str::beforeLast($this->argument('host'), '.');
$host = str_replace('https://', '', $host);
$host = str_replace('http://', '', $host);
$host = Str::beforeLast($host, ':');
$subdomains = [Str::slug($host)];
$this->info('Trying to use custom domain: '.$subdomains[0].PHP_EOL, OutputInterface::VERBOSITY_VERBOSE);
}
(new Factory())
->setLoop(app(LoopInterface::class))
->setHost(config('expose.host', 'localhost'))
->setPort(config('expose.port', 8080))
->setAuth($this->option('auth'))
->setHost($this->getServerHost())
->setPort($this->getServerPort())
->setAuth($auth)
->setBasicAuth($this->option('basicAuth'))
->createClient()
->share($this->argument('host'), explode(',', $this->option('subdomain')))
->share(
$this->argument('host'),
$subdomains,
$domain
)
->createHttpServer()
->run();
}

View File

@@ -4,17 +4,17 @@ namespace App\Commands;
class ShareCurrentWorkingDirectoryCommand extends ShareCommand
{
protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=}';
protected $signature = 'share-cwd {host?} {--subdomain=} {--auth=} {--basicAuth=} {--dns=} {--domain=}';
public function handle()
{
$subdomain = $this->detectName();
$host = $this->prepareSharedHost($subdomain.'.'.$this->detectTld());
$folderName = $this->detectName();
$host = $this->prepareSharedHost($folderName.'.'.$this->detectTld());
$this->input->setArgument('host', $host);
if (! $this->option('subdomain')) {
$this->input->setOption('subdomain', $subdomain);
$this->input->setOption('subdomain', str_replace('.', '-', $folderName));
}
parent::handle();
@@ -56,17 +56,22 @@ class ShareCurrentWorkingDirectoryCommand extends ShareCommand
}
}
return str_replace('.', '-', basename($projectPath));
return basename($projectPath);
}
protected function prepareSharedHost($host): string
protected function detectProtocol($host): string
{
$certificateFile = ($_SERVER['HOME'] ?? $_SERVER['USERPROFILE']).DIRECTORY_SEPARATOR.'.config'.DIRECTORY_SEPARATOR.'valet'.DIRECTORY_SEPARATOR.'Certificates'.DIRECTORY_SEPARATOR.$host.'.crt';
if (file_exists($certificateFile)) {
return 'https://'.$host;
return 'https://';
}
return $host;
return config('expose.default_https', false) ? 'https://' : 'http://';
}
protected function prepareSharedHost($host): string
{
return $this->detectProtocol($host).$host;
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Commands;
use App\Client\Factory;
use React\EventLoop\LoopInterface;
class SharePortCommand extends ServerAwareCommand
{
protected $signature = 'share-port {port} {--auth=}';
protected $description = 'Share a local port with a remote expose server';
public function handle()
{
$auth = $this->option('auth') ?? config('expose.auth_token', '');
(new Factory())
->setLoop(app(LoopInterface::class))
->setHost($this->getServerHost())
->setPort($this->getServerPort())
->setAuth($auth)
->createClient()
->sharePort($this->argument('port'))
->createHttpServer()
->run();
}
}

View File

@@ -14,7 +14,7 @@ class StoreAuthenticationTokenCommand extends Command
{
protected $signature = 'token {token?}';
protected $description = 'Set or retrieve the authentication token to use with expose.';
protected $description = 'Set or retrieve the authentication token to use with Expose.';
public function handle()
{

View File

@@ -8,7 +8,9 @@ use Ratchet\ConnectionInterface;
interface ConnectionManager
{
public function storeConnection(string $host, ?string $subdomain, ConnectionInterface $connection): ControlConnection;
public function storeConnection(string $host, ?string $subdomain, ?string $serverHost, ConnectionInterface $connection): ControlConnection;
public function storeTcpConnection(int $port, ConnectionInterface $connection): ControlConnection;
public function limitConnectionLength(ControlConnection $connection, int $maximumConnectionLength);
@@ -18,11 +20,17 @@ interface ConnectionManager
public function removeControlConnection($connection);
public function findControlConnectionForSubdomain($subdomain): ?ControlConnection;
public function findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost): ?ControlConnection;
public function findControlConnectionForClientId(string $clientId): ?ControlConnection;
public function getConnections(): array;
public function getConnectionsForAuthToken(string $authToken): array;
public function getTcpConnectionsForAuthToken(string $authToken): array;
public function findControlConnectionsForIp(string $ip): array;
public function findControlConnectionsForAuthToken(string $token): array;
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Contracts;
use React\Promise\PromiseInterface;
interface DomainRepository
{
public function getDomains(): PromiseInterface;
public function getDomainById($id): PromiseInterface;
public function getDomainByName(string $name): PromiseInterface;
public function getDomainsByUserId($id): PromiseInterface;
public function getDomainsByUserIdAndName($id, $name): PromiseInterface;
public function deleteDomainForUserId($userId, $domainId): PromiseInterface;
public function storeDomain(array $data): PromiseInterface;
public function updateDomain($id, array $data): PromiseInterface;
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Contracts;
use React\Promise\PromiseInterface;
interface LoggerRepository
{
public function logSubdomain($authToken, $subdomain);
public function getLogs(): PromiseInterface;
public function getLogsBySubdomain($subdomain): PromiseInterface;
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Contracts;
interface StatisticsCollector
{
public function siteShared($authToken = null);
public function portShared($authToken = null);
public function incomingRequest();
public function flush();
public function save();
public function shouldCollectStatistics(): bool;
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Contracts;
use React\Promise\PromiseInterface;
interface StatisticsRepository
{
public function getStatistics($from, $until): PromiseInterface;
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Contracts;
use React\Promise\PromiseInterface;
interface SubdomainRepository
{
public function getSubdomains(): PromiseInterface;
public function getSubdomainById($id): PromiseInterface;
public function getSubdomainByName(string $name): PromiseInterface;
public function getSubdomainByNameAndDomain(string $name, string $domain): PromiseInterface;
public function getSubdomainsByNameAndDomain(string $name, string $domain): PromiseInterface;
public function getSubdomainsByUserId($id): PromiseInterface;
public function getSubdomainsByUserIdAndName($id, $name): PromiseInterface;
public function deleteSubdomainForUserId($userId, $subdomainId): PromiseInterface;
public function storeSubdomain(array $data): PromiseInterface;
}

View File

@@ -10,11 +10,15 @@ interface UserRepository
public function getUserById($id): PromiseInterface;
public function paginateUsers(int $perPage, int $currentPage): PromiseInterface;
public function paginateUsers(string $searchQuery, int $perPage, int $currentPage): PromiseInterface;
public function getUserByToken(string $authToken): PromiseInterface;
public function storeUser(array $data): PromiseInterface;
public function deleteUser($id): PromiseInterface;
public function getUsersByTokens(array $authTokens): PromiseInterface;
public function updateLastSharedAt($id): PromiseInterface;
}

View File

@@ -9,7 +9,7 @@ use Twig\Loader\ArrayLoader;
trait LoadsViews
{
protected function getView(ConnectionInterface $connection, string $view, array $data = [])
protected function getView(?ConnectionInterface $connection, string $view, array $data = [])
{
$templatePath = implode(DIRECTORY_SEPARATOR, explode('.', $view));
@@ -23,7 +23,10 @@ trait LoadsViews
$data = array_merge($data, [
'request' => $connection->laravelRequest ?? null,
]);
return stream_for($twig->render('template', $data));
try {
return stream_for($twig->render('template', $data));
} catch (\Throwable $e) {
var_dump($e->getMessage());
}
}
}

View File

@@ -2,29 +2,60 @@
namespace App\Logger;
use App\Client\Support\ConsoleSectionOutput;
use Illuminate\Support\Collection;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Terminal;
class CliRequestLogger extends Logger
{
/** @var Table */
protected $table;
/** @var Collection */
protected $requests;
/** @var \Symfony\Component\Console\Output\ConsoleSectionOutput */
protected $section;
protected $verbColors = [
'GET' => 'blue',
'HEAD' => '#6C7280',
'OPTIONS' => '#6C7280',
'POST' => 'yellow',
'PUT' => 'yellow',
'PATCH' => 'yellow',
'DELETE' => 'red',
];
protected $consoleSectionOutputs = [];
/**
* The current terminal width.
*
* @var int|null
*/
protected $terminalWidth;
/**
* Computes the terminal width.
*
* @return int
*/
protected function getTerminalWidth()
{
if ($this->terminalWidth == null) {
$this->terminalWidth = (new Terminal)->getWidth();
$this->terminalWidth = $this->terminalWidth >= 30
? $this->terminalWidth
: 30;
}
return $this->terminalWidth;
}
public function __construct(ConsoleOutputInterface $consoleOutput)
{
parent::__construct($consoleOutput);
$this->section = $this->output->section();
$this->table = new Table($this->section);
$this->table->setHeaders(['Method', 'URI', 'Response', 'Time', 'Duration']);
$this->section = new ConsoleSectionOutput($this->output->getStream(), $this->consoleSectionOutputs, $this->output->getVerbosity(), $this->output->isDecorated(), $this->output->getFormatter());
$this->requests = new Collection();
}
@@ -37,8 +68,28 @@ class CliRequestLogger extends Logger
return $this->output;
}
protected function getRequestColor(?LoggedRequest $request)
{
$statusCode = optional($request->getResponse())->getStatusCode();
$color = 'white';
if ($statusCode >= 200 && $statusCode < 300) {
$color = 'green';
} elseif ($statusCode >= 300 && $statusCode < 400) {
$color = 'blue';
} elseif ($statusCode >= 400 && $statusCode < 500) {
$color = 'yellow';
} elseif ($statusCode >= 500) {
$color = 'red';
}
return $color;
}
public function logRequest(LoggedRequest $loggedRequest)
{
$dashboardUrl = 'http://127.0.0.1:'.config('expose.dashboard_port');
if ($this->requests->has($loggedRequest->id())) {
$this->requests[$loggedRequest->id()] = $loggedRequest;
} else {
@@ -46,18 +97,55 @@ class CliRequestLogger extends Logger
}
$this->requests = $this->requests->slice(0, config('expose.max_logged_requests', 10));
$this->section->clear();
$terminalWidth = $this->getTerminalWidth();
$this->table->setRows($this->requests->map(function (LoggedRequest $loggedRequest) {
$requests = $this->requests->map(function (LoggedRequest $loggedRequest) {
return [
$loggedRequest->getRequest()->getMethod(),
$loggedRequest->getRequest()->getUri(),
optional($loggedRequest->getResponse())->getStatusCode().' '.optional($loggedRequest->getResponse())->getReasonPhrase(),
$loggedRequest->getStartTime()->toDateTimeString(),
$loggedRequest->getDuration().'ms',
'method' => $loggedRequest->getRequest()->getMethod(),
'url' => $loggedRequest->getRequest()->getUri(),
'duration' => $loggedRequest->getDuration(),
'time' => $loggedRequest->getStartTime()->isToday() ? $loggedRequest->getStartTime()->toTimeString() : $loggedRequest->getStartTime()->toDateTimeString(),
'color' => $this->getRequestColor($loggedRequest),
'status' => optional($loggedRequest->getResponse())->getStatusCode(),
];
})->toArray());
});
$this->table->render();
$maxMethod = mb_strlen($requests->max('method'));
$maxDuration = mb_strlen($requests->max('duration'));
$output = $requests->map(function ($loggedRequest) use ($terminalWidth, $maxMethod, $maxDuration) {
$method = $loggedRequest['method'];
$spaces = str_repeat(' ', max($maxMethod + 2 - mb_strlen($method), 0));
$url = $loggedRequest['url'];
$duration = $loggedRequest['duration'];
$time = $loggedRequest['time'];
$durationSpaces = str_repeat(' ', max($maxDuration + 2 - mb_strlen($duration), 0));
$color = $loggedRequest['color'];
$status = $loggedRequest['status'];
$dots = str_repeat('.', max($terminalWidth - strlen($method.$spaces.$url.$time.$durationSpaces.$duration) - 16, 0));
if (empty($dots)) {
$url = substr($url, 0, $terminalWidth - strlen($method.$spaces.$time.$durationSpaces.$duration) - 15 - 3).'...';
} else {
$dots .= ' ';
}
return sprintf(
' <fg=%s;options=bold>%s </> <fg=%s;options=bold>%s%s</> %s<fg=#6C7280> %s%s%s%s ms</>',
$color,
$status,
$this->verbColors[$method] ?? 'default',
$method,
$spaces,
$url,
$dots,
$time,
$durationSpaces,
$duration,
);
});
$this->section->overwrite($output);
}
}

View File

@@ -3,9 +3,11 @@
namespace App\Logger;
use Carbon\Carbon;
use GuzzleHttp\Psr7\Message;
use function GuzzleHttp\Psr7\parse_request;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Laminas\Http\Header\GenericHeader;
use Laminas\Http\Request;
use Laminas\Http\Response;
use Namshi\Cuzzle\Formatter\CurlFormatter;
@@ -19,11 +21,8 @@ class LoggedRequest implements \JsonSerializable
/** @var Request */
protected $parsedRequest;
/** @var string */
protected $rawResponse;
/** @var Response */
protected $parsedResponse;
/** @var LoggedResponse */
protected $response;
/** @var string */
protected $id;
@@ -51,6 +50,7 @@ class LoggedRequest implements \JsonSerializable
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$data = [
@@ -71,22 +71,8 @@ class LoggedRequest implements \JsonSerializable
],
];
if ($this->parsedResponse) {
$logBody = $this->shouldReturnBody();
try {
$body = $logBody ? $this->parsedResponse->getBody() : '';
} catch (\Exception $e) {
$body = '';
}
$data['response'] = [
'raw' => $logBody ? $this->rawResponse : 'SKIPPED BY CONFIG OR BINARY RESPONSE',
'status' => $this->parsedResponse->getStatusCode(),
'headers' => $this->parsedResponse->getHeaders()->toArray(),
'reason' => $this->parsedResponse->getReasonPhrase(),
'body' => $logBody ? $body : 'SKIPPED BY CONFIG OR BINARY RESPONSE',
];
if ($this->response) {
$data['response'] = $this->response->toArray();
}
return $data;
@@ -107,96 +93,6 @@ class LoggedRequest implements \JsonSerializable
return preg_match('~[^\x20-\x7E\t\r\n]~', $string) > 0;
}
protected function shouldReturnBody(): bool
{
if ($this->skipByStatus()) {
return false;
}
if ($this->skipByContentType()) {
return false;
}
if ($this->skipByExtension()) {
return false;
}
if ($this->skipBySize()) {
return false;
}
$header = $this->parsedResponse->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
$patterns = [
'application/json',
'text/*',
'*javascript*',
];
return Str::is($patterns, $contentType);
}
protected function skipByStatus(): bool
{
if (empty(config()->get('expose.skip_body_log.status'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.status'), $this->parsedResponse->getStatusCode());
}
protected function skipByContentType(): bool
{
if (empty(config()->get('expose.skip_body_log.content_type'))) {
return false;
}
$header = $this->parsedResponse->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
return Str::is(config()->get('expose.skip_body_log.content_type'), $contentType);
}
protected function skipByExtension(): bool
{
if (empty(config()->get('expose.skip_body_log.extension'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.extension'), $this->parsedRequest->getUri()->getPath());
}
protected function skipBySize(): bool
{
$configSize = $this->getConfigSize(config()->get('expose.skip_body_log.size', '1MB'));
$contentLength = $this->parsedResponse->getHeaders()->get('Content-Length');
if (! $contentLength) {
return false;
}
$contentSize = $contentLength->getFieldValue() ?? 0;
return $contentSize > $configSize;
}
protected function getConfigSize(string $size): int
{
$units = ['B', 'KB', 'MB', 'GB'];
$number = substr($size, 0, -2);
$suffix = strtoupper(substr($size, -2));
// B or no suffix
if (is_numeric(substr($suffix, 0, 1))) {
return preg_replace('/[^\d]/', '', $size);
}
// if we have an error in the input, default to GB
$exponent = array_flip($units)[$suffix] ?? 5;
return $number * (1024 ** $exponent);
}
public function getRequest()
{
return $this->parsedRequest;
@@ -204,9 +100,7 @@ class LoggedRequest implements \JsonSerializable
public function setResponse(string $rawResponse, Response $response)
{
$this->parsedResponse = $response;
$this->rawResponse = $rawResponse;
$this->response = new LoggedResponse($rawResponse, $response, $this->getRequest());
if (is_null($this->stopTime)) {
$this->stopTime = now();
@@ -223,9 +117,9 @@ class LoggedRequest implements \JsonSerializable
return $this->rawRequest;
}
public function getResponse(): ?Response
public function getResponse(): ?LoggedResponse
{
return $this->parsedResponse;
return $this->response;
}
public function getPostData()
@@ -280,7 +174,7 @@ class LoggedRequest implements \JsonSerializable
return $postData;
}
protected function detectSubdomain()
public function detectSubdomain()
{
return collect($this->parsedRequest->getHeaders()->toArray())
->mapWithKeys(function ($value, $key) {
@@ -320,4 +214,23 @@ class LoggedRequest implements \JsonSerializable
return '';
}
}
public function getUrl()
{
$request = Message::parseRequest($this->rawRequest);
dd($request->getUri()->withFragment(''));
}
public function refreshId()
{
$requestId = (string) Str::uuid();
$this->getRequest()->getHeaders()->removeHeader(
$this->getRequest()->getHeader('x-expose-request-id')
);
$this->getRequest()->getHeaders()->addHeader(new GenericHeader('x-expose-request-id', $requestId));
$this->id = $requestId;
}
}

View File

@@ -0,0 +1,160 @@
<?php
namespace App\Logger;
use Illuminate\Support\Str;
use Laminas\Http\Request;
use Laminas\Http\Response;
class LoggedResponse
{
/** @var string */
protected $rawResponse;
/** @var Response */
protected $response;
/** @var Request */
protected $request;
protected $reasonPhrase;
protected $body;
protected $statusCode;
protected $headers;
public function __construct(string $rawResponse, Response $response, Request $request)
{
$this->rawResponse = $rawResponse;
$this->response = $response;
$this->request = $request;
if (! $this->shouldReturnBody()) {
$this->rawResponse = 'SKIPPED BY CONFIG OR BINARY RESPONSE';
$this->body = 'SKIPPED BY CONFIG OR BINARY RESPONSE';
} else {
try {
$this->body = $response->getBody();
} catch (\Exception $e) {
$this->body = '';
}
}
$this->statusCode = $response->getStatusCode();
$this->reasonPhrase = $response->getReasonPhrase();
$this->headers = $response->getHeaders()->toArray();
$this->response = null;
$this->request = null;
}
protected function shouldReturnBody(): bool
{
if ($this->skipByStatus()) {
return false;
}
if ($this->skipByContentType()) {
return false;
}
if ($this->skipByExtension()) {
return false;
}
if ($this->skipBySize()) {
return false;
}
$header = $this->response->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
$patterns = [
'application/json',
'text/*',
'*javascript*',
];
return Str::is($patterns, $contentType);
}
protected function skipByStatus(): bool
{
if (empty(config()->get('expose.skip_body_log.status'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.status'), $this->response->getStatusCode());
}
protected function skipByContentType(): bool
{
if (empty(config()->get('expose.skip_body_log.content_type'))) {
return false;
}
$header = $this->response->getHeaders()->get('Content-Type');
$contentType = $header ? $header->getMediaType() : '';
return Str::is(config()->get('expose.skip_body_log.content_type'), $contentType);
}
protected function skipByExtension(): bool
{
if (empty(config()->get('expose.skip_body_log.extension'))) {
return false;
}
return Str::is(config()->get('expose.skip_body_log.extension'), $this->request->getUri()->getPath());
}
protected function skipBySize(): bool
{
$configSize = $this->getConfigSize(config()->get('expose.skip_body_log.size', '1MB'));
$contentLength = $this->response->getHeaders()->get('Content-Length');
if (! $contentLength) {
return false;
}
$contentSize = $contentLength->getFieldValue() ?? 0;
return $contentSize > $configSize;
}
protected function getConfigSize(string $size): int
{
$units = ['B', 'KB', 'MB', 'GB'];
$number = substr($size, 0, -2);
$suffix = strtoupper(substr($size, -2));
// B or no suffix
if (is_numeric(substr($suffix, 0, 1))) {
return preg_replace('/[^\d]/', '', $size);
}
// if we have an error in the input, default to GB
$exponent = array_flip($units)[$suffix] ?? 5;
return $number * (1024 ** $exponent);
}
public function getStatusCode()
{
return $this->statusCode;
}
public function getReasonPhrase()
{
return $this->reasonPhrase;
}
public function toArray()
{
return [
'raw' => $this->rawResponse,
'status' => $this->statusCode,
'headers' => $this->headers,
'reason' => $this->reasonPhrase,
'body' => $this->body,
];
}
}

View File

@@ -15,6 +15,7 @@ class AppServiceProvider extends ServiceProvider
{
public function boot()
{
UriFactory::registerScheme('capacitor', Uri::class);
UriFactory::registerScheme('chrome-extension', Uri::class);
}
@@ -37,6 +38,14 @@ class AppServiceProvider extends ServiceProvider
{
$builtInConfig = config('expose');
$keyServerVariable = 'EXPOSE_CONFIG_FILE';
if (array_key_exists($keyServerVariable, $_SERVER) && is_string($_SERVER[$keyServerVariable]) && file_exists($_SERVER[$keyServerVariable])) {
$localConfig = require $_SERVER[$keyServerVariable];
config()->set('expose', array_merge($builtInConfig, $localConfig));
return;
}
$localConfigFile = getcwd().DIRECTORY_SEPARATOR.'.expose.php';
if (file_exists($localConfigFile)) {

View File

@@ -40,6 +40,7 @@ class Configuration implements \JsonSerializable
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
return array_merge([

View File

@@ -3,10 +3,14 @@
namespace App\Server\Connections;
use App\Contracts\ConnectionManager as ConnectionManagerContract;
use App\Contracts\LoggerRepository;
use App\Contracts\StatisticsCollector;
use App\Contracts\SubdomainGenerator;
use App\Http\QueryParameters;
use App\Server\Exceptions\NoFreePortAvailable;
use Ratchet\ConnectionInterface;
use React\EventLoop\LoopInterface;
use React\Socket\Server;
class ConnectionManager implements ConnectionManagerContract
{
@@ -22,10 +26,18 @@ class ConnectionManager implements ConnectionManagerContract
/** @var LoopInterface */
protected $loop;
public function __construct(SubdomainGenerator $subdomainGenerator, LoopInterface $loop)
/** @var StatisticsCollector */
protected $statisticsCollector;
/** @var LoggerRepository */
protected $logger;
public function __construct(SubdomainGenerator $subdomainGenerator, StatisticsCollector $statisticsCollector, LoggerRepository $logger, LoopInterface $loop)
{
$this->subdomainGenerator = $subdomainGenerator;
$this->loop = $loop;
$this->statisticsCollector = $statisticsCollector;
$this->logger = $logger;
}
public function limitConnectionLength(ControlConnection $connection, int $maximumConnectionLength)
@@ -41,7 +53,7 @@ class ConnectionManager implements ConnectionManagerContract
});
}
public function storeConnection(string $host, ?string $subdomain, ConnectionInterface $connection): ControlConnection
public function storeConnection(string $host, ?string $subdomain, ?string $serverHost, ConnectionInterface $connection): ControlConnection
{
$clientId = (string) uniqid();
@@ -52,14 +64,75 @@ class ConnectionManager implements ConnectionManagerContract
$host,
$subdomain ?? $this->subdomainGenerator->generateSubdomain(),
$clientId,
$serverHost,
$this->getAuthTokenFromConnection($connection)
);
$this->connections[] = $storedConnection;
$this->statisticsCollector->siteShared($this->getAuthTokenFromConnection($connection));
$this->logger->logSubdomain($storedConnection->authToken, $storedConnection->subdomain);
$this->performConnectionCallback($storedConnection);
return $storedConnection;
}
protected function performConnectionCallback(ControlConnection $connection)
{
$connectionCallback = config('expose.admin.connection_callback');
if ($connectionCallback !== null && class_exists($connectionCallback)) {
app($connectionCallback)->handle($connection);
}
}
public function storeTcpConnection(int $port, ConnectionInterface $connection): ControlConnection
{
$clientId = (string) uniqid();
$connection->client_id = $clientId;
$storedConnection = new TcpControlConnection(
$connection,
$port,
$this->getSharedTcpServer(),
$clientId,
$this->getAuthTokenFromConnection($connection)
);
$this->connections[] = $storedConnection;
$this->statisticsCollector->portShared($this->getAuthTokenFromConnection($connection));
return $storedConnection;
}
protected function getSharedTcpServer(): Server
{
$portRange = config('expose.admin.tcp_port_range');
$port = $portRange['from'] ?? 50000;
$maxPort = $portRange['to'] ?? 60000;
do {
try {
$portFound = true;
$server = new Server('0.0.0.0:'.$port, $this->loop);
} catch (\RuntimeException $exception) {
$portFound = false;
$port++;
if ($port > $maxPort) {
throw new NoFreePortAvailable();
}
}
} while (! $portFound);
return $server;
}
public function storeHttpConnection(ConnectionInterface $httpConnection, $requestId): HttpConnection
{
$this->httpConnections[$requestId] = new HttpConnection($httpConnection);
@@ -82,16 +155,26 @@ class ConnectionManager implements ConnectionManagerContract
if (isset($connection->client_id)) {
$clientId = $connection->client_id;
$controlConnection = collect($this->connections)->first(function ($connection) use ($clientId) {
return $connection->client_id == $clientId;
});
if ($controlConnection instanceof TcpControlConnection) {
$controlConnection->stop();
$controlConnection = null;
}
$this->connections = collect($this->connections)->reject(function ($connection) use ($clientId) {
return $connection->client_id == $clientId;
})->toArray();
}
}
public function findControlConnectionForSubdomain($subdomain): ?ControlConnection
public function findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost): ?ControlConnection
{
return collect($this->connections)->last(function ($connection) use ($subdomain) {
return $connection->subdomain == $subdomain;
return collect($this->connections)->last(function ($connection) use ($subdomain, $serverHost) {
return $connection->subdomain == $subdomain && $connection->serverHost === $serverHost;
});
}
@@ -102,6 +185,20 @@ class ConnectionManager implements ConnectionManagerContract
});
}
public function findControlConnectionsForIp(string $ip): array
{
return collect($this->connections)->filter(function (ControlConnection $connection) use ($ip) {
return $connection->socket->remoteAddress == $ip;
})->toArray();
}
public function findControlConnectionsForAuthToken(string $token): array
{
return collect($this->connections)->filter(function (ControlConnection $connection) use ($token) {
return $connection->authToken === $token;
})->toArray();
}
public function getConnections(): array
{
return $this->connections;
@@ -118,9 +215,29 @@ class ConnectionManager implements ConnectionManagerContract
->filter(function ($connection) use ($authToken) {
return $connection->authToken === $authToken;
})
->filter(function ($connection) {
return get_class($connection) === ControlConnection::class;
})
->map(function ($connection) {
return $connection->toArray();
})
->values()
->toArray();
}
public function getTcpConnectionsForAuthToken(string $authToken): array
{
return collect($this->connections)
->filter(function ($connection) use ($authToken) {
return $connection->authToken === $authToken;
})
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($connection) {
return $connection->toArray();
})
->values()
->toArray();
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Server\Connections;
use App\Http\QueryParameters;
use Evenement\EventEmitterTrait;
use Ratchet\ConnectionInterface;
@@ -12,20 +13,24 @@ class ControlConnection
/** @var ConnectionInterface */
public $socket;
public $host;
public $serverHost;
public $authToken;
public $subdomain;
public $client_id;
public $client_version;
public $proxies = [];
protected $shared_at;
public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId, string $authToken = '')
public function __construct(ConnectionInterface $socket, string $host, string $subdomain, string $clientId, string $serverHost, string $authToken = '')
{
$this->socket = $socket;
$this->host = $host;
$this->subdomain = $subdomain;
$this->client_id = $clientId;
$this->authToken = $authToken;
$this->serverHost = $serverHost;
$this->shared_at = now()->toDateTimeString();
$this->client_version = QueryParameters::create($socket->httpRequest)->get('version');
}
public function setMaximumConnectionLength(int $maximumConnectionLength)
@@ -43,6 +48,8 @@ class ControlConnection
$this->socket->send(json_encode([
'event' => 'createProxy',
'data' => [
'host' => $this->host,
'subdomain' => $this->subdomain,
'request_id' => $requestId,
'client_id' => $this->client_id,
],
@@ -57,8 +64,12 @@ class ControlConnection
public function toArray()
{
return [
'type' => 'http',
'host' => $this->host,
'remote_address' => $this->socket->remoteAddress ?? null,
'server_host' => $this->serverHost,
'client_id' => $this->client_id,
'client_version' => $this->client_version,
'auth_token' => $this->authToken,
'subdomain' => $this->subdomain,
'shared_at' => $this->shared_at,

View File

@@ -0,0 +1,108 @@
<?php
namespace App\Server\Connections;
use Ratchet\ConnectionInterface;
use Ratchet\RFC6455\Messaging\Frame;
use React\Socket\Server;
class TcpControlConnection extends ControlConnection
{
public $proxy;
public $proxyConnection;
public $port;
public $shared_port;
public $shared_server;
public function __construct(ConnectionInterface $socket, int $port, Server $sharedServer, string $clientId, string $authToken = '')
{
$this->socket = $socket;
$this->client_id = $clientId;
$this->shared_server = $sharedServer;
$this->port = $port;
$this->shared_at = now()->toDateTimeString();
$this->shared_port = parse_url($sharedServer->getAddress(), PHP_URL_PORT);
$this->authToken = $authToken;
$this->configureServer($sharedServer);
}
public function setMaximumConnectionLength(int $maximumConnectionLength)
{
$this->socket->send(json_encode([
'event' => 'setMaximumConnectionLength',
'data' => [
'length' => $maximumConnectionLength,
],
]));
}
public function registerProxy($requestId)
{
$this->socket->send(json_encode([
'event' => 'createProxy',
'data' => [
'request_id' => $requestId,
'client_id' => $this->client_id,
],
]));
}
public function registerTcpProxy($requestId)
{
$this->socket->send(json_encode([
'event' => 'createTcpProxy',
'data' => [
'port' => $this->port,
'tcp_request_id' => $requestId,
'client_id' => $this->client_id,
],
]));
}
public function stop()
{
$this->shared_server->close();
$this->shared_server = null;
}
public function close()
{
$this->socket->close();
}
public function toArray()
{
return [
'type' => 'tcp',
'port' => $this->port,
'auth_token' => $this->authToken,
'client_id' => $this->client_id,
'shared_port' => $this->shared_port,
'shared_at' => $this->shared_at,
];
}
protected function configureServer(Server $sharedServer)
{
$requestId = uniqid();
$sharedServer->on('connection', function (\React\Socket\ConnectionInterface $connection) use ($requestId) {
$this->proxyConnection = $connection;
$this->once('tcp_proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($connection) {
$this->proxy = $proxy;
$connection->on('data', function ($data) use ($proxy) {
$binaryMsg = new Frame($data, true, Frame::OP_BINARY);
$proxy->send($binaryMsg);
});
$connection->resume();
});
$connection->pause();
$this->registerTcpProxy($requestId);
});
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace App\Server\DomainRepository;
use App\Contracts\DomainRepository;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class DatabaseDomainRepository implements DomainRepository
{
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function getDomains(): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM domains ORDER by created_at DESC')
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function getDomainById($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM domains WHERE id = :id', ['id' => $id])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getDomainByName(string $name): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM domains WHERE domain = :name', ['name' => $name])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getDomainsByUserId($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM domains WHERE user_id = :user_id ORDER by created_at DESC', [
'user_id' => $id,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function storeDomain(array $data): PromiseInterface
{
$deferred = new Deferred();
$this->getDomainByName($data['domain'])
->then(function ($registeredDomain) use ($data, $deferred) {
$this->database->query("
INSERT INTO domains (user_id, domain, created_at)
VALUES (:user_id, :domain, DATETIME('now'))
", $data)
->then(function (Result $result) use ($deferred) {
$this->database->query('SELECT * FROM domains WHERE id = :id', ['id' => $result->insertId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
});
});
return $deferred->promise();
}
public function getDomainsByUserIdAndName($id, $name): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM domains WHERE user_id = :user_id AND domain = :name ORDER by created_at DESC', [
'user_id' => $id,
'name' => $name,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function deleteDomainForUserId($userId, $domainId): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('DELETE FROM domains WHERE id = :id AND user_id = :user_id', [
'id' => $domainId,
'user_id' => $userId,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result);
});
return $deferred->promise();
}
public function updateDomain($id, array $data): PromiseInterface
{
$deferred = new Deferred();
// TODO
return $deferred->promise();
}
}

View File

@@ -0,0 +1,7 @@
<?php
namespace App\Server\Exceptions;
class NoFreePortAvailable extends \Exception
{
}

View File

@@ -3,26 +3,46 @@
namespace App\Server;
use App\Contracts\ConnectionManager as ConnectionManagerContract;
use App\Contracts\DomainRepository;
use App\Contracts\LoggerRepository;
use App\Contracts\StatisticsCollector;
use App\Contracts\StatisticsRepository;
use App\Contracts\SubdomainGenerator;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use App\Http\RouteGenerator;
use App\Http\Server as HttpServer;
use App\Server\Connections\ConnectionManager;
use App\Server\DomainRepository\DatabaseDomainRepository;
use App\Server\Http\Controllers\Admin\DeleteSubdomainController;
use App\Server\Http\Controllers\Admin\DeleteUsersController;
use App\Server\Http\Controllers\Admin\DisconnectSiteController;
use App\Server\Http\Controllers\Admin\DisconnectTcpConnectionController;
use App\Server\Http\Controllers\Admin\GetLogsController;
use App\Server\Http\Controllers\Admin\GetLogsForSubdomainController;
use App\Server\Http\Controllers\Admin\GetSettingsController;
use App\Server\Http\Controllers\Admin\GetSiteDetailsController;
use App\Server\Http\Controllers\Admin\GetSitesController;
use App\Server\Http\Controllers\Admin\GetStatisticsController;
use App\Server\Http\Controllers\Admin\GetTcpConnectionsController;
use App\Server\Http\Controllers\Admin\GetUserDetailsController;
use App\Server\Http\Controllers\Admin\GetUsersController;
use App\Server\Http\Controllers\Admin\ListSitesController;
use App\Server\Http\Controllers\Admin\ListTcpConnectionsController;
use App\Server\Http\Controllers\Admin\ListUsersController;
use App\Server\Http\Controllers\Admin\RedirectToUsersController;
use App\Server\Http\Controllers\Admin\ShowSettingsController;
use App\Server\Http\Controllers\Admin\StoreDomainController;
use App\Server\Http\Controllers\Admin\StoreSettingsController;
use App\Server\Http\Controllers\Admin\StoreSubdomainController;
use App\Server\Http\Controllers\Admin\StoreUsersController;
use App\Server\Http\Controllers\ControlMessageController;
use App\Server\Http\Controllers\TunnelMessageController;
use App\Server\Http\Router;
use App\Server\LoggerRepository\NullLogger;
use App\Server\StatisticsCollector\DatabaseStatisticsCollector;
use App\Server\StatisticsRepository\DatabaseStatisticsRepository;
use App\Server\SubdomainRepository\DatabaseSubdomainRepository;
use Clue\React\SQLite\DatabaseInterface;
use Phar;
use Ratchet\Server\IoServer;
@@ -120,15 +140,32 @@ class Factory
$this->router->get('/users', ListUsersController::class, $adminCondition);
$this->router->get('/settings', ShowSettingsController::class, $adminCondition);
$this->router->get('/sites', ListSitesController::class, $adminCondition);
$this->router->get('/tcp', ListTcpConnectionsController::class, $adminCondition);
$this->router->get('/api/statistics', GetStatisticsController::class, $adminCondition);
$this->router->get('/api/settings', GetSettingsController::class, $adminCondition);
$this->router->post('/api/settings', StoreSettingsController::class, $adminCondition);
$this->router->get('/api/users', GetUsersController::class, $adminCondition);
$this->router->post('/api/users', StoreUsersController::class, $adminCondition);
$this->router->get('/api/users/{id}', GetUserDetailsController::class, $adminCondition);
$this->router->delete('/api/users/{id}', DeleteUsersController::class, $adminCondition);
$this->router->get('/api/logs', GetLogsController::class, $adminCondition);
$this->router->get('/api/logs/{subdomain}', GetLogsForSubdomainController::class, $adminCondition);
$this->router->post('/api/domains', StoreDomainController::class, $adminCondition);
$this->router->delete('/api/domains/{domain}', DeleteSubdomainController::class, $adminCondition);
$this->router->post('/api/subdomains', StoreSubdomainController::class, $adminCondition);
$this->router->delete('/api/subdomains/{subdomain}', DeleteSubdomainController::class, $adminCondition);
$this->router->get('/api/sites', GetSitesController::class, $adminCondition);
$this->router->get('/api/sites/{site}', GetSiteDetailsController::class, $adminCondition);
$this->router->delete('/api/sites/{id}', DisconnectSiteController::class, $adminCondition);
$this->router->get('/api/tcp', GetTcpConnectionsController::class, $adminCondition);
$this->router->delete('/api/tcp/{id}', DisconnectTcpConnectionController::class, $adminCondition);
}
protected function bindConfiguration()
@@ -165,8 +202,12 @@ class Factory
$this->bindConfiguration()
->bindSubdomainGenerator()
->bindUserRepository()
->bindLoggerRepository()
->bindSubdomainRepository()
->bindDomainRepository()
->bindDatabase()
->ensureDatabaseIsInitialized()
->registerStatisticsCollector()
->bindConnectionManager()
->addAdminRoutes();
@@ -201,6 +242,33 @@ class Factory
return $this;
}
protected function bindSubdomainRepository()
{
app()->singleton(SubdomainRepository::class, function () {
return app(config('expose.admin.subdomain_repository', DatabaseSubdomainRepository::class));
});
return $this;
}
protected function bindLoggerRepository()
{
app()->singleton(LoggerRepository::class, function () {
return app(config('expose.admin.logger_repository', NullLogger::class));
});
return $this;
}
protected function bindDomainRepository()
{
app()->singleton(DomainRepository::class, function () {
return app(config('expose.admin.domain_repository', DatabaseDomainRepository::class));
});
return $this;
}
protected function bindDatabase()
{
app()->singleton(DatabaseInterface::class, function () {
@@ -227,7 +295,8 @@ class Factory
->files()
->ignoreDotFiles(true)
->in(database_path('migrations'))
->name('*.sql');
->name('*.sql')
->sortByName();
/** @var SplFileInfo $migration */
foreach ($migrations as $migration) {
@@ -243,4 +312,27 @@ class Factory
return $this;
}
protected function registerStatisticsCollector()
{
if (config('expose.admin.statistics.enable_statistics', true) === false) {
return $this;
}
app()->singleton(StatisticsRepository::class, function () {
return app(config('expose.admin.statistics.repository', DatabaseStatisticsRepository::class));
});
app()->singleton(StatisticsCollector::class, function () {
return app(DatabaseStatisticsCollector::class);
});
$intervalInSeconds = config('expose.admin.statistics.interval_in_seconds', 3600);
$this->loop->addPeriodicTimer($intervalInSeconds, function () {
app(StatisticsCollector::class)->save();
});
return $this;
}
}

View File

@@ -3,8 +3,8 @@
namespace App\Server\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
@@ -14,7 +14,7 @@ abstract class AdminController extends Controller
protected function shouldHandleRequest(Request $request, ConnectionInterface $httpConnection): bool
{
try {
$authorization = Str::after($request->header('Authorization'), 'Basic ');
$authorization = Str::after($request->header('Authorization', ''), 'Basic ');
$authParts = explode(':', base64_decode($authorization), 2);
[$user, $password] = $authParts;
@@ -24,9 +24,11 @@ abstract class AdminController extends Controller
return true;
} catch (\Throwable $e) {
$httpConnection->send(str(new Response(401, [
$httpConnection->send(Message::toString(new Response(401, [
'WWW-Authenticate' => 'Basic realm="Expose"',
])));
$httpConnection->close();
}
return false;

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class DeleteSubdomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var SubdomainRepository */
protected $subdomainRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository)
{
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->userRepository->getUserByToken($request->get('auth_token', ''))
->then(function ($user) use ($request, $httpConnection) {
if (is_null($user)) {
$httpConnection->send(respond_json(['error' => 'The user does not exist'], 404));
$httpConnection->close();
return;
}
$this->subdomainRepository->deleteSubdomainForUserId($user['id'], $request->get('subdomain'))
->then(function ($deleted) use ($httpConnection) {
$httpConnection->send(respond_json(['deleted' => $deleted], 200));
$httpConnection->close();
});
});
}
}

View File

@@ -22,7 +22,11 @@ class DisconnectSiteController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$connection = $this->connectionManager->findControlConnectionForClientId($request->get('id'));
if ($request->has('server_host')) {
$connection = $this->connectionManager->findControlConnectionForSubdomainAndServerHost($request->get('id'), $request->get('server_host'));
} else {
$connection = $this->connectionManager->findControlConnectionForClientId($request->get('id'));
}
if (! is_null($connection)) {
$connection->close();

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use App\Server\Connections\TcpControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class DisconnectTcpConnectionController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager)
{
$this->connectionManager = $connectionManager;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$connection = $this->connectionManager->findControlConnectionForClientId($request->get('id'));
if (! is_null($connection)) {
$connection->close();
$this->connectionManager->removeControlConnection($connection);
}
$httpConnection->send(respond_json([
'tcp_connections' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->values(),
]));
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\LoggerRepository;
use App\Server\Configuration;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetLogsController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var Configuration */
protected $configuration;
/** @var LoggerRepository */
protected $logger;
public function __construct(LoggerRepository $logger)
{
$this->logger = $logger;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$subdomain = $request->get('subdomain');
$this->logger->getLogs()
->then(function ($logs) use ($httpConnection) {
$httpConnection->send(
respond_json(['logs' => $logs])
);
$httpConnection->close();
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\LoggerRepository;
use App\Server\Configuration;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetLogsForSubdomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var Configuration */
protected $configuration;
/** @var LoggerRepository */
protected $logger;
public function __construct(LoggerRepository $logger)
{
$this->logger = $logger;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$subdomain = $request->get('subdomain');
$this->logger->getLogsBySubdomain($subdomain)
->then(function ($logs) use ($httpConnection) {
$httpConnection->send(
respond_json(['logs' => $logs])
);
$httpConnection->close();
});
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use App\Server\Connections\ControlConnection;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetSiteDetailsController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$domain = $request->get('site');
$connectedSite = collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === ControlConnection::class;
})
->first(function (ControlConnection $site) use ($domain) {
return "{$site->subdomain}.{$site->serverHost}" === $domain;
});
if (is_null($connectedSite)) {
$httpConnection->send(
Message::toString(new Response(404))
);
return;
}
$httpConnection->send(
respond_json($connectedSite->toArray())
);
}
}

View File

@@ -3,33 +3,63 @@
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Contracts\UserRepository;
use App\Server\Configuration;
use App\Server\Connections\ControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetSitesController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
/** @var UserRepository */
protected $userRepository;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration, UserRepository $userRepository)
{
$this->connectionManager = $connectionManager;
$this->userRepository = $userRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$httpConnection->send(
respond_json([
'sites' => collect($this->connectionManager->getConnections())->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
$authTokens = [];
$sites = collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === ControlConnection::class;
})
->map(function ($site, $siteId) use (&$authTokens) {
$site = $site->toArray();
$site['id'] = $siteId;
$authTokens[] = $site['auth_token'];
return $site;
})->values();
$this->userRepository->getUsersByTokens($authTokens)
->then(function ($users) use ($httpConnection, $sites) {
$users = collect($users);
$sites = collect($sites)->map(function ($site) use ($users) {
$site['user'] = $users->firstWhere('auth_token', $site['auth_token']);
return $site;
})->values(),
])
);
})->toArray();
$httpConnection->send(
respond_json([
'sites' => $sites,
])
);
$httpConnection->close();
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\StatisticsRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetStatisticsController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var StatisticsRepository */
protected $statisticsRepository;
public function __construct(StatisticsRepository $statisticsRepository)
{
$this->statisticsRepository = $statisticsRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$from = today()->subWeek()->toDateString();
$until = today()->toDateString();
$this->statisticsRepository->getStatistics($request->get('from', $from), $request->get('until', $until))
->then(function ($statistics) use ($httpConnection) {
$httpConnection->send(
respond_json([
'statistics' => $statistics,
])
);
$httpConnection->close();
});
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Contracts\UserRepository;
use App\Server\Configuration;
use App\Server\Connections\TcpControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class GetTcpConnectionsController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
/** @var UserRepository */
protected $userRepository;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration, UserRepository $userRepository)
{
$this->connectionManager = $connectionManager;
$this->userRepository = $userRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$authTokens = [];
$connections = collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($site, $siteId) use (&$authTokens) {
$site = $site->toArray();
$site['id'] = $siteId;
$authTokens[] = $site['auth_token'];
return $site;
})
->values();
$this->userRepository->getUsersByTokens($authTokens)
->then(function ($users) use ($httpConnection, $connections) {
$users = collect($users);
$connections = collect($connections)->map(function ($connection) use ($users) {
$connection['user'] = $users->firstWhere('auth_token', $connection['auth_token']);
return $connection;
})->toArray();
$httpConnection->send(
respond_json([
'tcp_connections' => $connections,
])
);
$httpConnection->close();
});
}
}

View File

@@ -2,6 +2,7 @@
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
@@ -13,21 +14,46 @@ class GetUserDetailsController extends AdminController
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository)
/** @var SubdomainRepository */
protected $subdomainRepository;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository)
{
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->userRepository
->getUserById($request->get('id'))
->then(function ($user) use ($httpConnection) {
$id = $request->get('id');
if (! is_numeric($id)) {
$promise = $this->userRepository->getUserByToken($id);
} else {
$promise = $this->userRepository->getUserById($id);
}
$promise->then(function ($user) use ($httpConnection) {
if (is_null($user)) {
$httpConnection->send(
respond_json(['user' => $user])
respond_json([], 404)
);
$httpConnection->close();
});
return;
}
$this->subdomainRepository->getSubdomainsByUserId($user['id'])
->then(function ($subdomains) use ($httpConnection, $user) {
$httpConnection->send(
respond_json([
'user' => $user,
'subdomains' => $subdomains,
])
);
$httpConnection->close();
});
});
}
}

View File

@@ -21,7 +21,7 @@ class GetUsersController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->userRepository
->paginateUsers(20, (int) $request->get('page', 1))
->paginateUsers($request->get('search', ''), (int) $request->get('perPage', 20), (int) $request->get('page', 1))
->then(function ($paginated) use ($httpConnection) {
$httpConnection->send(
respond_json(['paginated' => $paginated])

View File

@@ -25,12 +25,6 @@ class ListSitesController extends AdminController
$sites = $this->getView($httpConnection, 'server.sites.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration,
'sites' => collect($this->connectionManager->getConnections())->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
return $site;
})->values(),
]);
$httpConnection->send(

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
class ListTcpConnectionsController extends AdminController
{
/** @var ConnectionManager */
protected $connectionManager;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
$this->configuration = $configuration;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$sites = $this->getView($httpConnection, 'server.tcp.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration,
]);
$httpConnection->send(
respond_html($sites)
);
}
}

View File

@@ -21,7 +21,7 @@ class ListUsersController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->userRepository
->paginateUsers(20, (int) $request->get('page', 1))
->paginateUsers($request->get('search', ''), 20, (int) $request->get('page', 1))
->then(function ($paginated) use ($httpConnection) {
$httpConnection->send(
respond_html($this->getView($httpConnection, 'server.users.index', ['paginated' => $paginated]))

View File

@@ -2,17 +2,16 @@
namespace App\Server\Http\Controllers\Admin;
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Ratchet\ConnectionInterface;
class RedirectToUsersController extends AdminController
{
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$httpConnection->send(str(new Response(301, [
$httpConnection->send(Message::toString(new Response(301, [
'Location' => '/sites',
])));
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\DomainRepository;
use App\Contracts\UserRepository;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Ratchet\ConnectionInterface;
class StoreDomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var DomainRepository */
protected $domainRepository;
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, DomainRepository $domainRepository)
{
$this->userRepository = $userRepository;
$this->domainRepository = $domainRepository;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$validator = Validator::make($request->all(), [
'domain' => 'required',
], [
'required' => 'The :attribute field is required.',
]);
if ($validator->fails()) {
$httpConnection->send(respond_json(['errors' => $validator->getMessageBag()], 401));
$httpConnection->close();
return;
}
$this->userRepository
->getUserByToken($request->get('auth_token', ''))
->then(function ($user) use ($httpConnection, $request) {
if (is_null($user)) {
$httpConnection->send(respond_json(['error' => 'The user does not exist'], 404));
$httpConnection->close();
return;
}
if ($user['can_specify_domains'] === 0) {
$httpConnection->send(respond_json(['error' => 'The user is not allowed to reserve custom domains.'], 401));
$httpConnection->close();
return;
}
$insertData = [
'user_id' => $user['id'],
'domain' => $request->get('domain'),
];
$this->domainRepository
->storeDomain($insertData)
->then(function ($domain) use ($httpConnection) {
$httpConnection->send(respond_json(['domain' => $domain], 200));
$httpConnection->close();
});
});
}
}

View File

@@ -31,6 +31,14 @@ class StoreSettingsController extends AdminController
config()->set('expose.admin.messages.message_of_the_day', Arr::get($messages, 'message_of_the_day'));
config()->set('expose.admin.messages.custom_subdomain_unauthorized', Arr::get($messages, 'custom_subdomain_unauthorized'));
config()->set('expose.admin.messages.no_free_tcp_port_available', Arr::get($messages, 'no_free_tcp_port_available'));
config()->set('expose.admin.messages.tcp_port_sharing_unauthorized', Arr::get($messages, 'tcp_port_sharing_unauthorized'));
config()->set('expose.admin.messages.tcp_port_sharing_disabled', Arr::get($messages, 'tcp_port_sharing_disabled'));
$httpConnection->send(
respond_json([
'configuration' => $this->configuration,

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use App\Server\Configuration;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Ratchet\ConnectionInterface;
class StoreSubdomainController extends AdminController
{
protected $keepConnectionOpen = true;
/** @var SubdomainRepository */
protected $subdomainRepository;
/** @var UserRepository */
protected $userRepository;
/** @var Configuration */
protected $configuration;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository, Configuration $configuration)
{
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
$this->configuration = $configuration;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$validator = Validator::make($request->all(), [
'subdomain' => 'required',
], [
'required' => 'The :attribute field is required.',
]);
if ($validator->fails()) {
$httpConnection->send(respond_json(['errors' => $validator->getMessageBag()], 401));
$httpConnection->close();
return;
}
$this->userRepository
->getUserByToken($request->get('auth_token', ''))
->then(function ($user) use ($httpConnection, $request) {
if (is_null($user)) {
$httpConnection->send(respond_json(['error' => 'The user does not exist'], 404));
$httpConnection->close();
return;
}
if ($user['can_specify_subdomains'] === 0) {
$httpConnection->send(respond_json(['error' => 'The user is not allowed to reserve subdomains.'], 401));
$httpConnection->close();
return;
}
if (in_array($request->get('subdomain'), config('expose.admin.reserved_subdomains', []))) {
$httpConnection->send(respond_json(['error' => 'The subdomain is already taken.'], 422));
$httpConnection->close();
return;
}
$insertData = [
'user_id' => $user['id'],
'subdomain' => $request->get('subdomain'),
'domain' => $request->get('domain', $this->configuration->hostname()),
];
$this->subdomainRepository
->storeSubdomain($insertData)
->then(function ($subdomain) use ($httpConnection) {
$httpConnection->send(respond_json(['subdomain' => $subdomain], 200));
$httpConnection->close();
});
});
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Server\Http\Controllers\Admin;
use App\Contracts\UserRepository;
use function GuzzleHttp\Psr7\str;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
@@ -38,7 +37,11 @@ class StoreUsersController extends AdminController
$insertData = [
'name' => $request->get('name'),
'auth_token' => (string) Str::uuid(),
'auth_token' => $request->get('token', (string) Str::uuid()),
'can_specify_subdomains' => (int) $request->get('can_specify_subdomains'),
'can_specify_domains' => (int) $request->get('can_specify_domains'),
'can_share_tcp_ports' => (int) $request->get('can_share_tcp_ports'),
'max_connections' => (int) $request->get('max_connections'),
];
$this->userRepository

View File

@@ -3,12 +3,18 @@
namespace App\Server\Http\Controllers;
use App\Contracts\ConnectionManager;
use App\Contracts\DomainRepository;
use App\Contracts\SubdomainRepository;
use App\Contracts\UserRepository;
use App\Http\QueryParameters;
use App\Server\Configuration;
use App\Server\Exceptions\NoFreePortAvailable;
use Illuminate\Support\Arr;
use Ratchet\ConnectionInterface;
use Ratchet\WebSocket\MessageComponentInterface;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
use function React\Promise\reject;
use stdClass;
class ControlMessageController implements MessageComponentInterface
@@ -19,10 +25,22 @@ class ControlMessageController implements MessageComponentInterface
/** @var UserRepository */
protected $userRepository;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository)
/** @var SubdomainRepository */
protected $subdomainRepository;
/** @var DomainRepository */
protected $domainRepository;
/** @var Configuration */
protected $configuration;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository, SubdomainRepository $subdomainRepository, Configuration $configuration, DomainRepository $domainRepository)
{
$this->connectionManager = $connectionManager;
$this->userRepository = $userRepository;
$this->subdomainRepository = $subdomainRepository;
$this->domainRepository = $domainRepository;
$this->configuration = $configuration;
}
/**
@@ -53,6 +71,10 @@ class ControlMessageController implements MessageComponentInterface
if (isset($connection->request_id)) {
return $this->sendResponseToHttpConnection($connection->request_id, $msg);
}
if (isset($connection->tcp_request_id)) {
$connectionInfo = $this->connectionManager->findControlConnectionForClientId($connection->tcp_client_id);
$connectionInfo->proxyConnection->write($msg);
}
try {
$payload = json_decode($msg);
@@ -75,24 +97,48 @@ class ControlMessageController implements MessageComponentInterface
protected function authenticate(ConnectionInterface $connection, $data)
{
if (! isset($data->subdomain)) {
$data->subdomain = null;
}
if (! isset($data->type)) {
$data->type = 'http';
}
if (! isset($data->server_host) || is_null($data->server_host)) {
$data->server_host = $this->configuration->hostname();
}
$this->verifyAuthToken($connection)
->then(function () use ($connection, $data) {
if (! $this->hasValidSubdomain($connection, $data->subdomain)) {
return;
->then(function ($user) use ($connection) {
$maximumConnectionCount = config('expose.admin.maximum_open_connections_per_user', 0);
if (is_null($user)) {
$connectionCount = count($this->connectionManager->findControlConnectionsForIp($connection->remoteAddress));
} else {
$maximumConnectionCount = Arr::get($user, 'max_connections', $maximumConnectionCount);
$connectionCount = count($this->connectionManager->findControlConnectionsForAuthToken($user['auth_token']));
}
$connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection);
if ($maximumConnectionCount > 0 && $connectionCount + 1 > $maximumConnectionCount) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.maximum_connection_count'),
],
]));
$connection->close();
$this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length'));
reject(null);
}
$connection->send(json_encode([
'event' => 'authenticated',
'data' => [
'message' => config('expose.admin.messages.message_of_the_day'),
'subdomain' => $connectionInfo->subdomain,
'client_id' => $connectionInfo->client_id,
],
]));
return $user;
})
->then(function ($user) use ($connection, $data) {
if ($data->type === 'http') {
$this->handleHttpConnection($connection, $data, $user);
} elseif ($data->type === 'tcp') {
$this->handleTcpConnection($connection, $data, $user);
}
}, function () use ($connection) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
@@ -104,6 +150,91 @@ class ControlMessageController implements MessageComponentInterface
});
}
protected function resolveConnectionMessage($connectionInfo, $user)
{
$deferred = new Deferred();
$connectionMessageResolver = config('expose.admin.messages.resolve_connection_message')($connectionInfo, $user);
if ($connectionMessageResolver instanceof PromiseInterface) {
$connectionMessageResolver->then(function ($connectionMessage) use ($connectionInfo, $deferred) {
$connectionInfo->message = $connectionMessage;
$deferred->resolve($connectionInfo);
});
} else {
$connectionInfo->message = $connectionMessageResolver;
return \React\Promise\resolve($connectionInfo);
}
return $deferred->promise();
}
protected function handleHttpConnection(ConnectionInterface $connection, $data, $user = null)
{
$this->hasValidDomain($connection, $data->server_host, $user)
->then(function () use ($connection, $data, $user) {
return $this->hasValidSubdomain($connection, $data->subdomain, $user, $data->server_host);
})
->then(function ($subdomain) use ($data, $connection, $user) {
if ($subdomain === false) {
return;
}
$data->subdomain = $subdomain;
$connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $data->server_host, $connection);
$this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length'));
return $this->resolveConnectionMessage($connectionInfo, $user);
})
->then(function ($connectionInfo) use ($connection, $user) {
$connection->send(json_encode([
'event' => 'authenticated',
'data' => [
'message' => $connectionInfo->message,
'subdomain' => $connectionInfo->subdomain,
'server_host' => $connectionInfo->serverHost,
'user' => $user,
'client_id' => $connectionInfo->client_id,
],
]));
});
}
protected function handleTcpConnection(ConnectionInterface $connection, $data, $user = null)
{
if (! $this->canShareTcpPorts($connection, $data, $user)) {
return;
}
try {
$connectionInfo = $this->connectionManager->storeTcpConnection($data->port, $connection);
} catch (NoFreePortAvailable $exception) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.no_free_tcp_port_available'),
],
]));
$connection->close();
return;
}
$connection->send(json_encode([
'event' => 'authenticated',
'data' => [
'message' => config('expose.admin.messages.resolve_connection_message')($connectionInfo, $user),
'user' => $user,
'port' => $connectionInfo->port,
'shared_port' => $connectionInfo->shared_port,
'client_id' => $connectionInfo->client_id,
],
]));
}
protected function registerProxy(ConnectionInterface $connection, $data)
{
$connection->request_id = $data->request_id;
@@ -115,6 +246,18 @@ class ControlMessageController implements MessageComponentInterface
]);
}
protected function registerTcpProxy(ConnectionInterface $connection, $data)
{
$connection->tcp_client_id = $data->client_id;
$connection->tcp_request_id = $data->tcp_request_id;
$connectionInfo = $this->connectionManager->findControlConnectionForClientId($data->client_id);
$connectionInfo->emit('tcp_proxy_ready_'.$data->tcp_request_id, [
$connection,
]);
}
/**
* {@inheritdoc}
*/
@@ -139,31 +282,141 @@ class ControlMessageController implements MessageComponentInterface
if (is_null($user)) {
$deferred->reject();
} else {
$deferred->resolve($user);
$this->userRepository
->updateLastSharedAt($user['id'])
->then(function () use ($deferred, $user) {
$deferred->resolve($user);
});
}
});
return $deferred->promise();
}
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain): bool
protected function hasValidDomain(ConnectionInterface $connection, ?string $serverHost, ?array $user): PromiseInterface
{
if (! is_null($user) && $serverHost !== $this->configuration->hostname()) {
$deferred = new Deferred();
$this->domainRepository
->getDomainsByUserId($user['id'])
->then(function ($domains) use ($connection, $deferred, $serverHost) {
$userDomain = collect($domains)->first(function ($domain) use ($serverHost) {
return strtolower($domain['domain']) === strtolower($serverHost);
});
if (is_null($userDomain)) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.custom_domain_unauthorized').PHP_EOL,
],
]));
$connection->close();
$deferred->reject(null);
return;
}
$deferred->resolve(null);
});
return $deferred->promise();
} else {
return \React\Promise\resolve(null);
}
}
protected function hasValidSubdomain(ConnectionInterface $connection, ?string $subdomain, ?array $user, string $serverHost): PromiseInterface
{
/**
* Check if the user can specify a custom subdomain in the first place.
*/
if (! is_null($user) && $user['can_specify_subdomains'] === 0 && ! is_null($subdomain)) {
$connection->send(json_encode([
'event' => 'error',
'data' => [
'message' => config('expose.admin.messages.custom_subdomain_unauthorized').PHP_EOL,
],
]));
return \React\Promise\resolve(null);
}
/**
* Check if the given subdomain is reserved for a different user.
*/
if (! is_null($subdomain)) {
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) {
$message = config('expose.admin.messages.subdomain_taken');
$message = str_replace(':subdomain', $subdomain, $message);
return $this->subdomainRepository->getSubdomainsByNameAndDomain($subdomain, $serverHost)
->then(function ($foundSubdomains) use ($connection, $subdomain, $user, $serverHost) {
$ownSubdomain = collect($foundSubdomains)->first(function ($subdomain) use ($user) {
return $subdomain['user_id'] === $user['id'];
});
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
if (count($foundSubdomains) > 0 && ! is_null($user) && is_null($ownSubdomain)) {
$message = config('expose.admin.messages.subdomain_reserved', '');
$message = str_replace(':subdomain', $subdomain, $message);
return false;
}
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
return \React\Promise\resolve(false);
}
$controlConnection = $this->connectionManager->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost);
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain') || in_array($subdomain, config('expose.admin.reserved_subdomains', []))) {
$message = config('expose.admin.messages.subdomain_taken');
$message = str_replace(':subdomain', $subdomain, $message);
$connection->send(json_encode([
'event' => 'subdomainTaken',
'data' => [
'message' => $message,
],
]));
$connection->close();
return \React\Promise\resolve(false);
}
return \React\Promise\resolve($subdomain);
});
}
return \React\Promise\resolve($subdomain);
}
protected function canShareTcpPorts(ConnectionInterface $connection, $data, $user)
{
if (! config('expose.admin.allow_tcp_port_sharing', true)) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.tcp_port_sharing_disabled'),
],
]));
$connection->close();
return false;
}
if (! is_null($user) && $user['can_share_tcp_ports'] === 0) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.tcp_port_sharing_unauthorized'),
],
]));
$connection->close();
return false;
}
return true;

View File

@@ -3,6 +3,7 @@
namespace App\Server\Http\Controllers;
use App\Contracts\ConnectionManager;
use App\Contracts\StatisticsCollector;
use App\Http\Controllers\Controller;
use App\Server\Configuration;
use App\Server\Connections\ControlConnection;
@@ -27,15 +28,20 @@ class TunnelMessageController extends Controller
protected $modifiers = [];
public function __construct(ConnectionManager $connectionManager, Configuration $configuration)
/** @var StatisticsCollector */
protected $statisticsCollector;
public function __construct(ConnectionManager $connectionManager, StatisticsCollector $statisticsCollector, Configuration $configuration)
{
$this->connectionManager = $connectionManager;
$this->configuration = $configuration;
$this->statisticsCollector = $statisticsCollector;
}
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$subdomain = $this->detectSubdomain($request);
$serverHost = $this->detectServerHost($request);
if (is_null($subdomain)) {
$httpConnection->send(
@@ -46,7 +52,7 @@ class TunnelMessageController extends Controller
return;
}
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
$controlConnection = $this->connectionManager->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost);
if (is_null($controlConnection)) {
$httpConnection->send(
@@ -57,14 +63,23 @@ class TunnelMessageController extends Controller
return;
}
$this->statisticsCollector->incomingRequest();
$this->sendRequestToClient($request, $controlConnection, $httpConnection);
}
protected function detectSubdomain(Request $request): ?string
{
$subdomain = Str::before($request->getHost(), '.'.$this->configuration->hostname());
$serverHost = $this->detectServerHost($request);
return $subdomain === $request->getHost() ? null : $subdomain;
$subdomain = Str::before($request->header('Host'), '.'.$serverHost);
return $subdomain === $request->header('Host') ? null : $subdomain;
}
protected function detectServerHost(Request $request): ?string
{
return Str::before(Str::after($request->header('Host'), '.'), ':');
}
protected function sendRequestToClient(Request $request, ControlConnection $controlConnection, ConnectionInterface $httpConnection)
@@ -75,7 +90,7 @@ class TunnelMessageController extends Controller
$httpConnection = $this->connectionManager->storeHttpConnection($httpConnection, $requestId);
transform($this->passRequestThroughModifiers($request, $httpConnection), function (Request $request) use ($controlConnection , $requestId) {
transform($this->passRequestThroughModifiers($request, $httpConnection), function (Request $request) use ($controlConnection, $requestId) {
$controlConnection->once('proxy_ready_'.$requestId, function (ConnectionInterface $proxy) use ($request) {
// Convert the Laravel request into a PSR7 request
$psr17Factory = new Psr17Factory();
@@ -107,7 +122,7 @@ class TunnelMessageController extends Controller
{
$request::setTrustedProxies([$controlConnection->socket->remoteAddress, '127.0.0.1'], Request::HEADER_X_FORWARDED_ALL);
$host = $this->configuration->hostname();
$host = $controlConnection->serverHost;
if (! $request->isSecure()) {
$host .= ":{$this->configuration->port()}";

View File

@@ -35,6 +35,7 @@ class Router implements HttpServerInterface
/**
* {@inheritdoc}
*
* @throws \UnexpectedValueException If a controller is not \Ratchet\Http\HttpServerInterface
*/
public function onOpen(ConnectionInterface $conn, RequestInterface $request = null)

View File

@@ -0,0 +1,83 @@
<?php
namespace App\Server\LoggerRepository;
use App\Contracts\LoggerRepository;
use App\Contracts\UserRepository;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class DatabaseLogger implements LoggerRepository
{
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function logSubdomain($authToken, $subdomain)
{
app(UserRepository::class)->getUserByToken($authToken)
->then(function ($user) use ($subdomain) {
$this->database->query("
INSERT INTO logs (user_id, subdomain, created_at)
VALUES (:user_id, :subdomain, DATETIME('now'))
", [
'user_id' => $user['id'],
'subdomain' => $subdomain,
])->then(function () {
$this->cleanOldLogs();
});
});
}
public function cleanOldLogs()
{
$this->database->query("DELETE FROM logs WHERE created_at < date('now', '-30 day')");
}
public function getLogsBySubdomain($subdomain): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('
SELECT
logs.id AS log_id,
logs.subdomain,
users.*
FROM logs
INNER JOIN users
ON users.id = logs.user_id
WHERE logs.subdomain = :subdomain', ['subdomain' => $subdomain])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function getLogs(): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('
SELECT
logs.id AS log_id,
logs.subdomain,
users.*
FROM logs
INNER JOIN users
ON users.id = logs.user_id')
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Server\LoggerRepository;
use App\Contracts\LoggerRepository;
use React\Promise\PromiseInterface;
class NullLogger implements LoggerRepository
{
public function logSubdomain($authToken, $subdomain)
{
// noop
}
public function getLogsBySubdomain($subdomain): PromiseInterface
{
return \React\Promise\resolve([]);
}
public function getLogs(): PromiseInterface
{
return \React\Promise\resolve([]);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace App\Server\StatisticsCollector;
use App\Contracts\StatisticsCollector;
use Clue\React\SQLite\DatabaseInterface;
class DatabaseStatisticsCollector implements StatisticsCollector
{
/** @var DatabaseInterface */
protected $database;
/** @var array */
protected $sharedPorts = [];
/** @var array */
protected $sharedSites = [];
/** @var int */
protected $requests = 0;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
/**
* Flush the stored statistics.
*
* @return void
*/
public function flush()
{
$this->sharedPorts = [];
$this->sharedSites = [];
$this->requests = 0;
}
public function siteShared($authToken = null)
{
if (! $this->shouldCollectStatistics()) {
return;
}
if (! isset($this->sharedSites[$authToken])) {
$this->sharedSites[$authToken] = 0;
}
$this->sharedSites[$authToken]++;
}
public function portShared($authToken = null)
{
if (! $this->shouldCollectStatistics()) {
return;
}
if (! isset($this->sharedPorts[$authToken])) {
$this->sharedPorts[$authToken] = 0;
}
$this->sharedPorts[$authToken]++;
}
public function incomingRequest()
{
if (! $this->shouldCollectStatistics()) {
return;
}
$this->requests++;
}
public function save()
{
$sharedSites = 0;
collect($this->sharedSites)->map(function ($numSites) use (&$sharedSites) {
$sharedSites += $numSites;
});
$sharedPorts = 0;
collect($this->sharedPorts)->map(function ($numPorts) use (&$sharedPorts) {
$sharedPorts += $numPorts;
});
$this->database->query('
INSERT INTO statistics (timestamp, shared_sites, shared_ports, unique_shared_sites, unique_shared_ports, incoming_requests)
VALUES (:timestamp, :shared_sites, :shared_ports, :unique_shared_sites, :unique_shared_ports, :incoming_requests)
', [
'timestamp' => today()->toDateString(),
'shared_sites' => $sharedSites,
'shared_ports' => $sharedPorts,
'unique_shared_sites' => count($this->sharedSites),
'unique_shared_ports' => count($this->sharedPorts),
'incoming_requests' => $this->requests,
])
->then(function () {
$this->flush();
});
}
public function shouldCollectStatistics(): bool
{
return config('expose.admin.statistics.enable_statistics', true);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Server\StatisticsRepository;
use App\Contracts\StatisticsRepository;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class DatabaseStatisticsRepository implements StatisticsRepository
{
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function getStatistics($from, $until): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT
timestamp,
SUM(shared_sites) as shared_sites,
SUM(shared_ports) as shared_ports,
SUM(unique_shared_sites) as unique_shared_sites,
SUM(unique_shared_ports) as unique_shared_ports,
SUM(incoming_requests) as incoming_requests
FROM statistics
WHERE
`timestamp` >= "'.$from.'" AND `timestamp` <= "'.$until.'"')
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
}

View File

@@ -0,0 +1,155 @@
<?php
namespace App\Server\SubdomainRepository;
use App\Contracts\SubdomainRepository;
use Clue\React\SQLite\DatabaseInterface;
use Clue\React\SQLite\Result;
use React\Promise\Deferred;
use React\Promise\PromiseInterface;
class DatabaseSubdomainRepository implements SubdomainRepository
{
/** @var DatabaseInterface */
protected $database;
public function __construct(DatabaseInterface $database)
{
$this->database = $database;
}
public function getSubdomains(): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains ORDER by created_at DESC')
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function getSubdomainById($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE id = :id', ['id' => $id])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getSubdomainByName(string $name): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE subdomain = :name', ['name' => $name])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getSubdomainByNameAndDomain(string $name, string $domain): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE subdomain = :name AND domain = :domain', [
'name' => $name,
'domain' => $domain,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
});
return $deferred->promise();
}
public function getSubdomainsByNameAndDomain(string $name, string $domain): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE subdomain = :name AND domain = :domain', [
'name' => $name,
'domain' => $domain,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function getSubdomainsByUserId($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE user_id = :user_id ORDER by created_at DESC', [
'user_id' => $id,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function storeSubdomain(array $data): PromiseInterface
{
$deferred = new Deferred();
$this->database->query("
INSERT INTO subdomains (user_id, subdomain, domain, created_at)
VALUES (:user_id, :subdomain, :domain, DATETIME('now'))
", $data)
->then(function (Result $result) use ($deferred) {
$this->database->query('SELECT * FROM subdomains WHERE id = :id', ['id' => $result->insertId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
});
return $deferred->promise();
}
public function getSubdomainsByUserIdAndName($id, $name): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM subdomains WHERE user_id = :user_id AND subdomain = :name ORDER by created_at DESC', [
'user_id' => $id,
'name' => $name,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows);
});
return $deferred->promise();
}
public function deleteSubdomainForUserId($userId, $subdomainId): PromiseInterface
{
$deferred = new Deferred();
$this->database->query('DELETE FROM subdomains WHERE (id = :id OR subdomain = :id) AND user_id = :user_id', [
'id' => $subdomainId,
'user_id' => $userId,
])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result);
});
return $deferred->promise();
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Server\Support;
use App\Server\Connections\ControlConnection;
use Clue\React\Buzz\Browser;
use Psr\Http\Message\ResponseInterface;
class RetrieveWelcomeMessageFromApi
{
/** @var Browser */
protected $browser;
/** @var string */
protected $url;
public function __construct(Browser $browser)
{
$this->browser = $browser;
$this->url = config('expose.admin.welcome_message_api_url');
}
public function forUser(ControlConnection $connectionInfo, $user)
{
return $this->browser
->post($this->url, [
'Content-Type' => 'application/json',
'Accept' => 'application/json',
], json_encode([
'user' => $user,
'connectionInfo' => $connectionInfo->toArray(),
]))
->then(function (ResponseInterface $response) {
$result = json_decode($response->getBody());
return $result->message ?? '';
}, function (Exception $e) {
return '';
});
}
}

View File

@@ -36,34 +36,52 @@ class DatabaseUserRepository implements UserRepository
return $deferred->promise();
}
public function paginateUsers(int $perPage, int $currentPage): PromiseInterface
public function paginateUsers(string $searchQuery, int $perPage, int $currentPage): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query('SELECT * FROM users ORDER by created_at DESC LIMIT :limit OFFSET :offset', [
'limit' => $perPage + 1,
'offset' => $currentPage < 2 ? 0 : ($currentPage - 1) * $perPage,
])
->then(function (Result $result) use ($deferred, $perPage, $currentPage) {
if (count($result->rows) == $perPage + 1) {
array_pop($result->rows);
$nextPage = $currentPage + 1;
}
->query('SELECT COUNT(*) AS count FROM users')
->then(function (Result $result) use ($searchQuery, $deferred, $perPage, $currentPage) {
$totalUsers = $result->rows[0]['count'];
$users = collect($result->rows)->map(function ($user) {
return $this->getUserDetails($user);
})->toArray();
$query = 'SELECT * FROM users ';
$paginated = [
'users' => $users,
'current_page' => $currentPage,
'per_page' => $perPage,
'next_page' => $nextPage ?? null,
'previous_page' => $currentPage > 1 ? $currentPage - 1 : null,
$bindings = [
'limit' => $perPage + 1,
'offset' => $currentPage < 2 ? 0 : ($currentPage - 1) * $perPage,
];
$deferred->resolve($paginated);
if ($searchQuery !== '') {
$query .= "WHERE name LIKE '%".$searchQuery."%' ";
$bindings['search'] = $searchQuery;
}
$query .= ' ORDER by created_at DESC LIMIT :limit OFFSET :offset';
$this->database
->query($query, $bindings)
->then(function (Result $result) use ($deferred, $perPage, $currentPage, $totalUsers) {
if (count($result->rows) == $perPage + 1) {
array_pop($result->rows);
$nextPage = $currentPage + 1;
}
$users = collect($result->rows)->map(function ($user) {
return $this->getUserDetails($user);
})->toArray();
$paginated = [
'total' => $totalUsers,
'users' => $users,
'current_page' => $currentPage,
'per_page' => $perPage,
'next_page' => $nextPage ?? null,
'previous_page' => $currentPage > 1 ? $currentPage - 1 : null,
];
$deferred->resolve($paginated);
});
});
return $deferred->promise();
@@ -72,6 +90,7 @@ class DatabaseUserRepository implements UserRepository
protected function getUserDetails(array $user)
{
$user['sites'] = $user['auth_token'] !== '' ? $this->connectionManager->getConnectionsForAuthToken($user['auth_token']) : [];
$user['tcp_connections'] = $user['auth_token'] !== '' ? $this->connectionManager->getTcpConnectionsForAuthToken($user['auth_token']) : [];
return $user;
}
@@ -95,6 +114,19 @@ class DatabaseUserRepository implements UserRepository
return $deferred->promise();
}
public function updateLastSharedAt($id): PromiseInterface
{
$deferred = new Deferred();
$this->database
->query("UPDATE users SET last_shared_at = date('now') WHERE id = :id", ['id' => $id])
->then(function (Result $result) use ($deferred) {
$deferred->resolve();
});
return $deferred->promise();
}
public function getUserByToken(string $authToken): PromiseInterface
{
$deferred = new Deferred();
@@ -102,7 +134,13 @@ class DatabaseUserRepository implements UserRepository
$this->database
->query('SELECT * FROM users WHERE auth_token = :token', ['token' => $authToken])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0] ?? null);
$user = $result->rows[0] ?? null;
if (! is_null($user)) {
$user = $this->getUserDetails($user);
}
$deferred->resolve($user);
});
return $deferred->promise();
@@ -112,15 +150,38 @@ class DatabaseUserRepository implements UserRepository
{
$deferred = new Deferred();
$this->database->query("
INSERT INTO users (name, auth_token, created_at)
VALUES (:name, :auth_token, DATETIME('now'))
$this->getUserByToken($data['auth_token'])
->then(function ($existingUser) use ($data, $deferred) {
if (is_null($existingUser)) {
$this->database->query("
INSERT INTO users (name, auth_token, can_specify_subdomains, can_specify_domains, can_share_tcp_ports, max_connections, created_at)
VALUES (:name, :auth_token, :can_specify_subdomains, :can_specify_domains, :can_share_tcp_ports, :max_connections, DATETIME('now'))
", $data)
->then(function (Result $result) use ($deferred) {
$this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
->then(function (Result $result) use ($deferred) {
$this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $result->insertId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
});
} else {
$this->database->query('
UPDATE users
SET
name = :name,
can_specify_subdomains = :can_specify_subdomains,
can_specify_domains = :can_specify_domains,
can_share_tcp_ports = :can_share_tcp_ports,
max_connections = :max_connections
WHERE
auth_token = :auth_token
', $data)
->then(function (Result $result) use ($existingUser, $deferred) {
$this->database->query('SELECT * FROM users WHERE id = :id', ['id' => $existingUser['id']])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
});
}
});
return $deferred->promise();
@@ -130,11 +191,31 @@ class DatabaseUserRepository implements UserRepository
{
$deferred = new Deferred();
$this->database->query('DELETE FROM users WHERE id = :id', ['id' => $id])
$this->database->query('DELETE FROM users WHERE id = :id OR auth_token = :id', ['id' => $id])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result);
});
return $deferred->promise();
}
public function getUsersByTokens(array $authTokens): PromiseInterface
{
$deferred = new Deferred();
$authTokenString = collect($authTokens)->map(function ($token) {
return '"'.$token.'"';
})->join(',');
$this->database->query('SELECT * FROM users WHERE auth_token IN ('.$authTokenString.')')
->then(function (Result $result) use ($deferred) {
$users = collect($result->rows)->map(function ($user) {
return $this->getUserDetails($user);
})->toArray();
$deferred->resolve($users);
});
return $deferred->promise();
}
}

View File

@@ -1,11 +1,11 @@
<?php
use GuzzleHttp\Psr7\Message;
use GuzzleHttp\Psr7\Response;
use function GuzzleHttp\Psr7\str;
function respond_json($responseData, int $statusCode = 200)
{
return str(new Response(
return Message::toString(new Response(
$statusCode,
['Content-Type' => 'application/json'],
json_encode($responseData, JSON_INVALID_UTF8_IGNORE)
@@ -14,7 +14,7 @@ function respond_json($responseData, int $statusCode = 200)
function respond_html(string $html, int $statusCode = 200)
{
return str(new Response(
return Message::toString(new Response(
$statusCode,
['Content-Type' => 'text/html'],
$html

2
builds/.gitignore vendored
View File

@@ -1,2 +0,0 @@
!.gitignore
*

Binary file not shown.

View File

@@ -1,51 +1,56 @@
{
"name": "beyondcode/expose",
"name": "bitinflow/expose",
"type": "project",
"description": "Expose",
"description": "Create public URLs for local sites through any firewall and VPN.",
"keywords": [
"expose",
"tunnel",
"ngrok"
],
"homepage": "https://sharedwithexpose.com",
"homepage": "https://bitinflow.dev",
"license": "MIT",
"authors": [
{
"name": "René Preuß",
"email": "rene@bitinflow.com"
},
{
"name": "Marcel Pociot",
"email": "marcel@beyondco.de"
}
],
"require": {
"php": "^7.3.0",
"php": "^8.0",
"ext-json": "*",
"padraic/phar-updater": "^1.0.6"
"laravel-zero/phar-updater": "^1.2"
},
"require-dev": {
"cboden/ratchet": "^0.4.2",
"clue/block-react": "^1.3",
"clue/buzz-react": "^2.7",
"cboden/ratchet": "^0.4.3",
"clue/block-react": "^1.4",
"clue/buzz-react": "^2.9",
"clue/reactphp-sqlite": "dev-modular-worker-for-phar-support",
"guzzlehttp/guzzle": "^6.5",
"guzzlehttp/psr7": "dev-master as 1.6.1",
"illuminate/http": "5.8.* || ^6.0 || ^7.0",
"illuminate/pipeline": "^7.6",
"illuminate/validation": "^7.7",
"laminas/laminas-http": "^2.11",
"laravel-zero/framework": "^7.0",
"mockery/mockery": "^1.3",
"namshi/cuzzle": "^2.0",
"nikic/php-parser": "^4.4",
"nyholm/psr7": "^1.2",
"phpunit/phpunit": "^8.5",
"ratchet/pawl": "^0.3.4",
"react/http": "^0.8.6",
"react/socket": "dev-master as 1.1",
"guzzlehttp/guzzle": "^7.2",
"guzzlehttp/psr7": "^1.7",
"illuminate/log": "^8.0",
"illuminate/http": "5.8.* || ^6.0 || ^7.0 || ^8.0",
"illuminate/pipeline": "^7.6 || ^8.0",
"illuminate/validation": "^7.7 || ^8.0",
"laminas/laminas-http": "^2.13",
"laravel-zero/framework": "^8.2",
"mockery/mockery": "^1.4.2",
"octoper/cuzzle": "^3.1",
"nikic/php-parser": "^v4.10",
"nyholm/psr7": "^1.3",
"phpunit/phpunit": "^9.4.3",
"ratchet/pawl": "^0.3.5",
"react/http": "^1.1.0",
"react/socket": "^1.6",
"react/stream": "^1.1.1",
"riverline/multipart-parser": "^2.0",
"symfony/expression-language": "^5.0",
"symfony/http-kernel": "^4.0 || ^5.0",
"symfony/expression-language": "^5.2",
"symfony/http-kernel": "^4.0 || ^5.2",
"symfony/psr-http-message-bridge": "^2.0",
"twig/twig": "^3.0"
"twig/twig": "^3.1"
},
"config": {
"optimize-autoloader": true,

8460
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -26,7 +26,7 @@ return [
|
*/
'version' => '1.3.0',
'version' => '2.2.2',
/*
|--------------------------------------------------------------------------
@@ -59,4 +59,6 @@ return [
Illuminate\Translation\TranslationServiceProvider::class,
],
'locale' => 'en',
];

View File

@@ -4,30 +4,58 @@ return [
/*
|--------------------------------------------------------------------------
| Host
| Servers
|--------------------------------------------------------------------------
|
| The expose server to connect to. By default, expose is using the free
| sharedwithexpose.com server, offered by Beyond Code. You will need a free
| Beyond Code account in order to authenticate with the server.
| Feel free to host your own server and change this value.
| The available Expose servers that your client can connect to.
| When sharing sites or TCP ports, you can specify the server
| that should be used using the `--server=` option.
|
*/
'host' => 'sharedwithexpose.com',
'servers' => [
'free' => [
'host' => 'bitinflow.dev',
'port' => 443,
],
],
/*
|--------------------------------------------------------------------------
| Port
| Server Endpoint
|--------------------------------------------------------------------------
|
| The port that expose will try to connect to. If you want to bypass
| firewalls and have proper SSL encrypted tunnels, make sure to use
| port 443 and use a reverse proxy for Expose.
| When you specify a server that does not exist in above static array,
| Expose will perform a GET request to this URL and tries to retrieve
| a JSON payload that looks like the configurations servers array.
|
| The free default server is already running on port 443.
| Expose then tries to load the configuration for the given server
| if available.
|
*/
'port' => 443,
'server_endpoint' => 'https://bitinflow.dev/api/servers',
/*
|--------------------------------------------------------------------------
| Default Server
|--------------------------------------------------------------------------
|
| The default server from the servers array,
| or the servers endpoint above.
|
*/
'default_server' => 'free',
/*
|--------------------------------------------------------------------------
| DNS
|--------------------------------------------------------------------------
|
| The DNS server to use when resolving the shared URLs.
| When Expose is running from within Docker containers, you should set this to
| `true` to fall-back to the system default DNS servers.
|
*/
'dns' => '127.0.0.1',
/*
|--------------------------------------------------------------------------
@@ -43,6 +71,20 @@ return [
*/
'auth_token' => '',
/*
|--------------------------------------------------------------------------
| Default Domain
|--------------------------------------------------------------------------
|
| The custom domain to use when sharing sites with Expose.
| You can register your own custom domain using Expose Pro
| Learn more at: https://expose.dev/get-pro
|
| > expose default-domain YOUR-CUSTOM-WHITELABEL-DOMAIN
|
*/
'default_domain' => null,
/*
|--------------------------------------------------------------------------
| Default TLD
@@ -55,6 +97,18 @@ return [
*/
'default_tld' => 'test',
/*
|--------------------------------------------------------------------------
| Default HTTPS
|--------------------------------------------------------------------------
|
| Whether to use HTTPS as a default when sharing your local sites. Expose
| will try to look up the protocol if you are using Laravel Valet
| automatically. Otherwise you can specify it here manually.
|
*/
'default_https' => false,
/*
|--------------------------------------------------------------------------
| Maximum Logged Requests
@@ -151,6 +205,37 @@ return [
*/
'validate_auth_tokens' => false,
/*
|--------------------------------------------------------------------------
| TCP Port Sharing
|--------------------------------------------------------------------------
|
| Control if you want to allow users to share TCP ports with your Expose
| server. You can add fine-grained control per authentication token,
| but if you want to disable TCP port sharing in general, set this
| value to false.
|
*/
'allow_tcp_port_sharing' => true,
/*
|--------------------------------------------------------------------------
| TCP Port Range
|--------------------------------------------------------------------------
|
| Expose allows you to also share TCP ports, for example when sharing your
| local SSH server with the public. This setting allows you to define the
| port range that Expose will use to assign new ports to the users.
|
| Note: Do not use port ranges below 1024, as it might require root
| privileges to assign these ports.
|
*/
'tcp_port_range' => [
'from' => 50000,
'to' => 60000,
],
/*
|--------------------------------------------------------------------------
| Maximum connection length
@@ -164,6 +249,21 @@ return [
*/
'maximum_connection_length' => 0,
/*
|--------------------------------------------------------------------------
| Maximum number of open connections
|--------------------------------------------------------------------------
|
| You can limit the amount of connections that one client/user can have
| open. A maximum connection count of 0 means that clients can open
| as many connections as they want.
|
| When creating users with the API/admin interface, you can
| override this setting per user.
|
*/
'maximum_open_connections_per_user' => 0,
/*
|--------------------------------------------------------------------------
| Subdomain
@@ -176,6 +276,17 @@ return [
*/
'subdomain' => 'expose',
/*
|--------------------------------------------------------------------------
| Reserved Subdomain
|--------------------------------------------------------------------------
|
| Specify any subdomains that you don't want to be able to register
| on your expose server.
|
*/
'reserved_subdomains' => [],
/*
|--------------------------------------------------------------------------
| Subdomain Generator
@@ -188,6 +299,25 @@ return [
*/
'subdomain_generator' => \App\Server\SubdomainGenerator\RandomSubdomainGenerator::class,
/*
|--------------------------------------------------------------------------
| Connection Callback
|--------------------------------------------------------------------------
|
| This is a callback method that will be called when a new connection is
| established.
| The \App\Client\Callbacks\WebHookConnectionCallback::class is included out of the box.
|
*/
'connection_callback' => null,
'connection_callbacks' => [
'webhook' => [
'url' => null,
'secret' => null,
],
],
/*
|--------------------------------------------------------------------------
| Users
@@ -214,6 +344,10 @@ return [
*/
'user_repository' => \App\Server\UserRepository\DatabaseUserRepository::class,
'subdomain_repository' => \App\Server\SubdomainRepository\DatabaseSubdomainRepository::class,
'logger_repository' => \App\Server\LoggerRepository\NullLogger::class,
/*
|--------------------------------------------------------------------------
| Messages
@@ -225,11 +359,35 @@ return [
|
*/
'messages' => [
'resolve_connection_message' => function ($connectionInfo, $user) {
return config('expose.admin.messages.message_of_the_day');
},
'message_of_the_day' => 'Thank you for using expose.',
'invalid_auth_token' => 'Authentication failed. Please check your authentication token and try again.',
'subdomain_taken' => 'The chosen subdomain :subdomain is already taken. Please choose a different subdomain.',
'subdomain_reserved' => 'The chosen subdomain :subdomain is not available. Please choose a different subdomain.',
'custom_subdomain_unauthorized' => 'You are not allowed to specify custom subdomains. Please upgrade to Expose Pro. Assigning a random subdomain instead.',
'custom_domain_unauthorized' => 'You are not allowed to use this custom domain.',
'tcp_port_sharing_unauthorized' => 'You are not allowed to share TCP ports. Please upgrade to Expose Pro.',
'no_free_tcp_port_available' => 'There are no free TCP ports available on this server. Please try again later.',
'tcp_port_sharing_disabled' => 'TCP port sharing is not available on this Expose server.',
],
'statistics' => [
'enable_statistics' => true,
'interval_in_seconds' => 3600,
'repository' => \App\Server\StatisticsRepository\DatabaseStatisticsRepository::class,
],
],
];

113
config/logging.php Normal file
View File

@@ -0,0 +1,113 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Handler\SyslogUdpHandler;
return [
/*
|--------------------------------------------------------------------------
| Default Log Channel
|--------------------------------------------------------------------------
|
| This option defines the default log channel that gets used when writing
| messages to the logs. The name specified in this option should match
| one of the channels defined in the "channels" configuration array.
|
*/
'default' => env('LOG_CHANNEL', 'stack'),
/*
|--------------------------------------------------------------------------
| Log Channels
|--------------------------------------------------------------------------
|
| Here you may configure the log channels for your application. Out of
| the box, Laravel uses the Monolog PHP logging library. This gives
| you a variety of powerful log handlers / formatters to utilize.
|
| Available Drivers: "single", "daily", "slack", "syslog",
| "errorlog", "monolog",
| "custom", "stack"
|
*/
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['stderr'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'papertrail' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => SyslogUdpHandler::class,
'handler_with' => [
'host' => env('PAPERTRAIL_URL'),
'port' => env('PAPERTRAIL_PORT'),
],
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
],
'deprecations' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => [
'stream' => 'php://stderr',
],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
'emergency' => [
'path' => storage_path('logs/laravel.log'),
],
],
];

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD can_specify_subdomains BOOLEAN DEFAULT 1;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD can_share_tcp_ports BOOLEAN DEFAULT 1;

View File

@@ -0,0 +1,7 @@
CREATE TABLE IF NOT EXISTS subdomains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
subdomain STRING NOT NULL,
created_at DATETIME,
updated_at DATETIME
)

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD max_connections INTEGER NOT NULL DEFAULT 0;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD last_shared_at DATETIME;

View File

@@ -0,0 +1,9 @@
CREATE TABLE IF NOT EXISTS statistics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp DATE,
shared_sites INTEGER,
shared_ports INTEGER,
unique_shared_sites INTEGER,
unique_shared_ports INTEGER,
incoming_requests INTEGER
)

View File

@@ -0,0 +1,10 @@
ALTER TABLE users ADD can_specify_domains BOOLEAN DEFAULT 1;
ALTER TABLE subdomains ADD domain STRING;
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
domain STRING NOT NULL,
created_at DATETIME,
updated_at DATETIME
)

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