210 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
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
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
130 changed files with 4796 additions and 8643 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,5 +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
ENTRYPOINT ["/src/expose"]
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,18 +44,23 @@ 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, config('expose.auth_token'));
$this->connectToServerAndShareTcp($port, $this->configuration->auth());
}
protected function prepareSharedUrl(string $sharedUrl): string
@@ -72,28 +81,30 @@ 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(function () use ($sharedUrl, $subdomain, $authToken) {
$this->connectToServer($sharedUrl, $subdomain, $authToken);
$this->retryConnectionOrExit(function () use ($sharedUrl, $subdomain, $serverHost, $authToken) {
$this->connectToServer($sharedUrl, $subdomain, $serverHost, $authToken);
});
});
@@ -107,19 +118,22 @@ class Client
$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);
});
@@ -146,8 +160,9 @@ class Client
$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}", [], [
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) {
@@ -171,9 +186,9 @@ class Client
$host = $this->configuration->host();
$this->logger->info($data->message);
$this->logger->info("Local-Port:\t\t{$port}");
$this->logger->info("Shared-Port:\t\t{$data->shared_port}");
$this->logger->info("Expose-URL:\t\ttcp://{$host}:{$data->shared_port}.");
$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);
@@ -201,6 +216,10 @@ class Client
$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);
});
@@ -231,7 +250,9 @@ class Client
$deferred->reject();
$this->loop->futureTick(function () {
exit(1);
if ($this->shouldExit) {
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,11 +35,26 @@ 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);
@@ -40,7 +63,7 @@ class Configuration
public function getUrl(string $subdomain): string
{
$httpProtocol = $this->port() === 443 ? 'https' : 'http';
$host = $this->host();
$host = $this->serverHost();
if ($httpProtocol !== 'https') {
$host .= ":{$this->port()}";

View File

@@ -57,13 +57,14 @@ class ControlConnection
$this->proxyManager->createTcpProxy($this->clientId, $data);
}
public function authenticate(string $sharedHost, string $subdomain)
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,
],
]));

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,9 @@ 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;
}
@@ -116,6 +130,15 @@ class Factory
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;
}
protected function addRoutes()
{
$this->router->get('/', DashboardController::class);
@@ -127,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

@@ -6,8 +6,8 @@ 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;
@@ -74,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,
@@ -85,17 +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)) {
if (! isset($response->buffer)) {
$response = $this->rewriteResponseHeaders($response);
$response->buffer = str($response);
$response->buffer = Message::toString($response);
}
$this->sendChunkToServer($response->buffer, $proxyConnection);
@@ -103,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;
@@ -139,13 +141,13 @@ class HttpClient
protected function rewriteResponseHeaders(ResponseInterface $response)
{
if (!$response->hasHeader('Location')) {
if (! $response->hasHeader('Location')) {
return $response;
}
$location = $response->getHeaderLine('Location');
if (!strstr($location, $this->connectionData->host)) {
if (! strstr($location, $this->connectionData->host)) {
return $response;
}

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

@@ -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

@@ -3,35 +3,23 @@
namespace App\Commands;
use App\Client\Factory;
use App\Logger\CliRequestLogger;
use LaravelZero\Framework\Commands\Command;
use React\EventLoop\LoopInterface;
use Symfony\Component\Console\Output\ConsoleOutput;
class SharePortCommand extends Command
class SharePortCommand extends ServerAwareCommand
{
protected $signature = 'share-port {port} {--auth=}';
protected $description = 'Share a local port 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', '');
(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)
->createClient()
->sharePort($this->argument('port'))
->createHttpServer()

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,7 @@ 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;
@@ -20,7 +20,7 @@ interface ConnectionManager
public function removeControlConnection($connection);
public function findControlConnectionForSubdomain($subdomain): ?ControlConnection;
public function findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost): ?ControlConnection;
public function findControlConnectionForClientId(string $clientId): ?ControlConnection;
@@ -29,4 +29,8 @@ interface ConnectionManager
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

@@ -12,6 +12,10 @@ interface SubdomainRepository
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;

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;
@@ -48,6 +50,7 @@ class LoggedRequest implements \JsonSerializable
/**
* {@inheritdoc}
*/
#[\ReturnTypeWillChange]
public function jsonSerialize()
{
$data = [
@@ -171,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) {
@@ -211,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

@@ -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,6 +3,8 @@
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;
@@ -24,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)
@@ -43,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();
@@ -54,14 +64,30 @@ 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();
@@ -78,6 +104,8 @@ class ConnectionManager implements ConnectionManagerContract
$this->connections[] = $storedConnection;
$this->statisticsCollector->portShared($this->getAuthTokenFromConnection($connection));
return $storedConnection;
}
@@ -143,10 +171,10 @@ class ConnectionManager implements ConnectionManagerContract
}
}
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;
});
}
@@ -157,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;

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)
@@ -61,7 +66,10 @@ class ControlConnection
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

@@ -76,6 +76,7 @@ class TcpControlConnection extends ControlConnection
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,

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

@@ -3,18 +3,27 @@
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;
@@ -23,12 +32,17 @@ 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;
@@ -128,16 +142,28 @@ class Factory
$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->delete('/api/users/{id}', DeleteUsersController::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);
}
@@ -176,9 +202,12 @@ class Factory
$this->bindConfiguration()
->bindSubdomainGenerator()
->bindUserRepository()
->bindLoggerRepository()
->bindSubdomainRepository()
->bindDomainRepository()
->bindDatabase()
->ensureDatabaseIsInitialized()
->registerStatisticsCollector()
->bindConnectionManager()
->addAdminRoutes();
@@ -216,7 +245,25 @@ class Factory
protected function bindSubdomainRepository()
{
app()->singleton(SubdomainRepository::class, function () {
return app(config('expose.admin.subdomain_repository'));
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;
@@ -248,7 +295,8 @@ class Factory
->files()
->ignoreDotFiles(true)
->in(database_path('migrations'))
->name('*.sql');
->name('*.sql')
->sortByName();
/** @var SplFileInfo $migration */
foreach ($migrations as $migration) {
@@ -264,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

@@ -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,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,6 +3,7 @@
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;
@@ -10,31 +11,55 @@ 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())
->filter(function ($connection) {
return get_class($connection) === ControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
$authTokens = [];
return $site;
})->values(),
])
);
$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;
})->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

@@ -3,6 +3,7 @@
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;
@@ -10,32 +11,55 @@ use Ratchet\ConnectionInterface;
class GetTcpConnectionsController 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([
'tcp_connections' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
$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(),
])
);
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

@@ -25,10 +25,25 @@ class GetUserDetailsController extends AdminController
public function handle(Request $request, ConnectionInterface $httpConnection)
{
$this->userRepository
->getUserById($request->get('id'))
->then(function ($user) use ($httpConnection, $request) {
$this->subdomainRepository->getSubdomainsByUserId($request->get('id'))
$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([], 404)
);
$httpConnection->close();
return;
}
$this->subdomainRepository->getSubdomainsByUserId($user['id'])
->then(function ($subdomains) use ($httpConnection, $user) {
$httpConnection->send(
respond_json([
@@ -39,6 +54,6 @@ class GetUserDetailsController extends AdminController
$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

@@ -4,7 +4,6 @@ namespace App\Server\Http\Controllers\Admin;
use App\Contracts\ConnectionManager;
use App\Server\Configuration;
use App\Server\Connections\ControlConnection;
use Illuminate\Http\Request;
use Ratchet\ConnectionInterface;
@@ -26,17 +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())
->filter(function ($connection) {
return get_class($connection) === ControlConnection::class;
})
->map(function ($site, $siteId) {
$site = $site->toArray();
$site['id'] = $siteId;
return $site;
})
->values(),
]);
$httpConnection->send(

View File

@@ -4,7 +4,6 @@ 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;
@@ -26,17 +25,6 @@ class ListTcpConnectionsController extends AdminController
$sites = $this->getView($httpConnection, 'server.tcp.index', [
'scheme' => $this->configuration->port() === 443 ? 'https' : 'http',
'configuration' => $this->configuration,
'connections' => collect($this->connectionManager->getConnections())
->filter(function ($connection) {
return get_class($connection) === TcpControlConnection::class;
})
->map(function ($connection, $connectionId) {
$connection = $connection->toArray();
$connection['id'] = $connectionId;
return $connection;
})
->values(),
]);
$httpConnection->send(

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

@@ -4,6 +4,7 @@ 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;
@@ -18,10 +19,14 @@ class StoreSubdomainController extends AdminController
/** @var UserRepository */
protected $userRepository;
public function __construct(UserRepository $userRepository, SubdomainRepository $subdomainRepository)
/** @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)
@@ -39,7 +44,8 @@ class StoreSubdomainController extends AdminController
return;
}
$this->userRepository->getUserByToken($request->get('auth_token', ''))
$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));
@@ -55,20 +61,22 @@ class StoreSubdomainController extends AdminController
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) {
if (is_null($subdomain)) {
$httpConnection->send(respond_json(['error' => 'The subdomain is already taken.'], 422));
$httpConnection->close();
return;
}
$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,9 +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,14 +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
@@ -24,11 +28,19 @@ class ControlMessageController implements MessageComponentInterface
/** @var SubdomainRepository */
protected $subdomainRepository;
public function __construct(ConnectionManager $connectionManager, UserRepository $userRepository, SubdomainRepository $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;
}
/**
@@ -85,7 +97,42 @@ 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 ($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']));
}
if ($maximumConnectionCount > 0 && $connectionCount + 1 > $maximumConnectionCount) {
$connection->send(json_encode([
'event' => 'authenticationFailed',
'data' => [
'message' => config('expose.admin.messages.maximum_connection_count'),
],
]));
$connection->close();
reject(null);
}
return $user;
})
->then(function ($user) use ($connection, $data) {
if ($data->type === 'http') {
$this->handleHttpConnection($connection, $data, $user);
@@ -103,28 +150,57 @@ 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->hasValidSubdomain($connection, $data->subdomain, $user)->then(function ($subdomain) use ($data, $connection) {
if ($subdomain === false) {
return;
}
$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;
$data->subdomain = $subdomain;
$connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $connection);
$connectionInfo = $this->connectionManager->storeConnection($data->host, $data->subdomain, $data->server_host, $connection);
$this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length'));
$this->connectionManager->limitConnectionLength($connectionInfo, config('expose.admin.maximum_connection_length'));
$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 $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)
@@ -150,7 +226,8 @@ class ControlMessageController implements MessageComponentInterface
$connection->send(json_encode([
'event' => 'authenticated',
'data' => [
'message' => config('expose.admin.messages.message_of_the_day'),
'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,
@@ -205,21 +282,60 @@ 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, ?array $user): PromiseInterface
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' => 'info',
'event' => 'error',
'data' => [
'message' => config('expose.admin.messages.custom_subdomain_unauthorized').PHP_EOL,
],
@@ -232,10 +348,14 @@ class ControlMessageController implements MessageComponentInterface
* Check if the given subdomain is reserved for a different user.
*/
if (! is_null($subdomain)) {
return $this->subdomainRepository->getSubdomainByName($subdomain)
->then(function ($foundSubdomain) use ($connection, $subdomain, $user) {
if (! is_null($foundSubdomain) && ! is_null($user) && $foundSubdomain['user_id'] !== $user['id']) {
$message = config('expose.admin.messages.subdomain_reserved');
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'];
});
if (count($foundSubdomains) > 0 && ! is_null($user) && is_null($ownSubdomain)) {
$message = config('expose.admin.messages.subdomain_reserved', '');
$message = str_replace(':subdomain', $subdomain, $message);
$connection->send(json_encode([
@@ -249,9 +369,9 @@ class ControlMessageController implements MessageComponentInterface
return \React\Promise\resolve(false);
}
$controlConnection = $this->connectionManager->findControlConnectionForSubdomain($subdomain);
$controlConnection = $this->connectionManager->findControlConnectionForSubdomainAndServerHost($subdomain, $serverHost);
if (! is_null($controlConnection) || $subdomain === config('expose.admin.subdomain')) {
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);
@@ -275,11 +395,23 @@ class ControlMessageController implements MessageComponentInterface
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.custom_subdomain_unauthorized'),
'message' => config('expose.admin.messages.tcp_port_sharing_unauthorized'),
],
]));
$connection->close();

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)
@@ -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

@@ -57,6 +57,38 @@ class DatabaseSubdomainRepository implements SubdomainRepository
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();
@@ -76,23 +108,14 @@ class DatabaseSubdomainRepository implements SubdomainRepository
{
$deferred = new Deferred();
$this->getSubdomainByName($data['subdomain'])
->then(function ($registeredSubdomain) use ($data, $deferred) {
if (! is_null($registeredSubdomain)) {
$deferred->resolve(null);
return;
}
$this->database->query("
INSERT INTO subdomains (user_id, subdomain, created_at)
VALUES (:user_id, :subdomain, DATETIME('now'))
", $data)
$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) {
$this->database->query('SELECT * FROM subdomains WHERE id = :id', ['id' => $result->insertId])
->then(function (Result $result) use ($deferred) {
$deferred->resolve($result->rows[0]);
});
$deferred->resolve($result->rows[0]);
});
});
@@ -119,7 +142,7 @@ class DatabaseSubdomainRepository implements SubdomainRepository
{
$deferred = new Deferred();
$this->database->query('DELETE FROM subdomains WHERE id = :id AND user_id = :user_id', [
$this->database->query('DELETE FROM subdomains WHERE (id = :id OR subdomain = :id) AND user_id = :user_id', [
'id' => $subdomainId,
'user_id' => $userId,
])

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();
@@ -96,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();
@@ -103,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();
@@ -113,15 +150,38 @@ class DatabaseUserRepository implements UserRepository
{
$deferred = new Deferred();
$this->database->query("
INSERT INTO users (name, auth_token, can_specify_subdomains, can_share_tcp_ports, created_at)
VALUES (:name, :auth_token, :can_specify_subdomains, :can_share_tcp_ports, 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();
@@ -131,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,50 +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",
"ext-json": "*"
"php": "^8.0",
"ext-json": "*",
"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",
"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,

7893
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,19 @@ 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
@@ -182,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
@@ -194,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
@@ -206,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
@@ -234,6 +346,8 @@ return [
'subdomain_repository' => \App\Server\SubdomainRepository\DatabaseSubdomainRepository::class,
'logger_repository' => \App\Server\LoggerRepository\NullLogger::class,
/*
|--------------------------------------------------------------------------
| Messages
@@ -245,15 +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 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
)

View File

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

View File

@@ -3,7 +3,7 @@ services:
expose:
image: beyondcodegmbh/expose-server:latest
ports:
- 127.0.0.1:8080:${PORT}
- 8080:${PORT}
environment:
port: ${PORT}
domain: ${DOMAIN}

9
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
sed -i "s|username|${username}|g" ${exposeConfigPath} && sed -i "s|password|${password}|g" ${exposeConfigPath}
if [[ $# -eq 0 ]]; then
exec /src/expose serve ${domain} --port ${port} --validateAuthTokens
else
exec /src/expose "$@"
fi

View File

@@ -16,7 +16,7 @@ The result looks like this:
```json
{
"configuration":{
"hostname": "sharedwithexpose.com",
"hostname": "bitinflow.dev",
"port": 8080,
"database": "/home/forge/expose/database/expose.db",
"validate_auth_tokens": false,

View File

@@ -1,13 +1,13 @@
---
title: Basic Authentication
order: 2
order: 4
---
# Sharing sites with basic authentication
Expose allows you to share your local sites with custom basic authentication credentials.
This can be useful, if you have a static subdomain that you share with someone else, for example a client, and you want to provide some additional security to it. Before someone can access your shared site, they need to provide the correct credentials.
This is useful, if you have a static subdomain that you share with someone else, for example a client, and you want to provide some additional security to it. Before someone can access your shared site, they need to provide the correct credentials.
> **Warning**: You can not add basic authentication to a website that already uses basic authentication.
@@ -29,4 +29,4 @@ You can also use the basic authentication parameter in addition to a custom subd
```bash
expose share my-site.test --subdomain=site --auth="admin:secret"
```
```

View File

@@ -1,6 +1,6 @@
---
title: Configuration
order: 3
order: 6
---
# Configuration
@@ -17,6 +17,12 @@ The configuration file will be written to your home directory inside a `.expose`
`~/.expose/config.php`
You can also provide a custom location of the config file by providing the full path as a server variable.
```bash
EXPOSE_CONFIG_FILE="~/my-custom-config.php" expose share
```
And the default content of the configuration file is this:
```php
@@ -28,12 +34,12 @@ return [
|--------------------------------------------------------------------------
|
| 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
| bitinflow.dev 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.
|
*/
'host' => 'sharedwithexpose.com',
'host' => 'bitinflow.dev',
/*
|--------------------------------------------------------------------------

View File

@@ -1,23 +1,23 @@
---
title: Dashboard
order: 5
title: Local Dashboard
order: 2
---
# Dashboard
Once you share a local site, expose will show you all incoming HTTP requests along with their status code and duration in your terminal:
Once you share a local site, Expose shows you all incoming HTTP requests along with their status code and duration in your terminal:
![](/img/expose_terminal.png)
While this is great to get a quick look of the incoming requests, you sometimes need more information than this.
While this is great to get a quick look of the incoming requests, you often need more information than this.
Because of that, expose is also exposing a web based dashboard on port 4040.
Because of that, Expose is also exposing a web based dashboard on port 4040.
Once you start sharing a site, expose will show you a QR code that you can scan with your mobile device, to easily browse your shared sites on your phone or tablet.
Once you start sharing a site, Expose shows you a QR code that you can scan with your mobile device, to easily browse your shared sites on your phone or tablet.
![](/img/expose_qr.png)
Once a request comes in, you can see all incoming HTTP requests as they hit your local site in realtime.
When you click on a specific request, you can see detailed information about the request and response.
When a request comes in, you can see all incoming HTTP requests as they hit your local site in realtime.
You can click on a specific request and see detailed information about the request and response. Expose provides several tools for developers to make webhook testing easier the most powerful one is that you can replay requests without firing the webhook again. So if your previous process required to create multiple test orders to see how the paylods of your payment provider look like, Expose makes this a breeze by allowing you to replay these requests without creating more orders.
![](/img/expose_dashboard_details.png)
![](/img/expose_dashboard_details.png)

View File

@@ -0,0 +1,44 @@
---
title: Global Server Infrastructure
order: 4
---
# Global Server Infrastructure ::pro
[Expose Pro](/get-pro) allows you to choose between multiple Expose servers around the world, so that you can use an endpoint closest to you.
To get a list of all the available Expose servers, you can run `expose servers`
```
$ expose servers
+------+---------------------------+------+
| Key | Region | Type |
+------+---------------------------+------+
| eu-1 | EU (Frankfurt) | Pro |
| us-1 | US (New York) | Pro |
| us-2 | US (San Francisco) | Pro |
| ap-1 | Asia Pacific (Singapore) | Pro |
| in-1 | India (Bangalore) | Pro |
| sa-1 | South America (São Paulo) | Pro |
| au-1 | Australia (Sydney) | Pro |
+------+---------------------------+------+
```
## Changing servers
When you share a local URL, or a local TCP port, you can specify the Expose server region, using the `--server` command line option. Pass the server key as the option to connect to this specific server.
```bash
expose share my-local-site.test --server=eu-1
```
## Setting a default server
Most of the time you will want to always use the server location that is closest to you for all of your Expose commands. You can define the default server that Expose should use, by calling the `expose default-server` command:
```bash
expose default-server us-2
```
Now the next time that you will share a local URL or port, Expose is automatically going to connect to the `us-2` server for your.

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