Compare commits

...

122 commits
v0.2.0 ... main

Author SHA1 Message Date
3fb227f5d9 chore(deps): update dependency mimic to v1.10.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-09-02 22:26:28 +12:00
d74c8e3cb3 chore(deps): update dependency spark to v2.2.23
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-30 10:22:43 +12:00
fad27f142e chore: release version v0.6.1
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-08-27 03:54:31 +00:00
b96516405e
refactor(WebSocketProxy): Correctly traverse incoming messages to set the response.
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-27 15:53:17 +12:00
a191077cc4
fix(Proxy): don't crash for responses with no body. 2024-08-27 15:53:17 +12:00
de29cb2b51 chore(deps): update dependency telemetry to v1.3.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-23 04:35:50 +12:00
d9fe59958e chore: release version v0.6.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-08-20 04:45:52 +00:00
07c41153f5 feat: Add request telemetry (#114)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #114
Co-authored-by: James Harton <james@harton.nz>
Co-committed-by: James Harton <james@harton.nz>
2024-08-20 16:44:08 +12:00
dfe52b6e74 chore(deps): update dependency spark to v2.2.22
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2024-08-20 14:38:38 +12:00
c5a337ca4a
docs: Update readme.
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-18 09:58:18 +12:00
c440a88bdc chore: release version v0.5.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2024-08-17 06:54:11 +00:00
4ad256acdd
feat: Add support for proxying websockets.
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-17 18:50:43 +12:00
de160bd70c chore(deps): update dependency spark to v2.2.21
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-15 06:19:21 +12:00
11b8444b96
chore: update cheatsheet.
All checks were successful
continuous-integration/drone/push Build is passing
2024-08-14 15:44:07 +12:00
60664ff5cc chore(deps): update dependency spark to v2.2.20
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-14 14:21:51 +12:00
d8e7e0c3a0 chore(deps): update dependency spark to v2.2.19
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2024-08-14 11:20:45 +12:00
e43c31997e chore(deps): update dependency spark to v2.2.18
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-14 08:18:53 +12:00
f739d4b7e1 chore(deps): update dependency spark to v2.2.17
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-14 05:21:13 +12:00
cd489acd63 chore(deps): update dependency spark to v2.2.16
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-14 01:17:31 +12:00
09433f8e26 chore(deps): update dependency spark to v2.2.14
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2024-08-13 14:17:38 +12:00
adc2453081 chore(deps): update dependency spark to v2.2.12
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2024-08-13 13:16:28 +12:00
cac6f82746 chore(deps): update dependency spark to v2.2.11
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-08-02 18:36:41 +12:00
8cf2027f28 chore(deps): update dependency bandit to v1.5.7
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2024-08-02 18:28:38 +12:00
638badbba8 chore(deps): update dependency bandit to v1.5.5 (#102)
All checks were successful
continuous-integration/drone/push Build is passing
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [bandit](https://hex.pm/packages/bandit) | patch | `1.5.3` -> `1.5.5` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Disabled because a matching PR was automerged previously.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzOC43LjEiLCJ1cGRhdGVkSW5WZXIiOiIzOC44LjEiLCJ0YXJnZXRCcmFuY2giOiJtYWluIiwibGFiZWxzIjpbInJlbm92YXRlIl19-->

Reviewed-on: #102
Co-authored-by: Renovate Bot <bot@harton.nz>
Co-committed-by: Renovate Bot <bot@harton.nz>
2024-07-27 21:07:06 +12:00
a701a200b0
chore: remove unused dependencies.
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-27 15:31:09 +12:00
b11b37078e chore(deps): update dependency spark to v2.2.10
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2024-07-27 06:38:13 +12:00
7ffec1bed6 chore(deps): update dependency mimic to v1.9.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-07-18 20:32:31 +12:00
19f4a9b387 chore(deps): update dependency mix_audit to v2.1.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-17 02:34:26 +12:00
c016c5a8ae chore(deps): update dependency earmark to v1.4.47
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-13 06:42:06 +12:00
59ae3a59cf chore(deps): update dependency erlang to v27.0.1
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2024-07-11 01:42:20 +12:00
331827d6b4 chore(deps): update dependency ex_doc to v0.34.2
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is passing
2024-07-09 03:46:20 +12:00
c6b7c98930 chore(deps): update dependency elixir to v1.17.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-07 09:40:47 +12:00
08976af06c chore(deps): update dependency spark to v2.2.7
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-03 10:37:42 +12:00
69a32accd4 chore(deps): update dependency castore to v1.0.8
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-02 19:21:25 +12:00
0dd15ad75e chore(deps): update dependency mint to v1.6.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-07-02 04:12:11 +12:00
6bf61d35f1 chore(deps): update dependency spark to v2.2.6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-29 10:41:44 +12:00
ec239d1e57 chore(deps): update dependency spark to v2.2.5
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-06-26 07:39:37 +12:00
bbc6f05d20 chore(deps): update dependency mimic to v1.8.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-23 22:40:48 +12:00
82e4b461eb chore: release version v0.4.1
Some checks reported errors
continuous-integration/drone/push Build encountered an error
continuous-integration/drone/tag Build encountered an error
2024-06-23 08:44:45 +00:00
384257a33f
fix: incorrect handling of Mint stream failure.
All checks were successful
continuous-integration/drone Build is passing
continuous-integration/drone/push Build is passing
2024-06-23 16:00:51 +12:00
d4d38b7b34 chore(deps): update dependency elixir to v1.17.1
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2024-06-23 01:40:13 +12:00
57e0f63ee8 chore(deps): update dependency mimic to v1.8.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-23 00:42:06 +12:00
6eb6fd6a1f chore(deps): update dependency mimic to v1.8.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-22 22:42:33 +12:00
90c60759ca
chore: ignore a dialyzer warning I just can't wrap my head around.
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-21 19:28:37 +12:00
886e032c05 chore(deps): update dependency spark to v2.2.4
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2024-06-21 06:22:28 +12:00
3545d01262 chore(deps): update dependency ex_doc to v0.34.1
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-21 02:42:13 +12:00
e1e796e234 chore(deps): update dependency bandit to v1.5.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-21 02:24:07 +12:00
d7d16fd925 chore(deps): update dependency spark to v2.2.1
All checks were successful
continuous-integration/drone/push Build is passing
2024-06-15 13:33:20 +12:00
0c9137cfe9 chore(deps): update dependency credo to v1.7.7
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-15 12:55:44 +12:00
204dd61258 chore(deps): update dependency spark to v2.1.24
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-11 03:21:52 +12:00
51d100898c chore(deps): update dependency bandit to v1.5.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-06-08 06:20:41 +12:00
b6e2337dab chore(deps): update dependency spark to v2.1.23
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-06-08 02:23:58 +12:00
304fd0bcd4 chore(deps): update dependency ex_doc to v0.34.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-31 01:26:40 +12:00
1008da98a4 chore(deps): update dependency nimble_options to v1.1.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-25 23:17:11 +12:00
5d8be9ae42 chore(deps): update dependency ex_doc to v0.33.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-22 01:22:56 +12:00
2be599db1b chore(deps): update dependency elixir to v1.16.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-21 11:23:57 +12:00
e32b2a868d chore(deps): update dependency erlang to v27
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-20 21:45:26 +12:00
6009a19705 chore(deps): update dependency plug to v1.16.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-18 21:45:45 +12:00
055b0ca2d5 chore(deps): update dependency spark to v2.1.22
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-15 04:19:50 +12:00
b8b5623a2f chore(deps): update dependency spark to v2.1.21
All checks were successful
continuous-integration/drone/push Build is passing
2024-05-11 11:35:59 +12:00
4766d48dcb chore(deps): update dependency git_ops to v2.6.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-11 10:42:14 +12:00
db3abee0fb chore(deps): update dependency bandit to v1.5.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-11 05:45:31 +12:00
99aaa5d57d chore(deps): update dependency bandit to v1.5.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-11 03:36:03 +12:00
4ce0d89652 chore(deps): update dependency ex_doc to v0.32.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-10 23:40:33 +12:00
d78dca56f6 chore(deps): update dependency credo to v1.7.6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-10 00:00:52 +12:00
989d48c46d chore(deps): update dependency erlang to v26.2.5
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-05-03 03:37:37 +12:00
27858f1474 chore(deps): update dependency spark to v2.1.20
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-04-22 05:32:32 +12:00
7093ae3517 chore(deps): update dependency spark to v2.1.19
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-22 03:35:27 +12:00
6e63fa3961 chore(deps): update dependency bandit to v1.5.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-22 01:32:24 +12:00
fe61ed4e1a chore(deps): update dependency castore to v1.0.7
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2024-04-20 17:32:43 +12:00
7e5ec8c11c chore(deps): update dependency ex_doc to v0.32.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-13 08:36:52 +12:00
ac3440d512 chore(deps): update dependency spark to v2.1.18
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2024-04-13 06:33:15 +12:00
bbf213825b chore(deps): update dependency erlang to v26.2.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-13 00:37:35 +12:00
5a411f54ee chore(deps): update dependency spark to v2.1.17
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-12 05:31:37 +12:00
f3c49db9db chore(deps): update dependency spark to v2.1.15
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-11 04:59:18 +12:00
c6f84bb1c5 chore(deps): update dependency spark to v2.1.14
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-11 02:31:26 +12:00
5c70adaaff chore(deps): update dependency ex_doc to v0.32.0
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-04-10 21:34:21 +12:00
35dce07e77 chore(deps): update dependency spark to v2.1.13 (#50)
All checks were successful
continuous-integration/drone/push Build is passing
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [spark](https://hex.pm/packages/spark) | patch | `2.1.11` -> `2.1.13` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIwLjAuMC1zZW1hbnRpYy1yZWxlYXNlIiwidXBkYXRlZEluVmVyIjoiMC4wLjAtc2VtYW50aWMtcmVsZWFzZSIsInRhcmdldEJyYW5jaCI6Im1haW4ifQ==-->

Reviewed-on: #50
Co-authored-by: Renovate Bot <bot@harton.nz>
Co-committed-by: Renovate Bot <bot@harton.nz>
2024-04-06 12:48:47 +13:00
4747c50673 chore(deps): update dependency bandit to v1.4.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-04-03 06:25:01 +13:00
b35d678050 chore(deps): update dependency spark to v2.1.11
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is passing
2024-03-30 10:30:14 +13:00
8791e1228d chore(deps): update dependency spark to v2.1.10
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-30 08:00:51 +13:00
5d491362d2 chore(deps): update dependency spark to v2.1.9
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-29 11:41:51 +13:00
2e9f41c00d chore(deps): update dependency spark to v2.1.8
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-28 12:16:44 +13:00
3ed6b64e80 chore(deps): update dependency bandit to v1.4.1 (#45)
All checks were successful
continuous-integration/drone/push Build is passing
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [bandit](https://hex.pm/packages/bandit) | minor | `1.3.0` -> `1.4.1` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIwLjAuMC1zZW1hbnRpYy1yZWxlYXNlIiwidXBkYXRlZEluVmVyIjoiMC4wLjAtc2VtYW50aWMtcmVsZWFzZSIsInRhcmdldEJyYW5jaCI6Im1haW4ifQ==-->

Reviewed-on: #45
Co-authored-by: Renovate Bot <bot@harton.nz>
Co-committed-by: Renovate Bot <bot@harton.nz>
2024-03-28 11:37:01 +13:00
2b79e3cb10 chore(deps): update dependency spark to v2.1.4
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-26 11:15:52 +13:00
25fb114808 chore(deps): update dependency spark to v2.1.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-21 02:25:50 +13:00
c672e2c4f4 chore(deps): update dependency mix_audit to v2.1.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-19 16:10:11 +13:00
7e2f0f2182 chore(deps): update dependency spark to v2.1.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-19 07:24:15 +13:00
41ac07b8f1 chore(deps): update dependency spark to v2.1.0
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-16 05:33:49 +13:00
0d186e834a
chore: fix docs release.
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-14 19:30:17 +13:00
306a0d7935 chore(deps): update dependency ip to v2.0.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-14 17:19:59 +13:00
2736fdbbd1 chore(deps): update dependency ex_doc to v0.31.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-14 16:07:40 +13:00
e48f26dd85 chore(deps): update dependency bandit to v1.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-14 16:01:40 +13:00
b59af873e8 chore(deps): update dependency mix_audit to v2.1.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-14 15:25:04 +13:00
5cfb9a4a7d chore(deps): update dependency ip to v2.0.2
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-14 15:05:11 +13:00
52632fe88e chore(deps): update dependency credo to v1.7.5
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-14 15:04:38 +13:00
d95e23d727 chore(deps): update dependency dialyxir to v1.4.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-14 00:54:22 +00:00
25c9003193 chore(deps): update dependency plug to v1.15.3
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-14 13:33:30 +13:00
ccdfce9850 chore(deps): update dependency castore to v1.0.6
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-13 21:20:24 +00:00
d57e6a0b7f chore(deps): update dependency elixir to v1.16.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-11 00:37:49 +13:00
5ec7cf267a
chore: fix typo in readme
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-08 14:48:42 +13:00
48bc11159a chore(deps): update dependency erlang to v26.2.3
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-07 22:33:49 +13:00
3383898512
docs: Update Readme.
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-06 20:26:35 +13:00
ca1a344057
docs(README): Add GitHub mirror link.
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-06 18:05:41 +13:00
3312d02642
chore: Update hex package links.
All checks were successful
continuous-integration/drone/push Build is passing
2024-03-06 18:03:25 +13:00
e4224a0daf
chore(deps): update dependency spark to v2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-03 20:13:16 +13:00
49c30c4dde chore(deps): update dependency ex_check to ~> 0.16
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-03-01 23:30:58 +13:00
3ebb9843da chore(deps): update dependency faker to ~> 0.18
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-02-29 19:45:15 +13:00
f192b9fd5f chore(deps): update dependency erlang to v26.2.2
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2024-02-09 10:48:53 +13:00
76f2a299f8 chore: Update forgejo hostname.
All checks were successful
continuous-integration/drone/push Build is passing
2024-02-05 14:58:36 +13:00
37a064c507 chore: make more tests synchronous. 2024-02-05 14:54:27 +13:00
8358d2de28 chore(deps): update dependency elixir to v1.16.1
Some checks failed
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is failing
2024-01-31 23:22:35 +13:00
c142bdbc1a chore(deps): update dependency elixir to v1.16.0 (#13)
All checks were successful
continuous-integration/drone/push Build is passing
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [elixir](https://elixir-lang.org/) ([source](https://github.com/elixir-lang/elixir)) | minor | `1.15.7` -> `1.16.0` |

---

### Release Notes

<details>
<summary>elixir-lang/elixir (elixir)</summary>

### [`v1.16.0`](https://github.com/elixir-lang/elixir/releases/tag/v1.16.0)

[Compare Source](https://github.com/elixir-lang/elixir/compare/v1.15.7...v1.16.0)

Official announcement: https://elixir-lang.org/blog/2023/12/22/elixir-v1-16-0-released/

##### 1. Enhancements

##### EEx

-   \[EEx] Include relative file information in diagnostics

##### Elixir

-   \[Code] Add `:emit_warnings` for `Code.string_to_quoted/2`
-   \[Code] Automatically include columns in parsing options
-   \[Code] Introduce `MismatchedDelimiterError` for handling mismatched delimiter exceptions
-   \[Code.Fragment] Handle anonymous calls in fragments
-   \[Code.Formatter] Trim trailing whitespace on heredocs with `\r\n`
-   \[File] Add `:offset` option to `File.stream!/2`
-   \[Kernel] Auto infer size of matched variable in bitstrings
-   \[Kernel] Preserve column information when translating typespecs
-   \[Kernel] Suggest module names based on suffix and casing errors when the module does not exist in `UndefinedFunctionError`
-   \[Kernel.ParallelCompiler] Introduce `Kernel.ParallelCompiler.pmap/2` to compile multiple additional entries in parallel
-   \[Kernel.SpecialForms] Warn if `True`/`False`/`Nil` are used as aliases and there is no such alias
-   \[Macro] Add `Macro.compile_apply/4`
-   \[Module] Add support for `@nifs` annotation from Erlang/OTP 25
-   \[Module] Add support for missing `@dialyzer` configuration
-   \[String] Update to Unicode 15.1.0
-   \[String] Add `String.replace_invalid/2`
-   \[Task] Add `:limit` option to `Task.yield_many/2`

##### Logger

-   \[Logger] Add `Logger.levels/0`

##### Mix

-   \[mix] Add `MIX_PROFILE` to profile a list of comma separated tasks
-   \[mix archive.install] Support `--sparse` option
-   \[mix compile.app] Warn if both `:applications` and `:extra_applications` are used
-   \[mix compile.elixir] Pass original exception down to diagnostic `:details` when possible
-   \[mix compile.elixir] Optimize scenario where there are thousands of files in `lib/` and one of them is changed
-   \[mix deps.clean] Emit a warning instead of crashing when a dependency cannot be removed
-   \[mix escript.install] Support `--sparse` option
-   \[mix release] Include `include/` directory in releases
-   \[mix test] Allow testing multiple file:line at once, such as `mix test test/foo_test.exs:13 test/bar_test.exs:27`

##### 2. Bug fixes

##### Elixir

-   \[Code] Keep quotes for atom keys in formatter
-   \[Code.Fragment] Fix crash in `Code.Fragment.surround_context/2` when matching on `->`
-   \[IO] Raise when using `IO.binwrite/2` on terminated device (mirroring `IO.write/2`)
-   \[Kernel] Do not expand aliases recursively (the alias stored in Macro.Env is already expanded)
-   \[Kernel] Ensure `dbg` module is a compile-time dependency
-   \[Kernel] Warn when a private function or macro uses `unquote/1` and the function/macro itself is unused
-   \[Kernel] Re-enabled compiler optimizations for top level functions in scripts (disabled in v1.14.0 but shouldn't impact most programs)
-   \[Kernel] Do not define an alias for nested modules starting with `Elixir.` in their definition
-   \[Kernel.ParallelCompiler] Consider a module has been defined in `@after_compile` callbacks to avoid deadlocks
-   \[Macro] Address exception on `Macro.to_string/1` for certain ASTs
-   \[Path] Lazily evaluate `File.cwd!/0` in `Path.expand/1` and `Path.absname/1`
-   \[Path] Ensure `Path.relative_to/2` returns a relative path when the given argument does not share a common prefix with `cwd`

##### ExUnit

-   \[ExUnit] Raise on incorrectly dedented doctests

##### IEx

-   \[IEx.Pry] Fix prying functions with only literals in their body

##### Mix

-   \[mix archive.install] Restore code paths after `mix archive.install`
-   \[mix compile] Ensure files with duplicate modules are recompiled whenever any of the files change
-   \[mix compile] Update Mix compiler diagnostics documentation and typespecs to match the Elixir compiler behaviour where both lines and columns start from one (before it inaccurately said that columns started from zero)
-   \[mix escript.install] Restore code paths after `mix escript.install`

##### 3. Soft deprecations (no warnings emitted)

##### Elixir

-   \[File] Deprecate `File.stream!(file, options, line_or_bytes)` in favor of keeping the options as last argument, as in `File.stream!(file, line_or_bytes, options)`
-   \[Kernel.ParallelCompiler] Deprecate `Kernel.ParallelCompiler.async/1` in favor of `Kernel.ParallelCompiler.pmap/2`
-   \[Path] Deprecate `Path.safe_relative_to/2` in favor of `Path.safe_relative/2`

##### Mix

-   \[mix compile] Returning a four-element tuple as a position in `Mix.Task.Compiler.Diagnostic`

##### 4. Hard deprecations

##### Elixir

-   \[Date] Deprecate inferring a range with negative step, call `Date.range/3` with a negative step instead
-   \[Enum] Deprecate passing a range with negative step on `Enum.slice/2`, give `first..last//1` instead
-   \[Kernel] `~R/.../` is deprecated in favor of `~r/.../`. This is because `~R/.../` still allowed escape codes, which did not fit the definition of uppercase sigils
-   \[String] Deprecate passing a range with negative step on `String.slice/2`, give `first..last//1` instead

##### ExUnit

-   \[ExUnit.Formatter] Deprecate `format_time/2`, use `format_times/1` instead

##### Mix

-   \[mix compile.leex] Require `:leex` to be added as a compiler to run the `leex` compiler
-   \[mix compile.yecc] Require `:yecc` to be added as a compiler to run the `yecc` compiler

</details>

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4xMDcuMCIsInVwZGF0ZWRJblZlciI6IjM3LjEyNy4wIiwidGFyZ2V0QnJhbmNoIjoibWFpbiJ9-->

Reviewed-on: https://code.harton.nz/james/wayfarer/pulls/13
Co-authored-by: Renovate Bot <bot@harton.nz>
Co-committed-by: Renovate Bot <bot@harton.nz>
2024-01-15 10:37:57 +13:00
545384b149 chore(deps): update dependency erlang to v26.2.1
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2023-12-19 04:10:27 +13:00
b269855cb0 chore: release version v0.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-19 04:57:26 +00:00
a381ca4b34 feat: add proxying. (#7)
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: https://code.harton.nz/james/wayfarer/pulls/7
Co-authored-by: James Harton <james@harton.nz>
Co-committed-by: James Harton <james@harton.nz>
2023-11-19 17:56:07 +13:00
87bc601fb7 chore(deps): update dependency bandit to v1 (#6)
Some checks failed
continuous-integration/drone/push Build is failing
This PR contains the following updates:

| Package | Update | Change |
|---|---|---|
| [bandit](https://hex.pm/packages/bandit) | major | `~> 0.7` -> `~> 1.0` |

---

### Configuration

📅 **Schedule**: Branch creation - At any time (no schedule defined), Automerge - At any time (no schedule defined).

🚦 **Automerge**: Enabled.

♻ **Rebasing**: Whenever PR is behind base branch, or you tick the rebase/retry checkbox.

🔕 **Ignore**: Close this PR and you won't be reminded about this update again.

---

 - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box

---

This PR has been generated by [Renovate Bot](https://github.com/renovatebot/renovate).
<!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiIzNy4yNy4xIiwidXBkYXRlZEluVmVyIjoiMzcuMjcuMSIsInRhcmdldEJyYW5jaCI6Im1haW4ifQ==-->

Reviewed-on: https://code.harton.nz/james/wayfarer/pulls/6
Co-authored-by: Renovate Bot <bot@harton.nz>
Co-committed-by: Renovate Bot <bot@harton.nz>
2023-11-03 20:17:21 +13:00
c32dc0a22c
chore: Move repo bivouac/wayfarer -> james/wayfarer.
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-03 10:32:44 +13:00
2d2cbad0a9 chore(deps): update dependency elixir to v1.15.7
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2023-10-14 23:26:24 +13:00
9c2aaa62f3 chore: release version v0.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-14 08:01:24 +00:00
fb11032695
feat(Target): Add healthy-checking HTTP targets.
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2023-10-14 20:58:08 +13:00
8f759bf0db
improvement(Listener): Register listeners with scheme, address and port. 2023-10-14 15:25:06 +13:00
58 changed files with 6977 additions and 330 deletions

34
.check.exs Normal file
View file

@ -0,0 +1,34 @@
[
## don't run tools concurrently
# parallel: false,
## don't print info about skipped tools
# skipped: false,
## always run tools in fix mode (put it in ~/.check.exs locally, not in project config)
# fix: true,
## don't retry automatically even if last run resulted in failures
# retry: false,
## list of tools (see `mix check` docs for a list of default curated tools)
tools: [
## curated tools may be disabled (e.g. the check for compilation warnings)
# {:compiler, false},
## ...or have command & args adjusted (e.g. enable skip comments for sobelow)
{:sobelow, false},
## ...or reordered (e.g. to see output from dialyzer before others)
# {:dialyzer, order: -1},
## ...or reconfigured (e.g. disable parallel execution of ex_unit in umbrella)
# {:ex_unit, umbrella: [parallel: false]},
## custom new tools may be added (Mix tasks or arbitrary commands)
# {:my_task, "mix my_task", env: %{"MIX_ENV" => "prod"}},
# {:my_tool, ["my_tool", "arg with spaces"]}
{:spark_formatter, "mix spark.formatter --check"},
{:spark_cheat_sheets, "mix spark.cheat_sheets --check"}
]
]

5
.dialyzer_ignore.exs Normal file
View file

@ -0,0 +1,5 @@
[
{"lib/wayfarer/server/proxy.ex", :unknown_function},
{"lib/wayfarer/target/check.ex", :unknown_function},
{"test/support/http_request.ex", :unknown_function}
]

17
.doctor.exs Normal file
View file

@ -0,0 +1,17 @@
%Doctor.Config{
ignore_modules: [
~r/^Inspect\./,
~r/^Support\./
],
ignore_paths: [],
min_module_doc_coverage: 40,
min_module_spec_coverage: 0,
min_overall_doc_coverage: 50,
min_overall_spec_coverage: 0,
min_overall_moduledoc_coverage: 100,
exception_moduledoc_required: true,
raise: false,
reporter: Doctor.Reporters.Full,
struct_type_spec_required: true,
umbrella: false
}

View file

@ -49,7 +49,7 @@ steps:
- .rebar3
- name: install dependencies
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
pull: "always"
environment:
MIX_ENV: test
@ -122,7 +122,7 @@ steps:
- .rebar3
- name: mix compile
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -135,7 +135,7 @@ steps:
- asdf mix compile --warnings-as-errors
- name: mix test
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -148,7 +148,7 @@ steps:
- asdf mix test
- name: mix credo
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -161,7 +161,7 @@ steps:
- asdf mix credo --strict
- name: mix hex.audit
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -174,7 +174,7 @@ steps:
- asdf mix hex.audit
- name: mix format
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -187,7 +187,7 @@ steps:
- asdf mix format --check-formatted
- name: mix deps.unlock
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -200,7 +200,7 @@ steps:
- asdf mix deps.unlock --check-unused
- name: mix doctor
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -213,7 +213,7 @@ steps:
- asdf mix doctor --full
- name: mix dialyzer
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -226,7 +226,7 @@ steps:
- asdf mix dialyzer
- name: mix git_ops.check_message
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
environment:
MIX_ENV: test
HEX_HOME: /drone/src/.hex
@ -240,7 +240,7 @@ steps:
- asdf mix git_ops.check_message .last_commit_message
- name: mix git_ops.release
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
when:
branch:
- main
@ -280,7 +280,7 @@ steps:
- fi
- name: build artifacts
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
when:
event:
- tag
@ -327,7 +327,7 @@ steps:
settings:
api_key:
from_secret: DRONE_TOKEN
base_url: https://code.harton.nz
base_url: https://harton.dev
files: artifacts/*.tar.gz
checksum: sha256
@ -351,12 +351,11 @@ steps:
commands:
- mc alias set store $${S3_ENDPOINT} $${ACCESS_KEY} $${SECRET_KEY}
- mc mb -p store/docs.harton.nz
- mc anonymous set download store/docs.harton.nz
- mc mirror --overwrite doc/ store/docs.harton.nz/$${DRONE_REPO}/$${DRONE_TAG}
- mc mirror --overwrite doc/ store/docs.harton.nz/$${DRONE_REPO}
- name: hex release
image: code.harton.nz/james/asdf_container:latest
image: harton.dev/james/asdf_container:latest
when:
event:
- tag

View file

@ -1,4 +1,57 @@
# Used by "mix format"
[
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
spark_locals_without_parens = [
algorithm: 1,
certfile: 1,
check: 0,
check: 1,
check: 2,
cipher_suite: 1,
config: 0,
config: 1,
config: 2,
connect_timeout: 1,
health_checks: 0,
health_checks: 1,
host_patterns: 0,
host_patterns: 1,
hostname: 1,
http: 2,
http: 3,
http_1_options: 1,
http_2_options: 1,
https: 2,
https: 3,
interval: 1,
keyfile: 1,
listeners: 0,
listeners: 1,
method: 1,
name: 1,
path: 1,
pattern: 1,
pattern: 2,
plug: 1,
plug: 2,
response_timeout: 1,
scheme: 1,
success_codes: 1,
targets: 0,
targets: 1,
thousand_island_options: 1,
threshold: 1,
transport: 1,
websocket_options: 1,
ws: 2,
ws: 3,
wss: 2,
wss: 3
]
[
import_deps: [:spark],
inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"],
plugins: [Spark.Formatter],
locals_without_parens: spark_locals_without_parens,
export: [
locals_without_parens: spark_locals_without_parens
]
]

View file

@ -1,2 +1,3 @@
erlang 26.1.2
elixir 1.15.6
erlang 27.0.1
elixir 1.17.2
hey 0.1.4

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"cSpell.words": ["ntoa"]
}

View file

@ -5,7 +5,65 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
<!-- changelog -->
## [v0.2.0](https://code.harton.nz/james/smokestack/compare/v0.1.0...v0.2.0) (2023-10-14)
## [v0.6.1](https://harton.dev/james/wayfarer/compare/v0.6.0...v0.6.1) (2024-08-27)
### Bug Fixes:
* Proxy: don't crash for responses with no body.
## [v0.6.0](https://harton.dev/james/wayfarer/compare/v0.5.0...v0.6.0) (2024-08-20)
### Features:
* Add request telemetry (#114)
## [v0.5.0](https://harton.dev/james/wayfarer/compare/v0.4.0...v0.5.0) (2024-08-17)
### Features:
* Add support for proxying websockets.
## [v0.4.1](https://harton.dev/james/wayfarer/compare/v0.4.0...v0.4.1) (2024-06-23)
### Bug Fixes:
* incorrect handling of Mint stream failure.
## [v0.4.0](https://harton.dev/james/wayfarer/compare/v0.3.0...v0.4.0) (2023-11-19)
### Features:
* add proxying. (#7)
## [v0.3.0](https://harton.dev/james/wayfarer/compare/v0.2.0...v0.3.0) (2023-10-14)
### Features:
* Target: Add healthy-checking HTTP targets.
### Improvements:
* Listener: Register listeners with scheme, address and port.
## [v0.2.0](https://harton.dev/james/wayfarer/compare/v0.1.0...v0.2.0) (2023-10-14)
@ -14,7 +72,7 @@ See [Conventional Commits](Https://conventionalcommits.org) for commit guideline
* Add ability to start and stop HTTP listeners. (#1)
## [v0.1.0](https://code.harton.nz/james/smokestack/compare/v0.1.0...v0.1.0) (2023-10-13)
## [v0.1.0](https://harton.dev/james/wayfarer/compare/v0.1.0...v0.1.0) (2023-10-13)

View file

@ -1,6 +1,6 @@
# Wayfarer
[![Build Status](https://drone.harton.nz/api/badges/bivouac/wayfarer/status.svg?ref=refs/heads/main)](https://drone.harton.nz/bivouac/wayfarer)
[![Build Status](https://drone.harton.dev/api/badges/james/wayfarer/status.svg?ref=refs/heads/main)](https://drone.harton.dev/james/wayfarer)
[![Hippocratic License HL3-FULL](https://img.shields.io/static/v1?label=Hippocratic%20License&message=HL3-FULL&labelColor=5e2751&color=bc8c3d)](https://firstdonoharm.dev/version/3/0/full.html)
Wayfarer is a runtime-configurable HTTP reverse proxy using
@ -9,22 +9,31 @@ Wayfarer is a runtime-configurable HTTP reverse proxy using
## Status
Wayfarer is yet to handle it's first HTTP request. Please hold.
Wayfarer is able to proxy HTTP/1, HTTP/2 and WebSocket requests. There are
probably still edge cases and bugs, but it can be used.
## Installation
Wayfarer is not yet available on Hex, so you will need to add it as a Git
dependency in your app:
Wayfarer is [available in Hex](https://hex.pm/packages/wayfarer), the package
can be installed by adding `wayfarer` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:wayfarer, git: "https://code.harton.nz/bivouac/wayfarer.git", tag: "v0.1.0"}
{:wayfarer, "~> 0.4.1"}
]
end
```
Documentation for `main` is always available on [my docs site](https://docs.harton.nz/bivouac/wayfarer/Wayfarer.html).
Documentation for the latest release can be found on
[HexDocs](https://hexdocs.pm/wayfarer) and for the `main` branch on
[docs.harton.nz](https://docs.harton.nz/james/wayfarer).
## Github Mirror
This repository is mirrored [on Github](https://github.com/jimsynz/wayfarer)
from it's primary location [on my Forgejo instance](https://harton.dev/james/wayfarer).
Feel free to raise issues and open PRs on Github.
## License

View file

@ -3,10 +3,14 @@ import Config
config :git_ops,
mix_project: Mix.Project.get!(),
changelog_file: "CHANGELOG.md",
repository_url: "https://code.harton.nz/james/smokestack",
repository_url: "https://harton.dev/james/wayfarer",
manage_mix_version?: true,
version_tag_prefix: "v",
manage_readme_version: "README.md"
config :spark, :formatter, remove_parens?: true
config :wayfarer,
start_listeners?: config_env() != :test
start_listener_supervisor?: config_env() != :test,
start_server_supervisor?: config_env() != :test,
start_target_supervisor?: config_env() != :test

File diff suppressed because one or more lines are too long

View file

@ -2,4 +2,6 @@ defmodule Wayfarer do
@moduledoc """
Documentation for `Wayfarer`.
"""
use Spark.Dsl, default_extensions: [extensions: Wayfarer.Dsl]
end

View file

@ -7,13 +7,17 @@ defmodule Wayfarer.Application do
@spec start(any, any) :: {:error, any} | {:ok, pid}
def start(_type, _args) do
[]
|> start_listeners?()
|> maybe_add_child(Wayfarer.Target.Supervisor, :start_target_supervisor?)
|> maybe_add_child(Wayfarer.Listener.Supervisor, :start_listener_supervisor?)
|> maybe_add_child(Wayfarer.Server.Supervisor, :start_server_supervisor?)
|> Supervisor.start_link(strategy: :one_for_one, name: Wayfarer.Supervisor)
end
defp start_listeners?(children) do
if Application.get_env(:wayfarer, :start_listeners?, true) do
Enum.concat(children, [Wayfarer.Listener.Supervisor])
defp maybe_add_child(children, child_spec, option) do
:wayfarer
|> Application.get_env(option, true)
|> if do
Enum.concat(children, [child_spec])
else
children
end

22
lib/wayfarer/dsl.ex Normal file
View file

@ -0,0 +1,22 @@
defmodule Wayfarer.Dsl do
alias Spark.Dsl.{Extension, Section}
alias Wayfarer.Dsl.{Config, Transformer}
@sections [
%Section{
name: :wayfarer,
top_level?: true,
entities: Config.entities()
}
]
@moduledoc """
The Wayfarer DSL for defining static proxy configurations.
## DSL options
#{Extension.doc(@sections)}
"""
use Extension, sections: @sections, transformers: [Transformer]
end

View file

@ -0,0 +1,69 @@
defmodule Wayfarer.Dsl.Config do
@moduledoc """
The struct for storing configurations as generated by the DSL.
"""
alias Spark.Dsl.Entity
alias Wayfarer.Dsl.{HealthChecks, HostPatterns, Listeners, Targets}
defstruct __identifier__: nil,
health_checks: HealthChecks.init(),
host_patterns: HostPatterns.init(),
listeners: Listeners.init(),
name: nil,
targets: Targets.init()
@type t :: %__MODULE__{
__identifier__: any,
health_checks: HealthChecks.t(),
host_patterns: HostPatterns.t(),
listeners: Listeners.t(),
name: nil | String.t(),
targets: Targets.t()
}
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :config,
target: __MODULE__,
schema: [
name: [
type: :string,
required: false
]
],
entities: [
health_checks: HealthChecks.entities(),
host_patterns: HostPatterns.entities(),
listeners: Listeners.entities(),
targets: Targets.entities()
],
singleton_entity_keys: [:health_checks, :host_patterns, :listeners, :targets],
args: [{:optional, :name, nil}],
transform: {__MODULE__, :transform, []}
}
]
end
@doc false
@spec transform(t) :: {:ok, t} | {:error, any}
def transform(config) do
with :ok <- verify_at_least_one_listener(config),
:ok <- verify_at_least_one_target(config) do
{:ok, config}
end
end
defp verify_at_least_one_listener(config) when config.listeners.listeners == [],
do: {:error, "Must provide at least one listener to accept incoming connections."}
defp verify_at_least_one_listener(_config), do: :ok
defp verify_at_least_one_target(config) when config.targets.targets == [],
do: {:error, "Must provide at least one target to send requests to."}
defp verify_at_least_one_target(_config), do: :ok
end

View file

@ -0,0 +1,168 @@
defmodule Wayfarer.Dsl.HealthCheck do
@moduledoc """
A struct for storing a health-check generated by the DSL.
"""
alias Spark.Dsl.Entity
@defaults [
connect_timeout: :timer.seconds(5),
interval: :timer.seconds(30),
method: :get,
path: "/",
response_timeout: 500,
success_codes: [200..299],
threshold: 3
]
defstruct connect_timeout: @defaults[:connect_timeout],
hostname: nil,
interval: @defaults[:interval],
method: @defaults[:method],
name: nil,
path: @defaults[:path],
response_timeout: @defaults[:response_timeout],
success_codes: @defaults[:success_codes],
threshold: @defaults[:threshold]
@type t :: %__MODULE__{
connect_timeout: pos_integer,
hostname: nil | String.t(),
interval: pos_integer,
method: :get | :head | :port | :put | :delete | :connect | :options | :trace | :patch,
name: nil | String.t(),
path: String.t(),
response_timeout: pos_integer,
success_codes: [Range.t(100, 599)],
threshold: pos_integer
}
@schema [
name: [
type: {:or, [:string, nil]},
required: false,
doc: "A unique name for the health check."
],
method: [
type: {:in, [:get, :head, :post, :put, :delete, :connect, :options, :trace, :patch]},
required: false,
default: @defaults[:method],
doc: "The HTTP method to use for the request."
],
connect_timeout: [
type: :pos_integer,
required: false,
default: @defaults[:connect_timeout],
doc: "Connection timeout in milliseconds"
],
response_timeout: [
type: :pos_integer,
required: false,
default: @defaults[:response_timeout],
doc: "Response timeout in milliseconds"
],
hostname: [
type: {:or, [nil, :string]},
required: false,
doc: "The HTTP hostname to use when sending the request. Defaults to the IP address."
],
interval: [
type: :pos_integer,
required: false,
default: @defaults[:interval],
doc: "Interval in milliseconds"
],
threshold: [
type: :pos_integer,
required: false,
default: @defaults[:threshold],
doc: "Success threshold"
],
path: [
type: :string,
required: false,
default: @defaults[:path],
doc: "Path"
],
success_codes: [
type: {:wrap_list, {:or, [{:struct, Range}, {:in, 100..500}]}},
required: false,
default: @defaults[:success_codes],
doc: "HTTP status codes which are considered successful."
]
]
@doc false
@spec default :: t
def default, do: struct(__MODULE__, @defaults)
@doc false
@spec to_options(t) :: keyword()
def to_options(check) do
check
|> Map.from_struct()
|> Enum.to_list()
end
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :check,
target: __MODULE__,
schema: @schema,
args: [{:optional, :name, nil}],
transform: {__MODULE__, :transform, []}
}
]
end
@doc false
@spec schema :: NimbleOptions.schema()
def schema, do: @schema
@doc false
@spec transform(t) :: {:ok, t} | {:error, any}
def transform(check) do
with {:ok, success_codes} <- transform_success_codes(check.success_codes) do
maybe_set_name(%{check | success_codes: success_codes})
end
end
defguardp is_valid_status_code?(code) when is_integer(code) and code >= 100 and code <= 599
defguardp is_valid_range?(range)
when is_struct(range, Range) and is_valid_status_code?(range.first) and
is_valid_status_code?(range.last)
defp transform_success_codes(range) when is_valid_range?(range), do: {:ok, [range]}
defp transform_success_codes([]), do: {:ok, []}
defp transform_success_codes([head | tail]) when is_valid_range?(head) do
with {:ok, tail} <- transform_success_codes(tail) do
{:ok, [head | tail]}
end
end
defp transform_success_codes([head | tail])
when is_integer(head) and is_valid_status_code?(head) do
with {:ok, tail} <- transform_success_codes(tail) do
{:ok, [head..head | tail]}
end
end
defp transform_success_codes([range | _]),
do: {:error, "Value `#{inspect(range)}` is not valid. Must be a range between 100..599"}
defp maybe_set_name(check) when is_binary(check.name), do: {:ok, check}
defp maybe_set_name(check) do
name =
check.success_codes
|> Enum.map_join(",", &"#{&1.first}..#{&1.last}")
|> then(&"#{check.path}:[#{&1}]")
{:ok, %{check | name: name}}
end
end

View file

@ -0,0 +1,30 @@
defmodule Wayfarer.Dsl.HealthChecks do
@moduledoc """
A struct for storing a group of health-checks generated by the DSL.
"""
alias Spark.Dsl.Entity
alias Wayfarer.Dsl.HealthCheck
defstruct health_checks: []
@type t :: %__MODULE__{health_checks: [HealthCheck.t()]}
@doc false
@spec init :: t
def init, do: %__MODULE__{health_checks: []}
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :health_checks,
target: __MODULE__,
entities: [
health_checks: HealthCheck.entities()
]
}
]
end
end

View file

@ -0,0 +1,44 @@
defmodule Wayfarer.Dsl.HostPattern do
@moduledoc """
A struct for storing a host pattern generated by the DSL.
"""
alias Spark.Dsl.Entity
defstruct pattern: nil
@type t :: %__MODULE__{pattern: String.t()}
# This is not a rigorous check, it's just enough to do for now.
@pattern_regex ~r/^(\*\.)?([a-zA-Z0-9-]+\.)*([a-zA-Z0-9-]+)\.?$/
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :pattern,
target: __MODULE__,
schema: [
pattern: [
type: :string,
required: true,
doc: "A hostname matching pattern."
]
],
args: [:pattern],
transform: {__MODULE__, :transform, []}
}
]
end
@doc false
@spec transform(t) :: {:ok, t} | {:error, any}
def transform(pattern) do
if Regex.match?(@pattern_regex, pattern.pattern) do
{:ok, pattern}
else
{:error, "Invalid host pattern: `#{pattern}`"}
end
end
end

View file

@ -0,0 +1,30 @@
defmodule Wayfarer.Dsl.HostPatterns do
@moduledoc """
A struct for storing a group of host patterns generated by the DSL.
"""
alias Spark.Dsl.Entity
alias Wayfarer.Dsl.HostPattern
defstruct host_patterns: []
@type t :: %__MODULE__{host_patterns: []}
@doc false
@spec init :: t
def init, do: %__MODULE__{host_patterns: []}
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :host_patterns,
target: __MODULE__,
entities: [
host_patterns: HostPattern.entities()
]
}
]
end
end

View file

@ -0,0 +1,184 @@
defmodule Wayfarer.Dsl.Listener do
@moduledoc """
A struct for storing a listener generated by the DSL.
"""
alias Spark.Dsl.Entity
alias Wayfarer.Utils
defstruct address: nil,
certfile: nil,
cipher_suite: nil,
http_1_options: [],
http_2_options: [],
keyfile: nil,
name: nil,
port: nil,
scheme: :http,
thousand_island_options: [],
uri: nil,
websocket_options: []
@type t :: %__MODULE__{
address: IP.Address.t(),
certfile: nil | String.t(),
cipher_suite: nil | :strong | :compatible,
http_1_options: Bandit.http_1_options(),
http_2_options: Bandit.http_2_options(),
keyfile: nil | String.t(),
name: nil | String.t(),
port: :inet.port_number(),
scheme: :http | :https,
thousand_island_options: ThousandIsland.options(),
uri: URI.t(),
websocket_options: Bandit.websocket_options()
}
@http_schema [
scheme: [
type: {:in, [:http, :https]},
required: true,
doc: "The listening protocol."
],
address: [
type: {:or, [{:struct, IP.Address}, :string]},
required: true,
doc: "The address of the interface to listen on."
],
name: [
type: {:or, [nil, :string]},
required: false,
doc: "A unique name for the listener (defaults to the URI)."
],
port: [
type: :pos_integer,
required: true,
doc: "The TCP port on which to listen for incoming connections."
],
http_1_options: [
type: :keyword_list,
required: false,
default: [],
doc: "Options to configure the HTTP/1 stack in Bandit."
],
http_2_options: [
type: :keyword_list,
required: false,
default: [],
doc: "Options to configure the HTTP/2 stack in Bandit."
],
thousand_island_options: [
type: :keyword_list,
required: false,
default: [],
doc: "Possible options to configure a ThousandIsland server."
],
websocket_options: [
type: :keyword_list,
required: false,
default: [],
doc: "Options to configure the WebSocket stack in Bandit."
]
]
@https_schema @http_schema ++
[
certfile: [
type: :string,
required: false,
doc:
"The path to a file containing the SSL certificate to use for this listener."
],
keyfile: [
type: :string,
required: false,
doc: "The path to a file containing the SSL key to use for this listener."
],
cipher_suite: [
type: {:in, [nil, :strong, :compatible]},
required: false,
doc:
"Used to define a pre-selected set of ciphers, as described by `Plug.SSL.configure/1`."
]
]
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :http,
target: __MODULE__,
schema: @http_schema,
auto_set_fields: [scheme: :http],
args: [:address, :port],
imports: [IP.Sigil],
transform: {__MODULE__, :transform, []}
},
%Entity{
name: :https,
target: __MODULE__,
schema: @https_schema,
auto_set_fields: [scheme: :https],
args: [:address, :port],
imports: [IP.Sigil],
transform: {__MODULE__, :transform, []}
}
]
end
@doc false
@spec transform(t) :: {:ok, t} | {:error, any}
def transform(listener) do
with :ok <- validate_cert_and_key(listener),
{:ok, listener} <- maybe_parse_address(listener),
{:ok, listener} <- set_uri(listener) do
maybe_set_name(listener)
end
end
@doc false
@spec schema :: NimbleOptions.schema()
def schema,
do:
@https_schema
|> make_optional(:certfile)
|> make_optional(:keyfile)
|> make_optional(:cipher_suite)
defp make_optional(schema, field) do
schema
|> Keyword.update!(field, fn config ->
config
|> Keyword.put(:required, false)
|> Keyword.update!(:type, &{:or, [nil, &1]})
end)
end
defp validate_cert_and_key(listener) when listener.scheme == :http, do: :ok
defp validate_cert_and_key(listener)
when is_binary(listener.certfile) and is_binary(listener.keyfile),
do: :ok
defp validate_cert_and_key(_listener),
do: {:error, "Both `certfile` and `keyfile` options must be set for an HTTPS listener."}
defp maybe_parse_address(listener) when is_struct(listener.address, IP.Address),
do: {:ok, listener}
defp maybe_parse_address(listener) when is_binary(listener.address) do
with {:ok, address} <- IP.Address.from_string(listener.address) do
{:ok, %{listener | address: address}}
end
end
defp set_uri(listener) do
with {:ok, uri} <- Utils.to_uri(listener.scheme, listener.address, listener.port) do
{:ok, %{listener | uri: uri}}
end
end
defp maybe_set_name(listener) when is_binary(listener.name), do: {:ok, listener}
defp maybe_set_name(listener), do: {:ok, %{listener | name: to_string(listener.uri)}}
end

View file

@ -0,0 +1,31 @@
defmodule Wayfarer.Dsl.Listeners do
@moduledoc """
A struct for storing a group of listeners generated by the DSL.
"""
alias Spark.Dsl.Entity
alias Wayfarer.Dsl.Listener
defstruct listeners: []
@type t :: %__MODULE__{listeners: [Listener.t()]}
@doc false
@spec init :: t
def init, do: %__MODULE__{listeners: []}
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :listeners,
target: __MODULE__,
entities: [
listeners: Listener.entities()
],
imports: [IP.Sigil]
}
]
end
end

186
lib/wayfarer/dsl/target.ex Normal file
View file

@ -0,0 +1,186 @@
defmodule Wayfarer.Dsl.Target do
@moduledoc """
A struct for storing a target generated by the DSL.
"""
alias Spark.{Dsl.Entity, Options}
alias Wayfarer.Dsl.{HealthCheck, HealthChecks}
alias Wayfarer.Utils
defstruct address: nil,
health_checks: HealthChecks.init(),
module: nil,
name: nil,
port: nil,
scheme: :http,
transport: :auto,
uri: nil
@type t :: %__MODULE__{
address: IP.Address.t(),
health_checks: HealthChecks.t(),
module: nil | module,
name: nil | String.t(),
port: :inet.port_number(),
scheme: :http | :https | :plug | :ws | :wss,
transport: :http1 | :http2 | :auto,
uri: URI.t()
}
@shared_schema [
address: [
type: {:or, [{:struct, IP.Address}, :string]},
required: true,
doc: "The address of the interface to listen on."
],
name: [
type: {:or, [nil, :string]},
required: false,
doc: "A unique name for the target (defaults to the URI)."
],
port: [
type: :pos_integer,
required: true,
doc: "The TCP port on which to listen for incoming connections."
],
transport: [
type: {:in, [:http1, :http2, :auto]},
required: false,
default: :auto,
doc: "Which HTTP protocol to use."
]
]
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :http,
target: __MODULE__,
schema: @shared_schema,
auto_set_fields: [scheme: :http],
args: [:address, :port],
imports: [IP.Sigil],
transform: {__MODULE__, :transform, []},
entities: [health_checks: HealthChecks.entities()],
singleton_entity_keys: [:health_checks]
},
%Entity{
name: :https,
target: __MODULE__,
schema: @shared_schema,
auto_set_fields: [scheme: :https],
args: [:address, :port],
imports: [IP.Sigil],
transform: {__MODULE__, :transform, []},
entities: [health_checks: HealthChecks.entities()],
singleton_entity_keys: [:health_checks]
},
%Entity{
name: :plug,
target: __MODULE__,
schema: [
module: [
type: {:spark_behaviour, Plug},
doc: "A plug which can handle requests.",
required: true
]
],
auto_set_fields: [scheme: :plug],
args: [:module]
},
%Entity{
name: :ws,
target: __MODULE__,
schema: @shared_schema,
auto_set_fields: [scheme: :ws],
args: [:address, :port],
imports: [IP.Sigil],
transform: {__MODULE__, :transform, []},
entities: [health_checks: HealthChecks.entities()],
singleton_entity_keys: [:health_checks]
},
%Entity{
name: :wss,
target: __MODULE__,
schema: @shared_schema,
auto_set_fields: [scheme: :wss],
args: [:address, :port],
imports: [IP.Sigil],
transform: {__MODULE__, :transform, []},
entities: [health_checks: HealthChecks.entities()],
singleton_entity_keys: [:health_checks]
}
]
end
@doc false
@spec transform(t) :: {:ok, t} | {:error, any}
def transform(target) do
with {:ok, target} <- ensure_health_checks(target),
{:ok, target} <- maybe_parse_address(target),
{:ok, target} <- set_uri(target) do
maybe_set_name(target)
end
end
@doc false
def schema do
@shared_schema
|> Options.Helpers.make_optional!(:address)
|> Options.Helpers.make_optional!(:port)
|> Keyword.merge(
scheme: [
type: {:in, [:http, :https, :plug, :ws, :wss]},
required: true,
doc: "The connection type for the target."
],
plug: [
type: {:behaviour, Plug},
required: false,
doc: "A plug to use when `scheme` is `:plug`."
],
health_checks: [
type: {:list, {:keyword_list, HealthCheck.schema()}},
required: false,
default: [],
doc: "A list of health check configurations."
]
)
end
defp ensure_health_checks(target) when is_nil(target.health_checks),
do: {:ok, %{target | health_checks: HealthChecks.init()}}
defp ensure_health_checks(target), do: {:ok, target}
defp maybe_parse_address(target) when is_struct(target.address, IP.Address),
do: {:ok, target}
defp maybe_parse_address(target) when is_binary(target.address) do
with {:ok, address} <- IP.Address.from_string(target.address) do
{:ok, %{target | address: address}}
end
end
defp maybe_parse_address(target) when is_nil(target.address), do: {:ok, target}
defp set_uri(target) when target.scheme == :plug do
uri = %URI{
scheme: "plug",
host: inspect(target.module)
}
{:ok, %{target | uri: uri}}
end
defp set_uri(target) do
with {:ok, uri} <- Utils.to_uri(target.scheme, target.address, target.port) do
{:ok, %{target | uri: uri}}
end
end
defp maybe_set_name(target) when is_binary(target.name), do: {:ok, target}
defp maybe_set_name(target), do: {:ok, %{target | name: to_string(target.uri)}}
end

View file

@ -0,0 +1,38 @@
defmodule Wayfarer.Dsl.Targets do
@moduledoc """
A struct for storing a group of targets generated by the DSL.
"""
alias Spark.Dsl.Entity
alias Wayfarer.Dsl.Target
defstruct algorithm: :round_robin, targets: []
@type t :: %__MODULE__{algorithm: :round_robin | :sticky, targets: [Target.t()]}
@doc false
@spec init :: t
def init, do: %__MODULE__{algorithm: :round_robin, targets: []}
@doc false
@spec entities :: [Entity.t()]
def entities do
[
%Entity{
name: :targets,
target: __MODULE__,
entities: [
targets: Target.entities()
],
imports: [IP.Sigil],
schema: [
algorithm: [
type: {:in, [:round_robin, :sticky]},
doc: "The target selection algorithm.",
default: :round_robin
]
]
}
]
end
end

View file

@ -0,0 +1,174 @@
defmodule Wayfarer.Dsl.Transformer do
@moduledoc """
The Transformer for the Wayfarer DSL extension.
"""
alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
alias Wayfarer.Dsl.{Config, HealthCheck}
use Transformer
@doc false
@spec transform(Dsl.t()) :: {:ok, Dsl.t()} | {:error, DslError.t()}
def transform(dsl_state) do
with {:ok, listeners} <- get_unique_listeners(dsl_state),
{:ok, routing_table} <- build_routing_table(dsl_state),
{:ok, targets} <- build_unique_targets(dsl_state) do
listeners = Macro.escape(listeners)
routing_table = Macro.escape(routing_table)
targets = Macro.escape(targets)
dsl_state =
dsl_state
|> Transformer.eval(
[listeners: listeners, routing_table: routing_table, targets: targets],
quote do
use Wayfarer.Server,
listeners: unquote(listeners),
targets: unquote(targets),
routing_table: unquote(routing_table)
end
)
{:ok, dsl_state}
end
end
defp get_unique_listeners(dsl_state) do
dsl_state
|> Transformer.get_entities([:wayfarer])
|> Enum.filter(&is_struct(&1, Config))
|> Enum.flat_map(& &1.listeners.listeners)
|> Enum.group_by(& &1.uri)
|> Enum.reduce_while({:ok, []}, fn {uri, listeners_for_uri}, {:ok, all_listeners} ->
listeners_for_uri
|> Enum.uniq()
|> case do
[listener] ->
{:cont, {:ok, [listener_to_options(listener) | all_listeners]}}
_listeners ->
{:halt,
{:error,
DslError.exception(
module: Transformer.get_persisted(dsl_state, :module),
path: [:wayfarer],
message: "Multiple listeners for #{uri} with differing configurations."
)}}
end
end)
end
defp build_routing_table(dsl_state) do
table =
dsl_state
|> Transformer.get_entities([:wayfarer])
|> Enum.filter(&is_struct(&1, Config))
|> Enum.flat_map(fn config ->
listeners = build_routing_listeners(config)
host_patterns = build_routing_host_patterns(config)
targets = build_routing_targets(config)
for listener <- listeners, target <- targets do
{listener, target, host_patterns, config.targets.algorithm}
end
end)
{:ok, table}
end
defp build_routing_host_patterns(config) when is_struct(config, Config),
do: build_routing_host_patterns(config.host_patterns.host_patterns)
defp build_routing_host_patterns([]), do: ["*"]
defp build_routing_host_patterns(patterns), do: Enum.map(patterns, & &1.pattern)
defp build_routing_listeners(config) when is_struct(config, Config),
do: build_routing_listeners(config.listeners.listeners)
defp build_routing_listeners(listeners) do
Enum.map(listeners, &{&1.scheme, IP.Address.to_tuple(&1.address), &1.port})
end
defp build_routing_targets(config) when is_struct(config, Config),
do: build_routing_targets(config.targets.targets)
defp build_routing_targets(targets) do
Enum.map(targets, fn
target when target.scheme == :plug ->
{target.scheme, target.module}
target ->
{target.scheme, IP.Address.to_tuple(target.address), target.port, target.transport}
end)
end
defp build_targets(config) do
with {:ok, global_checks} <- build_global_health_checks(config) do
targets =
config.targets.targets
|> Enum.map(fn
target when target.scheme == :plug ->
target
|> Map.from_struct()
|> Map.drop([:address, :health_checks, :port, :uri])
|> Enum.to_list()
target when target.scheme in [:http, :https, :ws, :wss] ->
health_checks =
target.health_checks.health_checks
|> Enum.map(&HealthCheck.to_options/1)
|> Enum.concat(global_checks)
|> Enum.uniq()
|> maybe_add_default_health_check()
target
|> Map.from_struct()
|> Map.drop([:module, :uri])
|> Map.put(:health_checks, health_checks)
|> Enum.to_list()
end)
{:ok, targets}
end
end
defp maybe_add_default_health_check([]) do
HealthCheck.default()
|> HealthCheck.to_options()
|> then(&[&1])
end
defp maybe_add_default_health_check(checks), do: checks
defp build_unique_targets(dsl_state) do
dsl_state
|> Transformer.get_entities([:wayfarer])
|> Enum.filter(&is_struct(&1, Config))
|> Enum.reduce({:ok, []}, fn config, {:ok, targets} ->
with {:ok, new_targets} <- build_targets(config) do
{:ok, Enum.concat(targets, new_targets)}
end
end)
|> case do
{:ok, targets} -> {:ok, Enum.uniq(targets)}
{:error, reason} -> {:error, reason}
end
end
defp build_global_health_checks(config) when is_list(config.health_checks.health_checks) do
checks =
config.health_checks.health_checks
|> Enum.map(&HealthCheck.to_options/1)
{:ok, checks}
end
defp build_global_health_checks(_), do: {:ok, []}
defp listener_to_options(listener) do
listener
|> Map.from_struct()
|> Map.delete(:uri)
|> Enum.to_list()
end
end

View file

@ -1,23 +1,182 @@
defmodule Wayfarer.Listener do
# @moduledoc ⬇️⬇️
use GenServer, restart: :transient
require Logger
alias Spark.Options
import Wayfarer.Utils
@options_schema [
scheme: [
type: {:in, [:http, :https]},
doc: "The connection protocol.",
required: true
],
port: [
type: {:in, 1..0xFFFF},
doc: "The TCP port to listen on for connections.",
required: true
],
address: [
type: {:struct, IP.Address},
doc: "The IP address of an interface to bind to.",
required: true
],
drain_timeout: [
type: :pos_integer,
doc: "How long to wait for existing connections to complete on shutdown.",
required: false,
default: :timer.seconds(60)
],
module: [
type: {:behaviour, Wayfarer.Server},
doc: "The proxy module this listener is linked to.",
required: true
],
name: [
type: {:or, [:string, nil]},
doc: "An optional name for the listener.",
required: false
],
keyfile: [
type: {:or, [:string, nil]},
doc: "The path to the SSL secret key file.",
required: false,
subsection: "HTTPS Options"
],
certfile: [
type: {:or, [:string, nil]},
doc: "The path to the SSL certificate file",
required: false,
subsection: "HTTPS Options"
],
cipher_suite: [
type: {:in, [nil, :strong, :compatible]},
doc:
"Used to define a pre-selected set of ciphers, as described by `Plug.SSL.configure/1.`",
required: false,
subsection: "HTTPS Options"
],
http_1_options: [
type: :keyword_list,
doc: "See `t:Bandit.http_1_options/0`.",
required: false,
subsection: "Protocol-specific Options"
],
http_2_options: [
type: :keyword_list,
doc: "See `t:Bandit.http_2_options/0`.",
required: false,
subsection: "Protocol-specific Options"
],
websocket_options: [
type: :keyword_list,
doc: "See `t:Bandit.websocket_options/0`.",
required: false,
subsection: "Protocol-specific Options"
],
thousand_island_options: [
type: :keyword_list,
doc: "See `t:ThousandIsland.options/0`",
subsection: "Protocol-specific Options"
]
]
@moduledoc """
Manage HTTP listeners.
A GenServer which manages the state of each Bandit listener.
You should not need to create one of these yourself, instead use it via
`Wayfarer.Server`.
## Options
#{Options.docs(@options_schema)}
"""
alias Wayfarer.Listener.DynamicSupervisor, as: ListenerSupervisor
alias Wayfarer.Listener.Registry, as: ListenerRegistry
alias Wayfarer.Listener.Server
@doc false
@spec start_link(keyword) :: GenServer.on_start()
def start_link(options), do: GenServer.start_link(__MODULE__, options)
@doc """
Start listener.
"""
@spec start_listener(Server.options()) :: Supervisor.on_start_child()
def start_listener(options),
do: DynamicSupervisor.start_child(ListenerSupervisor, {Server, options})
@doc false
@impl true
def init(options) do
with {:ok, options} <- validate_options(options),
bandit_options <- build_bandit_options(options),
{:ok, pid} <- Bandit.start_link(bandit_options),
{:ok, {listen_address, listen_port}} <- ThousandIsland.listener_info(pid),
{:ok, listen_address} <- IP.Address.from_tuple(listen_address),
{:ok, uri} <- to_uri(options[:scheme], listen_address, listen_port) do
Logger.info("Started Wayfarer listener on #{uri}")
@doc """
Stop listener
"""
@spec stop_listener(:inet.socket_address(), :inet.port_number()) :: :ok
def stop_listener(ip, port),
do: GenServer.stop({:via, Registry, {ListenerRegistry, {ip, port}}}, :normal)
{:ok, %{server: pid, name: options[:name], uri: uri}}
else
:error -> {:stop, "Unable to retrieve listener information."}
{:error, reason} -> {:stop, reason}
end
end
@doc false
@impl true
def terminate(:normal, %{server: server}) do
GenServer.stop(server, :normal)
end
defp validate_options(options) do
case Keyword.fetch(options, :scheme) do
{:ok, :https} ->
schema =
@options_schema
|> Options.Helpers.make_required!(:keyfile)
|> Options.Helpers.make_required!(:certfile)
|> Options.Helpers.make_required!(:cipher_suite)
Options.validate(options, schema)
_ ->
Options.validate(options, @options_schema)
end
end
defp build_bandit_options(options) do
plug_options = %{
address: IP.Address.to_tuple(options[:address]),
module: options[:module],
port: options[:port],
scheme: options[:scheme]
}
thousand_island_options =
options
|> Keyword.get(:thousand_island_options, [])
|> Keyword.put_new(:shutdown_timeout, options[:drain_timeout])
base_options = [
ip: IP.Address.to_tuple(options[:address]),
otp_app: Application.get_application(options[:module]),
plug: {Wayfarer.Server.Plug, plug_options},
port: options[:port],
scheme: options[:scheme],
thousand_island_options: thousand_island_options
]
ssl_options =
if options[:scheme] == :https do
[
certfile: options[:certfile],
keyfile: options[:keyfile]
]
|> maybe_add_option(:cipher_suite, options[:cipher_suite])
else
[]
end
base_options
|> Enum.concat(ssl_options)
|> maybe_add_option(:http_1_options, options[:http_1_options])
|> maybe_add_option(:http_2_options, options[:http_2_options])
|> maybe_add_option(:websocket_options, options[:websocket_options])
end
defp maybe_add_option(options, _key, nil), do: options
defp maybe_add_option(options, key, value), do: Keyword.put(options, key, value)
end

View file

@ -1,21 +0,0 @@
defmodule Wayfarer.Listener.Plug do
@moduledoc """
Plug pipeline to handle inbound HTTP connections.
"""
import Plug.Conn
@behaviour Plug
@doc false
@impl true
def init(options) do
options
end
@doc false
@impl true
def call(conn, _opts) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(502, "Bad Gateway")
end
end

View file

@ -1,86 +0,0 @@
defmodule Wayfarer.Listener.Server do
@moduledoc """
A GenServer which manages the state of each listener.
"""
alias Wayfarer.Listener.Plug
alias Wayfarer.Listener.Registry, as: ListenerRegistry
use GenServer, restart: :transient
require Logger
# How long to wait for connections to drain when shutting down a listener.
@drain_timeout :timer.seconds(60)
@type options :: [
scheme: :http | :https,
port: :inet.port_number(),
ip: :inet.socket_address(),
keyfile: binary(),
certfile: binary(),
otp_app: binary() | atom(),
cipher_suite: :strong | :compatible,
display_plug: module(),
startup_log: Logger.level() | false,
thousand_island_options: ThousandIsland.options(),
http_1_options: Bandit.http_1_options(),
http_2_options: Bandit.http_2_options(),
websocket_options: Bandit.websocket_options()
]
@doc false
@spec start_link(options) :: GenServer.on_start()
def start_link(options), do: GenServer.start_link(__MODULE__, options)
@doc false
@impl true
def init(options) do
options =
options
|> Keyword.put(:plug, Plug)
|> Keyword.put(:startup_log, false)
|> Keyword.update(
:thousand_island_options,
[shutdown_timeout: @drain_timeout],
&Keyword.put_new(&1, :shutdown_timeout, @drain_timeout)
)
with {:ok, scheme} <- fetch_required_option(options, :scheme),
{:ok, pid} <- Bandit.start_link(options),
{:ok, %{address: addr, port: port}} <- ThousandIsland.listener_info(pid),
{:ok, _pid} <- Registry.register(ListenerRegistry, {addr, port}, pid) do
listen_url = listen_url(scheme, addr, port)
version = Application.spec(:wayfarer)[:vsn]
Logger.info("Started Wayfarer v#{version} listener on #{listen_url}")
{:ok, %{server: pid, options: options, addr: addr}}
end
end
@doc false
@impl true
def terminate(:normal, %{server: server}) do
GenServer.stop(server, :normal)
end
defp fetch_required_option(options, option) do
case Keyword.fetch(options, option) do
{:ok, value} -> {:ok, value}
:error -> {:error, {:required_option, option}}
end
end
defp listen_url(scheme, {:local, socket_path}, _), do: "#{scheme}:#{socket_path}"
defp listen_url(scheme, address, port) when tuple_size(address) == 4 do
"#{scheme}://#{:inet.ntoa(address)}:#{port}"
|> URI.new!()
|> to_string()
end
defp listen_url(scheme, address, port) when tuple_size(address) == 8 do
"#{scheme}://[#{:inet.ntoa(address)}]:#{port}"
|> URI.new!()
|> to_string()
end
end

View file

@ -1,20 +1,14 @@
defmodule Wayfarer.Listener.Supervisor do
@moduledoc """
Supervisor for HTTP listeners.
"""
@moduledoc false
use Supervisor
@doc false
@spec start_link(any) :: Supervisor.on_start()
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg)
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
@doc false
@impl true
def init(_arg) do
def init(_) do
[
{Registry, keys: :unique, name: Wayfarer.Listener.Registry},
{DynamicSupervisor, name: Wayfarer.Listener.DynamicSupervisor}
{DynamicSupervisor, name: Wayfarer.Listener.DynamicSupervisor, strategy: :one_for_one}
]
|> Supervisor.init(strategy: :one_for_one)
end

323
lib/wayfarer/router.ex Normal file
View file

@ -0,0 +1,323 @@
defmodule Wayfarer.Router do
@moduledoc """
Wayfarer's routing is implemented on top of an ETS table to allow for fast
querying and easy mutation.
This module provides a standardised interface to interact with a routing
table.
"""
import Wayfarer.Utils
alias Wayfarer.Target.Selector
require Selector
@table_options [
:duplicate_bag,
:protected,
:named_table,
read_concurrency: true
]
@type scheme :: :http | :https
@typedoc """
The row format for each route in the table.
"""
@type route :: {listener, host_pattern, target, algorithm, health}
@typedoc """
Uniquely identifies a listener.
"""
@type listener :: {scheme, :inet.ip_address(), :socket.port_number()}
@typedoc """
A fully qualified hostname optionally with a leading wildcard.
"""
@type host_name :: String.t()
@typedoc """
Host patterns are built by splitting hostnames into segments and storing them
as tuples. Hosts with a leading wildcard (`*`) segment will have that segment
replaced with a `:_`.
## Examples
* `www.example.com` becomes `{"com", "example", "www"}`
* `*.example.com` becomes `{"com", "example", :_}`
"""
@type host_pattern :: tuple()
@typedoc """
Uniquely identifies a request target, either a remote http(s) server or a
local `Plug`.
"""
@type target ::
{:http | :https | :ws | :wss, :inet.ip_address(), :socket.port_number(),
:http1 | :http2 | :auto}
| {:plug, module}
| {:plug, {module, any}}
@typedoc """
Like `t:target/0` except that it can contain user input for the address portion.
"""
@type target_input ::
{:http | :https | :ws | :wss, Wayfarer.Utils.address_input(), :socket.port_number(),
:http1 | :http2 | :auto}
| {:plug, module}
| {:plug, {module, any}}
@typedoc """
The algorithm used to select which target to forward requests to (when there
is more than one matching target).
"""
@type algorithm :: Selector.algorithm()
@typedoc """
The current health status of the target.
"""
@type health :: :initial | :healthy | :unhealthy | :draining
@doc """
Create a new, empty routing table.
This table is protected and owned by the calling process.
"""
@spec init(module) :: {:ok, :ets.tid()} | {:error, any}
def init(name) do
{:ok, :ets.new(name, @table_options)}
rescue
e -> {:error, e}
end
@doc """
Add new route to the routing table.
A route will be added for each host name with it's health state set to `:initial`.
This should only ever be called by `Wayfarer.Server` directly.
"""
@spec add_route(:ets.tid(), listener, target_input, [host_name], algorithm) ::
:ok | {:error, any}
def add_route(table, listener, target, host_names, algorithm) do
with {:ok, entries} <- route_to_entries(table, listener, target, host_names, algorithm) do
:ets.insert(table, entries)
:ok
end
rescue
e -> {:error, e}
end
@doc """
Add a number of routes into the routing table.
"""
@spec import_routes(:ets.tid(), [{listener, target_input, [host_name], algorithm}]) :: :ok
def import_routes(table, routes) do
with {:ok, entries} <- routes_to_entries(table, routes) do
:ets.insert(table, entries)
:ok
end
rescue
e -> {:error, e}
end
@doc """
Remove a listener from the routing table.
This should only ever be done by `Wayfarer.Server` after it has finished
draining connections.
"""
@spec remove_listener(:ets.tid(), listener) :: :ok
def remove_listener(table, listener) do
:ets.match_delete(table, {listener, :_, :_, :_, :_})
:ok
rescue
e -> {:error, e}
end
@doc """
Remove a target from the routing table.
This should only ever be done by `Wayfarer.Server` after it has finished
draining connections.
"""
@spec remove_target(:ets.tid(), target) :: :ok
def remove_target(table, target) do
:ets.match_delete(table, {:_, :_, target, :_, :_})
:ok
rescue
e -> {:error, e}
end
@doc """
Change a target's health state.
"""
@spec update_target_health_status(:ets.tid(), target, health) :: :ok
def update_target_health_status(table, {scheme, address, port, transport}, status) do
# Match spec generated using:
# :ets.fun2ms(fn {listener, host_pattern, {:http, {192, 168, 4, 26}, 80, transport}, algorithm, _} ->
# {listener, host_pattern, {:http, {192, 168, 4, 26}, 80, transport}, algorithm, :healthy}
# end)
match_spec = [
{{:"$1", :"$2", {scheme, address, port, transport}, :"$3", :_}, [],
[{{:"$1", :"$2", {{scheme, {address}, port, transport}}, :"$3", status}}]}
]
:ets.select_replace(table, match_spec)
:ok
rescue
e -> {:error, e}
end
@doc """
Find healthy targets for a given listener and hostname.
"""
@spec find_healthy_targets(:ets.tid(), listener, String.t()) ::
{:ok, [{target, algorithm}]} | {:error, any}
def find_healthy_targets(table, listener, hostname) do
# Match spec generated with:
# :ets.fun2ms(fn {:listener, {segment, "example", "com"}, target, algorithm, :healthy}
# when segment == "www" or segment == :_ ->
# {target, algorithm}
# end)
[head | tail] =
hostname
|> String.trim_trailing(".")
|> String.split(".")
host_match = [:"$1" | tail] |> List.to_tuple()
match_spec =
[
{{listener, host_match, :"$2", :"$3", :healthy},
[{:orelse, {:==, :"$1", head}, {:==, :"$1", :_}}], [{{:"$2", :"$3"}}]}
]
targets =
table
|> :ets.select(match_spec)
|> Enum.uniq()
{:ok, targets}
rescue
e -> {:error, e}
end
defp routes_to_entries(table, routes) do
Enum.reduce_while(routes, {:ok, []}, fn {listener, target, host_names, algorithm},
{:ok, entries} ->
case route_to_entries(table, listener, target, host_names, algorithm) do
{:ok, new_entries} -> {:cont, {:ok, Enum.concat(entries, new_entries)}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp route_to_entries(table, listener, target, host_names, algorithm)
when Selector.is_algorithm(algorithm) do
with {:ok, listener} <- sanitise_listener(listener),
{:ok, target} <- sanitise_target(target),
{:ok, patterns} <- host_names_to_pattern(host_names),
health_state <- current_health_state(table, target) do
entries = Enum.map(patterns, &{listener, &1, target, algorithm, health_state})
{:ok, entries}
end
end
defp route_to_entries(_table, _listener, _target, _host_names, algorithm),
do:
{:error,
ArgumentError.exception(
message: "Value `#{inspect(algorithm)}` is not a valid load balancing algorithm."
)}
defp current_health_state(table, {scheme, address, port, transport}) do
# Generated using
# :ets.fun2ms(fn {_, _, :target, :_, health} -> health end)
match_spec = [
{{:_, :_, {scheme, address, port, transport}, :_, :"$1"}, [], [:"$1"]}
]
case :ets.select(table, match_spec, 1) do
{[health], _} -> health
_ -> :initial
end
end
defp host_names_to_pattern(host_names) do
Enum.reduce_while(host_names, {:ok, []}, fn host_name, {:ok, patterns} ->
case host_name_to_pattern(host_name) do
{:ok, pattern} -> {:cont, {:ok, [pattern | patterns]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp host_name_to_pattern(host_name) when is_binary(host_name) do
pattern =
host_name
|> String.trim_trailing(".")
|> String.split(".")
|> Enum.reduce({}, fn
"*", {} -> {:_}
segment, pattern -> Tuple.append(pattern, segment)
end)
{:ok, pattern}
end
defp host_name_to_pattern(host_name) do
{:error,
ArgumentError.exception(
message: "Value `#{inspect(host_name)}` is not a valid host expression."
)}
end
defp sanitise_listener({scheme, address, port}) do
with {:ok, scheme} <- sanitise_scheme(scheme),
{:ok, address} <- sanitise_ip_address(address),
{:ok, port} <- sanitise_port(port) do
{:ok, {scheme, address, port}}
end
end
defp sanitise_listener(listener),
do: {:error, ArgumentError.exception(message: "Not a valid listener: `#{inspect(listener)}")}
defp sanitise_transport(transport) when transport in [:http1, :http2, :auto],
do: {:ok, transport}
defp sanitise_transport(transport),
do:
{:error,
ArgumentError.exception(message: "Not a valid target transport: `#{inspect(transport)}`")}
defp sanitise_target({:plug, module}), do: sanitise_target({:plug, module, []})
defp sanitise_target({:plug, module, _}) do
if Spark.implements_behaviour?(module, Plug) do
{:ok, {:plug, module}}
else
{:error,
ArgumentError.exception(
message: "Module `#{inspect(module)}` does not implement the `Plug` behaviour."
)}
end
end
defp sanitise_target({scheme, address, port, transport}) do
with {:ok, scheme} <- sanitise_scheme(scheme),
{:ok, address} <- sanitise_ip_address(address),
{:ok, port} <- sanitise_port(port),
{:ok, transport} <- sanitise_transport(transport) do
{:ok, {scheme, address, port, transport}}
end
end
defp sanitise_target(target),
do: {:error, ArgumentError.exception(message: "Not a valid target: `#{inspect(target)}")}
end

228
lib/wayfarer/server.ex Normal file
View file

@ -0,0 +1,228 @@
defmodule Wayfarer.Server do
alias Spark.Options
alias Wayfarer.{Dsl, Listener, Router, Server, Target}
use GenServer
require Logger
@callback child_spec(keyword()) :: Supervisor.child_spec()
@callback start_link(keyword()) :: GenServer.on_start()
@scheme_type {:in, [:http, :https, :ws, :wss]}
@port_type {:in, 0..0xFFFF}
@transport_type {:in, [:http1, :http2, :auto]}
@ip_type {:or,
[
{:tuple, [{:in, 0..0xFF}, {:in, 0..0xFF}, {:in, 0..0xFF}, {:in, 0..0xFF}]},
{:tuple,
[
{:in, 0..0xFFFF},
{:in, 0..0xFFFF},
{:in, 0..0xFFFF},
{:in, 0..0xFFFF},
{:in, 0..0xFFFF},
{:in, 0..0xFFFF},
{:in, 0..0xFFFF},
{:in, 0..0xFFFF}
]},
{:struct, IP.Address}
]}
@options_schema [
module: [
type: {:behaviour, __MODULE__},
required: true,
doc: "The name of the module which \"uses\" Wayfarer.Server."
],
targets: [
type: {:list, {:keyword_list, Dsl.Target.schema()}},
required: false,
default: [],
doc: "A list of target specifications."
],
listeners: [
type: {:list, {:keyword_list, Dsl.Listener.schema()}},
required: false,
default: [],
doc: "A list of listener specifications."
],
routing_table: [
type:
{:list,
{:tuple,
[
{:tuple, [@scheme_type, @ip_type, @port_type]},
{:tuple, [@scheme_type, @ip_type, @port_type, @transport_type]},
{:list, :string},
{:in, [:round_robin, :sticky, :random, :least_connections]}
]}},
required: false,
default: [],
doc: "A list of routes to add when the server starts."
]
]
@moduledoc """
A GenServer which manages a proxy.
An appropriate `child_spec/1` and `start_link/1` are generated when `use
Wayfarer.Server` is called.
You can use this module directly if you are not planning on using the configuration DSL at all.
## Example
```elixir
defmodule MyProxy do
use Wayfarer.Server, targets: [..], listeners: [..], routing_table: [..]
end
```
## Options
#{Options.docs(@options_schema)}
"""
@type options :: keyword
@doc false
@spec __using__(any) :: Macro.output()
defmacro __using__(opts) do
quote do
@behaviour Server
@doc false
@spec child_spec(keyword) :: Supervisor.child_spec()
def child_spec(opts) do
opts =
unquote(opts)
|> Keyword.merge(opts)
|> Keyword.put(:module, __MODULE__)
Server.child_spec(opts)
end
@doc false
@spec start_link(keyword) :: GenServer.on_start()
def start_link(opts) do
opts =
unquote(opts)
|> Keyword.merge(opts)
|> Keyword.put(:module, __MODULE__)
Server.start_link(opts)
end
defoverridable child_spec: 1, start_link: 1
end
end
@doc false
@spec target_status_change(
{module, :http | :https, IP.Address.t(), :socket.port_number(),
:http1 | :http2 | :auto},
Router.health()
) :: :ok
def target_status_change({module, scheme, address, port, transport}, status) do
GenServer.cast(
{:via, Registry, {Wayfarer.Server.Registry, module}},
{:target_status_change, scheme, address, port, transport, status}
)
end
@doc false
@spec start_link(options) :: GenServer.on_start()
def start_link(opts) do
case Keyword.fetch(opts, :module) do
{:ok, module} ->
GenServer.start_link(__MODULE__, opts,
name: {:via, Registry, {Wayfarer.Server.Registry, module}}
)
:error ->
{:error, "Missing required `module` option."}
end
end
@doc false
@impl true
@spec init(options) :: {:ok, map} | {:stop, any}
def init(options) do
with {:ok, options} <- Options.validate(options, @options_schema),
{:ok, module} <- assert_is_server(options[:module]),
listeners <- Keyword.get(options, :listeners, []),
targets <- Keyword.get(options, :targets, []),
initial_routing_table <- Keyword.get(options, :routing_table, []),
{:ok, routing_table} <- Router.init(module),
:ok <- Router.import_routes(routing_table, initial_routing_table),
state <- %{module: module, routing_table: routing_table},
{:ok, state} <- start_listeners(listeners, state),
{:ok, state} <- start_targets(targets, state) do
{:ok, state}
else
:error -> raise "unreachable"
{:error, reason} -> {:stop, reason}
end
end
@doc false
@impl true
@spec handle_cast(any, map) :: {:noreply, map}
def handle_cast({:target_status_change, scheme, address, port, transport, status}, state) do
Router.update_target_health_status(
state.routing_table,
{scheme, IP.Address.to_tuple(address), port, transport},
status
)
{:noreply, state}
end
defp start_listeners(listeners, state) do
listeners
|> Enum.reduce_while({:ok, state}, fn listener, success ->
listener = Keyword.put(listener, :module, state.module)
case DynamicSupervisor.start_child(Listener.DynamicSupervisor, {Listener, listener}) do
{:ok, pid} ->
Process.link(pid)
{:cont, success}
{:error, {:already_started, pid}} ->
Process.link(pid)
{:cont, success}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
end
defp start_targets(targets, state) do
targets
|> Enum.reduce_while({:ok, state}, fn target, success ->
target = Keyword.put(target, :module, state.module)
case DynamicSupervisor.start_child(Target.DynamicSupervisor, {Target, target}) do
{:ok, pid} ->
Process.link(pid)
{:cont, success}
{:error, {:already_started, pid}} ->
Process.link(pid)
{:cont, success}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
end
defp assert_is_server(module) do
if Spark.implements_behaviour?(module, __MODULE__) do
{:ok, module}
else
{:error,
"The module `#{inspect(module)}` does not implement the `Wayfarer.Server` behaviour."}
end
end
end

110
lib/wayfarer/server/plug.ex Normal file
View file

@ -0,0 +1,110 @@
defmodule Wayfarer.Server.Plug do
@moduledoc """
Plug pipeline to handle inbound HTTP connections.
"""
alias Wayfarer.{Router, Server.Proxy, Target.Selector, Telemetry}
require Logger
import Plug.Conn
@behaviour Plug
@doc false
@impl true
@spec init(Enumerable.t()) :: map
def init(config), do: Map.new(config)
@doc false
@impl true
@spec call(Plug.Conn.t(), map) :: Plug.Conn.t()
def call(conn, config) do
transport = get_transport(conn)
conn
|> put_private(:wayfarer, %{listener: config, transport: transport})
|> Telemetry.request_start()
|> do_call(config)
end
defp do_call(conn, config) when is_atom(config.module) do
listener = {config.scheme, config.address, config.port}
with {:ok, targets} <- Router.find_healthy_targets(config.module, listener, conn.host),
{:ok, targets, algorithm} <- split_targets_and_algorithms(targets),
{:ok, target} <- Selector.choose(conn, targets, algorithm) do
conn
|> Telemetry.request_routed(target, algorithm)
|> do_proxy(target)
else
:error ->
conn
|> Telemetry.request_exception(:error, :target_not_found)
|> bad_gateway()
{:error, reason} ->
conn
|> Telemetry.request_exception(:error, reason)
|> internal_error(reason)
end
rescue
exception ->
conn
|> Telemetry.request_exception(:exception, exception, __STACKTRACE__)
|> internal_error(exception)
catch
reason ->
conn
|> Telemetry.request_exception(:throw, reason)
|> internal_error(reason)
kind, reason ->
conn
|> Telemetry.request_exception(kind, reason)
|> internal_error(reason)
end
defp do_call(conn, _config) do
conn
|> Telemetry.request_exception(:error, :unrecognised_request)
|> bad_gateway()
end
defp internal_error(conn, reason) do
Logger.error("Internal error when routing proxy request: #{inspect(reason)}")
conn
|> put_resp_content_type("text/plain")
|> send_resp(500, "Internal Error")
end
defp bad_gateway(conn) do
conn
|> put_resp_content_type("text/plain")
|> send_resp(502, "Bad Gateway")
end
defp do_proxy(conn, {:plug, {module, opts}}), do: module.call(conn, module.init(opts))
defp do_proxy(conn, {:plug, module}), do: module.call(conn, module.init([]))
defp do_proxy(conn, target), do: Proxy.request(conn, target)
# This is lazy, but we assume that the algorithm is consistent across all
# matching targets.
defp split_targets_and_algorithms([]), do: :error
defp split_targets_and_algorithms([{target, algorithm}]), do: {:ok, [target], algorithm}
defp split_targets_and_algorithms([{target, algorithm} | tail]),
do: split_targets_and_algorithms(tail, [target], algorithm)
defp split_targets_and_algorithms([{target, algorithm} | tail], targets, algorithm),
do: split_targets_and_algorithms(tail, [target | targets], algorithm)
defp split_targets_and_algorithms([], targets, algorithm), do: {:ok, targets, algorithm}
defp get_transport(%{adapter: {Bandit.Adapter, %{transport: %Bandit.HTTP1.Socket{}}}}),
do: :http1
defp get_transport(%{adapter: {Bandit.Adapter, %{transport: %Bandit.HTTP2.Stream{}}}}),
do: :http2
defp get_transport(_), do: :unknown
end

View file

@ -0,0 +1,346 @@
defmodule Wayfarer.Server.Proxy do
@moduledoc """
Uses Mint to convert a Plug connection into an outgoing HTTP request to a
specific target.
"""
alias Mint.{HTTP, HTTP1, HTTP2}
alias Plug.Conn
alias Wayfarer.{Router, Target.ActiveConnections, Target.TotalConnections, Telemetry}
require Logger
@connect_timeout 5_000
@idle_timeout 5_000
@doc """
Convert the request conn into an HTTP request to the specified target.
"""
@spec request(Conn.t(), Router.target()) :: Conn.t()
def request(conn, target) do
with {:ok, mint} <- connect(conn, target),
:ok <- ActiveConnections.connect(target),
:ok <- TotalConnections.proxy_connect(target) do
handle_request(mint, conn, target)
else
error ->
conn
|> Telemetry.request_exception(:error, error)
|> handle_error(error, target)
end
end
defp handle_request(mint, conn, {proto, _, _, _}) do
if http1?(conn) && connection_wants_upgrade?(conn) && upgrade_is_websocket?(conn) do
handle_websocket_request(mint, conn, proto)
else
handle_http_request(mint, conn)
end
end
defp handle_http_request(mint, conn) do
with {:ok, mint, req} <- send_request(conn, mint),
{:ok, mint, conn} <- stream_request_body(conn, mint, req),
{:ok, conn, _mint} <- proxy_responses(conn, mint, req) do
conn
|> Telemetry.request_stop()
end
end
defp handle_websocket_request(mint, conn, proto) do
conn
|> WebSockAdapter.upgrade(Wayfarer.Server.WebSocketProxy, {mint, conn, proto}, compress: true)
|> Telemetry.request_upgraded()
end
defp connect(conn, {:ws, address, port, transport}),
do: connect(conn, {:http, address, port, transport})
defp connect(conn, {:wss, address, port, transport}),
do: connect(conn, {:https, address, port, transport})
defp connect(conn, {scheme, address, port, :http1}) when is_tuple(address),
do: HTTP1.connect(scheme, address, port, hostname: conn.host, timeout: @connect_timeout)
defp connect(conn, {scheme, address, port, :http2}) when is_tuple(address),
do: HTTP2.connect(scheme, address, port, hostname: conn.host, timeout: @connect_timeout)
defp connect(conn, {scheme, address, port, :auto}) when is_tuple(address),
do: HTTP.connect(scheme, address, port, hostname: conn.host, timeout: @connect_timeout)
defp handle_error(conn, {:error, _, reason}, target),
do: handle_error(conn, {:error, reason}, target)
defp handle_error(conn, {:error, reason}, {scheme, address, port, transport}) do
Logger.error(fn ->
phase = connection_phase(conn)
ip = :inet.ntoa(address)
"Proxy error [phase=#{phase},ip=#{ip},port=#{port},proto=#{scheme},trans=#{transport}]: #{message(reason)}"
end)
if conn.halted || conn.state in [:sent, :chunked, :upgraded] do
# Sadly there's not much more we can do here.
Conn.halt(conn)
else
{status, body} = status_and_response(reason)
conn
|> Conn.put_resp_content_type("text/plain")
|> Conn.send_resp(status, body)
end
end
defp http1?(%{private: %{wayfarer: %{transport: :http1}}}), do: true
defp http1?(_), do: false
defp connection_wants_upgrade?(conn) do
case Conn.get_req_header(conn, "connection") do
["Upgrade"] ->
true
["upgrade"] ->
true
[] ->
false
maybe ->
maybe
|> Enum.flat_map(&String.split(&1, ~r/,\s*/))
|> Enum.map(fn chunk ->
chunk
|> String.trim()
|> String.downcase()
end)
|> Enum.member?("upgrade")
end
end
defp upgrade_is_websocket?(conn) do
case Conn.get_req_header(conn, "upgrade") do
["websocket"] ->
true
[] ->
false
maybe ->
maybe
|> Enum.flat_map(&String.split(&1, ~r/,\s*/))
|> Enum.map(fn chunk ->
chunk
|> String.trim()
|> String.split("/")
|> hd()
|> String.downcase()
end)
|> Enum.member?("websocket")
end
end
defp connection_phase(conn) when conn.state in [:chunked, :upgraded], do: "stream"
defp connection_phase(conn) when conn.halted, do: "done"
defp connection_phase(conn) when conn.state == :sent, do: "done"
defp connection_phase(_conn), do: "init"
defp message(error) when is_exception(error), do: Exception.message(error)
defp message(error) when is_binary(error), do: error
defp message(error), do: inspect(error)
defp status_and_response(:idle_timeout), do: {504, "Gateway Timeout"}
defp status_and_response(error) when error.reason == :timeout, do: {504, "Gateway Timeout"}
defp status_and_response(error) when is_struct(error, Mint.TransportError),
do: {502, "Bad Gateway"}
defp status_and_response(_error), do: {500, "Internal Error"}
defp proxy_responses(conn, mint, req) do
receive do
message ->
case HTTP.stream(mint, message) do
:unknown ->
proxy_responses(conn, mint, req)
{:ok, mint, responses} ->
handle_responses(conn, responses, mint, req)
{:error, _, reason, _} ->
{:error, reason}
end
after
@idle_timeout -> {:error, :idle_timeout}
end
end
defp handle_responses(conn, [], mint, req), do: proxy_responses(conn, mint, req)
defp handle_responses(conn, [{:status, req, status} | responses], mint, req) do
conn
|> Conn.put_status(status)
|> Telemetry.request_received_status(status)
|> handle_responses(responses, mint, req)
end
defp handle_responses(conn, [{:headers, req, headers} | responses], mint, req) do
headers
|> Enum.reduce(conn, fn {header_name, header_value}, conn ->
conn
|> Conn.put_resp_header(header_name, header_value)
end)
|> handle_responses(responses, mint, req)
end
defp handle_responses(conn, [{:data, req, body} | responses], mint, req)
when conn.state == :chunked do
case Conn.chunk(conn, body) do
{:ok, conn} ->
body_size = byte_size(body)
conn
|> Telemetry.increment_metrics(%{resp_body_bytes: body_size})
|> Telemetry.request_resp_body_chunk(body_size)
|> handle_responses(responses, mint, req)
{:error, reason} ->
{:error, conn, reason}
end
end
defp handle_responses(conn, [{:data, req, body} | responses], mint, req) do
# We need to check here for a content-length or transfer encoding header and
# deal with it. This should be refactored out into a proxy state rather
# than using the conn as our state.
body_size = byte_size(body)
case Conn.get_resp_header(conn, "content-length") do
[] ->
conn
|> Conn.send_chunked(conn.status)
|> Telemetry.request_resp_started()
|> handle_responses([{:data, req, body} | responses], mint, req)
[length] ->
if String.to_integer(length) == body_size do
conn =
conn
|> Conn.send_resp(conn.status, body)
|> Telemetry.request_resp_started()
|> Telemetry.increment_metrics(%{resp_body_bytes: body_size})
|> Telemetry.request_resp_body_chunk(body_size)
|> Conn.halt()
{:ok, conn, mint}
else
conn =
conn
|> Telemetry.increment_metrics(%{resp_body_bytes: body_size})
|> Telemetry.request_resp_body_chunk(body_size)
|> Conn.send_chunked(conn.status)
handle_responses(conn, [{:data, req, body} | responses], mint, req)
end
end
end
# If the connection is done without sending any body content, then we need to
# send the respond and halt the conn.
defp handle_responses(conn, [{:done, req} | _], mint, req) when conn.state == :unset do
conn =
conn
|> Conn.send_resp(conn.status, "")
|> Conn.halt()
{:ok, conn, mint}
end
defp handle_responses(conn, [{:done, req} | _], mint, req) when conn.state == :chunked,
do: {:ok, Conn.halt(conn), mint}
defp handle_responses(conn, [{:error, req, reason} | _], _mint, req), do: {:error, conn, reason}
defp send_request(conn, mint) do
request_path =
case {conn.request_path, conn.query_string} do
{path, nil} -> path
{path, ""} -> path
{path, query} -> path <> "?" <> query
end
HTTP.request(
mint,
conn.method,
request_path,
proxy_headers(conn),
:stream
)
end
defp stream_request_body(conn, mint, req) do
case Conn.read_body(conn) do
{:ok, <<>>, conn} ->
with {:ok, mint} <- HTTP.stream_request_body(mint, req, :eof) do
conn =
conn
|> Telemetry.set_metrics(%{req_body_bytes: 0, req_body_chunks: 0})
{:ok, mint, conn}
end
{:ok, chunk, conn} ->
with {:ok, mint} <- HTTP.stream_request_body(mint, req, chunk),
{:ok, mint} <- HTTP.stream_request_body(mint, req, :eof) do
chunk_size = byte_size(chunk)
conn =
conn
|> Telemetry.increment_metrics(%{req_body_bytes: chunk_size, req_body_chunks: 1})
|> Telemetry.request_req_body_chunk(chunk_size)
{:ok, mint, conn}
end
{:more, chunk, conn} ->
with {:ok, mint} <- HTTP.stream_request_body(mint, req, chunk) do
chunk_size = byte_size(chunk)
conn
|> Telemetry.increment_metrics(%{req_body_bytes: chunk_size, req_body_chunks: 1})
|> Telemetry.request_req_body_chunk(chunk_size)
|> stream_request_body(mint, req)
end
{:error, reason} ->
{:error, conn, reason}
end
end
defp proxy_headers(conn) do
listener =
conn.private.wayfarer.listener
|> case do
%{address: address, port: port} when tuple_size(address) == 8 ->
"[#{:inet.ntoa(address)}]:#{port}"
%{address: address, port: port} ->
"#{:inet.ntoa(address)}:#{port}"
end
client =
conn
|> Conn.get_peer_data()
|> case do
%{address: address, port: port} when tuple_size(address) == 8 ->
"[#{:inet.ntoa(address)}]:#{port}"
%{address: address, port: port} ->
"#{:inet.ntoa(address)}:#{port}"
end
[
{"forwarded", "by=#{listener};for=#{client};host=#{conn.host};proto=#{conn.scheme}"}
| conn.req_headers
]
end
end

View file

@ -0,0 +1,15 @@
defmodule Wayfarer.Server.Supervisor do
@moduledoc false
use Supervisor
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
@impl true
def init(_) do
[
{Registry, keys: :unique, name: Wayfarer.Server.Registry}
]
|> Supervisor.init(strategy: :one_for_one)
end
end

View file

@ -0,0 +1,256 @@
defmodule Wayfarer.Server.WebSocketProxy do
@moduledoc """
When a connection is upgraded to a websocket, we switch from handing via
`Plug` to `WebSock` via `WebSockAdapter`.
The outgoing connection is made using `Mint.WebSocket`.
"""
@behaviour WebSock
alias Mint.WebSocket
alias Plug.Conn
alias Wayfarer.Telemetry
require Logger
@default_opts [extensions: [WebSocket.PerMessageDeflate]]
@doc false
@impl true
def init({mint, conn, proto}) when proto in [:ws, :wss] do
request_path =
case {conn.request_path, conn.query_string} do
{path, nil} -> path
{path, ""} -> path
{path, query} -> path <> "?" <> query
end
case WebSocket.upgrade(proto, mint, request_path, proxy_headers(conn), @default_opts) do
{:ok, mint, ref} -> {:ok, %{mint: mint, ref: ref, status: :init, buffer: [], conn: conn}}
{:error, _mint, reason} -> {:error, reason}
end
end
def init({mint, conn, :https}), do: init({mint, conn, :wss})
def init({mint, conn, :http}), do: init({mint, conn, :ws})
@doc false
@impl true
def handle_control({frame, [{:opcode, :ping}]}, state) do
with {:ok, websocket, data} <- WebSocket.encode(state.websocket, {:ping, frame}),
{:ok, mint} <- WebSocket.stream_request_body(state.mint, state.ref, data) do
conn = request_client_frame(state.conn, {:ping, frame})
{:ok, %{state | websocket: websocket, mint: mint, conn: conn}}
else
error -> handle_error(error, state)
end
end
def handle_control({frame, [{:opcode, frame_type}]}, state) do
conn = request_client_frame(state.conn, {frame_type, frame})
{:ok, %{state | conn: conn}}
end
@doc false
@impl true
def handle_in({payload, [{:opcode, frame_type}]}, state) when state.status == :init do
frame = {frame_type, payload}
buffer = [frame | state.buffer]
conn = request_client_frame(state.conn, frame)
{:ok, %{state | buffer: buffer, conn: conn}}
end
def handle_in({payload, [{:opcode, frame_type}]}, state) do
with {:ok, websocket, data} <- WebSocket.encode(state.websocket, {frame_type, payload}),
{:ok, mint} <- WebSocket.stream_request_body(state.mint, state.ref, data) do
conn = request_client_frame(state.conn, {frame_type, payload})
{:ok, %{state | websocket: websocket, mint: mint, conn: conn}}
else
error -> handle_error(error, state)
end
end
@doc false
@impl true
def handle_info(msg, state) when state.status == :init do
with {:ok, mint, result} <- WebSocket.stream(state.mint, msg),
{:ok, result} <- handle_upgrade_response(result, state.ref),
{:ok, mint, websocket} <- WebSocket.new(mint, state.ref, result.status, result.headers),
state <- Map.merge(state, %{status: :connected, websocket: websocket, mint: mint}),
{:ok, state} <- empty_buffer(state),
{:ok, messages, state} <- decode_frames(result.data, state) do
response_for_messages(messages, state)
else
error -> handle_error(error, state)
end
end
def handle_info(msg, state) when state.status == :connected do
with {:ok, mint, result} <- WebSocket.stream(state.mint, msg),
{:ok, frames} <- handle_websocket_data(result, state.ref),
{:ok, messages, state} <- decode_frames(frames, %{state | mint: mint}) do
response_for_messages(messages, state)
else
error -> handle_error(error, state)
end
end
@doc false
@impl true
def terminate(_reason, _state), do: :ok
defp handle_error({:error, _, %{reason: reason}, _}, state),
do: handle_error({:error, reason}, state)
defp handle_error({:error, reason, state}, _state),
do: handle_error({:error, reason}, state)
defp handle_error({:error, reason}, state) do
Logger.debug(fn ->
"Dropping WebSocket connection for reason: #{inspect(reason)}"
end)
{:stop, :normal, state}
end
defp proxy_headers(conn) do
listener =
conn.private.wayfarer.listener
|> case do
%{address: address, port: port} when tuple_size(address) == 8 ->
"[#{:inet.ntoa(address)}]:#{port}"
%{address: address, port: port} ->
"#{:inet.ntoa(address)}:#{port}"
end
client =
conn
|> Conn.get_peer_data()
|> case do
%{address: address, port: port} when tuple_size(address) == 8 ->
"[#{:inet.ntoa(address)}]:#{port}"
%{address: address, port: port} ->
"#{:inet.ntoa(address)}:#{port}"
end
req_headers =
conn.req_headers
|> Enum.reject(
&(elem(&1, 0) in [
"connection",
"upgrade",
"sec-websocket-extensions",
"sec-websocket-key",
"sec-websocket-version"
])
)
[
{"forwarded", "by=#{listener};for=#{client};host=#{conn.host};proto=#{conn.scheme}"}
| req_headers
]
end
defp empty_buffer(state) when state.buffer == [], do: {:ok, state}
defp empty_buffer(state), do: do_empty_buffer(Enum.reverse(state.buffer), %{state | buffer: []})
defp do_empty_buffer([], state), do: {:ok, state}
defp do_empty_buffer([head | tail], state) do
with {:ok, websocket, data} <- WebSocket.encode(state.websocket, head),
{:ok, mint} <- WebSocket.stream_request_body(state.mint, state.ref, data) do
conn = request_client_frame(state.conn, head)
do_empty_buffer(tail, %{state | websocket: websocket, mint: mint, conn: conn})
end
end
defp handle_upgrade_response(result, ref), do: handle_upgrade_response(result, ref, %{data: []})
defp handle_upgrade_response([{:done, ref}], ref, result), do: {:ok, result}
defp handle_upgrade_response([{:status, ref, status} | tail], ref, result) do
handle_upgrade_response(tail, ref, Map.put(result, :status, status))
end
defp handle_upgrade_response([{:headers, ref, headers} | tail], ref, result) do
handle_upgrade_response(tail, ref, Map.put(result, :headers, headers))
end
defp handle_upgrade_response([{:data, ref, data} | tail], ref, result) do
result = Map.update!(result, :data, &[data | &1])
handle_upgrade_response(tail, ref, result)
end
defp handle_websocket_data(data, ref),
do: handle_websocket_data(data, ref, [])
defp handle_websocket_data([], _ref, messages), do: {:ok, Enum.reverse(messages)}
defp handle_websocket_data([{:data, ref, data} | tail], ref, messages),
do: handle_websocket_data(tail, ref, [data | messages])
defp decode_frames(frames, state) do
frames
|> Enum.reduce_while({:ok, [], state}, fn frame, {:ok, messages, state} ->
case decode_frame(state, frame) do
{:ok, new_messages, state} -> {:cont, {:ok, [new_messages, messages], state}}
{:error, reason, state} -> {:halt, {:error, reason, state}}
end
end)
|> case do
{:ok, messages, state} -> {:ok, List.flatten(messages), state}
{:error, reason, state} -> {:error, reason, state}
end
end
defp decode_frame(state, frame) do
case WebSocket.decode(state.websocket, frame) do
{:ok, websocket, frames} when is_list(frames) ->
conn = Enum.reduce(frames, state.conn, &request_server_frame(&2, &1))
{:ok, frames, %{state | websocket: websocket, conn: conn}}
{:error, websocket, reason} ->
{:error, reason, %{state | websocket: websocket}}
end
end
# Handle all the frames coming from the target and decide how to respond to
# Bandit/WebSock. In the case of encountering a close frame, we terminate the
# client websocket with the same code, otherwise we just copy the frames over.
defp response_for_messages(messages, state, response \\ [])
defp response_for_messages([], state, []), do: {:ok, state}
defp response_for_messages([], state, response), do: {:push, Enum.reverse(response), state}
defp response_for_messages([{:close, code, _} | _], state, response),
do: {:stop, :normal, code, Enum.reverse(response), state}
defp response_for_messages([message | messages], state, response),
do: response_for_messages(messages, state, [message | response])
defp request_client_frame(conn, {frame_type, frame}) do
frame_size = byte_size(frame)
conn
|> Telemetry.increment_metrics(%{
client_frame_bytes: frame_size,
client_frame_count: 1
})
|> Telemetry.request_client_frame(frame_size, frame_type)
end
defp request_server_frame(conn, {frame_type, frame}) do
frame_size = byte_size(frame)
conn
|> Telemetry.increment_metrics(%{
server_frame_bytes: frame_size,
server_frame_count: 1
})
|> Telemetry.request_server_frame(frame_size, frame_type)
end
end

262
lib/wayfarer/target.ex Normal file
View file

@ -0,0 +1,262 @@
defmodule Wayfarer.Target do
# @moduledoc ⬇️⬇️
use GenServer, restart: :transient
require Logger
alias Spark.Options
alias Wayfarer.{Dsl.HealthCheck, Router, Server, Target}
import Wayfarer.Utils
@options_schema [
scheme: [
type: {:in, [:http, :https, :ws, :wss]},
doc: "The connection scheme.",
required: true
],
port: [
type: {:in, 1..0xFFFF},
doc: "The TCP port to connect to.",
required: true
],
address: [
type: {:struct, IP.Address},
doc: "The IP address to connect to.",
required: true
],
module: [
type: {:behaviour, Wayfarer.Server},
doc: "The proxy module this target is linked to.",
required: true
],
name: [
type: {:or, [nil, :string]},
doc: "An optional name for the target.",
required: false
],
transport: [
type: {:in, [:http1, :http2, :auto]},
required: false,
default: :auto,
doc: "The connection protocol."
],
health_checks: [
type: {:list, {:keyword_list, HealthCheck.schema()}},
required: false,
default: HealthCheck.default() |> HealthCheck.to_options() |> then(&[&1])
]
]
@wayfarer_vsn Application.spec(:wayfarer, :vsn) || Mix.Project.config()[:version]
@elixir_vsn Application.spec(:elixir, :vsn)
@erlang_vsn :erlang.system_info(:otp_release)
@mint_vsn Application.spec(:mint, :vsn)
@default_headers [
{"User-Agent",
"Wayfarer/#{@wayfarer_vsn} (Elixir #{@elixir_vsn}; Erlang #{@erlang_vsn}) Mint/#{@mint_vsn}"},
{"Connection", "close"},
{"Accept", "*/*"}
]
@moduledoc """
A GenServer responsible for performing health-checks against HTTP and HTTPS
targets.
You should not need to create one of these yourself, instead use it via
`Wayfarer.Server`.
## Options
#{Options.docs(@options_schema)}
"""
@type key :: {module, :http | :https, IP.Address.t(), :socket.port_number()}
@doc false
@spec check_failed({key, reference}) :: :ok
def check_failed({key, id}),
do: GenServer.cast({:via, Registry, {Wayfarer.Target.Registry, key}}, {:check_failed, id})
@doc false
@spec check_passed({key, reference}) :: :ok
def check_passed({key, id}),
do: GenServer.cast({:via, Registry, {Wayfarer.Target.Registry, key}}, {:check_passed, id})
@doc "Return the current health status of the target"
@spec current_status(pid | key) :: {:ok, Router.health()} | {:error, any}
def current_status(pid) when is_pid(pid), do: GenServer.call(pid, :current_status)
def current_status(key),
do: GenServer.call({:via, Registry, {Wayfarer.Target.Registry, key}}, :current_status)
@doc false
@spec start_link(keyword) :: GenServer.on_start()
def start_link(options), do: GenServer.start_link(__MODULE__, options)
@doc false
@impl true
def init(options) do
with {:ok, options} <- Options.validate(options, @options_schema),
{:ok, uri} <- to_uri(options[:scheme], options[:address], options[:port]) do
target = options |> Keyword.take(~w[scheme address port transport]a) |> Map.new()
module = options[:module]
key = {module, target.scheme, target.address, target.port}
checks =
options
|> Keyword.get(:health_checks, [])
|> Map.new(fn check ->
id = make_ref()
check =
check
|> Map.new()
|> Map.merge(%{
status: :initial,
scheme: target.scheme,
address: IP.Address.to_tuple(target.address),
port: target.port,
uri: %{uri | path: check[:path]},
id: id,
ref: {key, id},
method: check[:method] |> to_string() |> String.upcase(),
headers: @default_headers,
hostname: check[:hostname] || uri.host,
transport: target.transport,
passes: 0
})
{id, check}
end)
Logger.info("Starting Wayfarer target #{uri}.")
state = %{
target: target,
checks: checks,
uri: uri,
module: module,
name: options[:name],
status: :initial
}
Registry.register(Wayfarer.Target.Registry, key, uri)
{:ok, state, {:continue, :perform_health_checks}}
end
end
@doc false
@impl true
def handle_continue(:perform_health_checks, state) do
perform_health_checks(state)
{:noreply, state}
end
@doc false
@impl true
def handle_cast({:check_failed, id}, state)
when is_map_key(state.checks, id) and state.status == :unhealthy do
checks =
Map.update!(state.checks, id, fn check ->
check
|> queue_check()
|> then(&%{&1 | status: :unhealthy, passes: 0})
end)
{:noreply, %{state | checks: checks}}
end
def handle_cast({:check_failed, id}, state) when is_map_key(state.checks, id) do
checks =
Map.update!(state.checks, id, fn check ->
check
|> queue_check()
|> then(&%{&1 | status: :unhealthy, passes: 0})
end)
Server.target_status_change(
{state.module, state.target.scheme, state.target.address, state.target.port,
state.target.transport},
:unhealthy
)
{:noreply, %{state | checks: checks, status: :unhealthy}}
end
def handle_cast({:check_passed, id}, state) when state.status == :healthy do
check =
state.checks
|> Map.fetch!(id)
|> queue_check()
|> increment_success()
checks = Map.put(state.checks, id, check)
{:noreply, %{state | checks: checks}}
end
def handle_cast({:check_passed, id}, state) when is_map_key(state.checks, id) do
check =
state.checks
|> Map.fetch!(id)
|> queue_check()
|> increment_success()
checks = Map.put(state.checks, id, check)
target_became_healthy? =
checks
|> Map.values()
|> Enum.all?(&(&1.status == :healthy))
if target_became_healthy? do
Server.target_status_change(
{state.module, state.target.scheme, state.target.address, state.target.port,
state.target.transport},
:healthy
)
Logger.info("Target #{state.uri} became healthy")
{:noreply, %{state | checks: checks, status: :healthy}}
else
{:noreply, %{state | checks: checks}}
end
end
def handle_cast(_message, state), do: {:noreply, state}
@doc false
@impl true
def handle_info({:perform_check, id}, state) do
check = Map.fetch!(state.checks, id)
{:ok, _pid} = GenServer.start(Target.Check, check)
{:noreply, state}
end
@doc false
@impl true
def handle_call(:current_status, _from, state) do
{:reply, {:ok, state.status}, state}
end
defp perform_health_checks(state) do
for {_ref, check} <- state.checks do
{:ok, _pid} = GenServer.start(Target.Check, check)
end
end
defp queue_check(check) do
Process.send_after(self(), {:perform_check, check.id}, check.interval)
check
end
defp increment_success(check), do: increment_success(check, check.passes + 1)
defp increment_success(check, passes) when passes >= check.threshold,
do: %{check | passes: passes, status: :healthy}
defp increment_success(check, passes), do: %{check | passes: passes}
end

View file

@ -0,0 +1,126 @@
defmodule Wayfarer.Target.ActiveConnections do
@moduledoc """
A simple ETS table that tracks active connections to a given target.
"""
use GenServer
require Logger
alias Wayfarer.{Router, Utils}
@type state :: %{table: :ets.tid(), timer: :timer.tref()}
@doc false
@spec start_link(any) :: GenServer.on_start()
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
@doc false
@impl true
@spec init(any) :: {:ok, state} | {:stop, any}
def init(_) do
report_interval()
|> :timer.send_interval(:tick)
|> case do
{:ok, timer} ->
table =
__MODULE__
|> :ets.new([:public, :named_table, :duplicate_bag])
{:ok, %{table: table, timer: timer}}
{:error, reason} ->
{:stop, reason}
end
end
@doc false
@impl true
@spec handle_info(:tick | {:DOWN, any, :process, any, pid, any}, state) :: {:noreply, state}
def handle_info(:tick, state) do
# size = :ets.info(state.table, :size)
# Logger.debug("Active connections: #{size}")
{:noreply, state}
end
def handle_info({:DOWN, _, :process, pid, _}, state) do
:ets.match_delete(__MODULE__, {:_, pid, :_})
{:noreply, state}
end
@doc false
@impl true
@spec handle_cast({:monitor, pid}, state) :: {:noreply, state}
def handle_cast({:monitor, pid}, state) do
Process.monitor(pid)
{:noreply, state}
end
@doc """
Track a new active connection.
"""
@spec connect(Router.target()) :: :ok
def connect(target) do
:ets.insert(__MODULE__, {target, self(), System.monotonic_time()})
GenServer.cast(__MODULE__, {:monitor, self()})
end
@doc """
Remove an inactive connection.
"""
@spec disconnect(Router.target()) :: :ok
def disconnect(target) do
:ets.match_delete(__MODULE__, {target, self(), :_})
:ok
end
@doc """
Return the request count for each of the named targets.
"""
@spec request_count(Router.target() | [Router.target()]) :: %{
Router.target() => non_neg_integer()
}
def request_count(targets) do
# :ets.fun2ms(fn {target, _, _} when target in [:targeta, :targetb] -> target end)
targets = List.wrap(targets)
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
match_spec = [{{:"$1", :_, :_}, target_guard, [:"$1"]}]
__MODULE__
|> :ets.select(match_spec)
|> Enum.frequencies()
end
@doc """
Return the most recent request time for the named targets.
"""
@spec last_request_time([Router.target()]) :: %{Router.target() => non_neg_integer()}
def last_request_time(targets) do
# :ets.fun2ms(fn {target, _, t} when target in [:targeta, :targetb] -> {target, t} end)
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
match_spec = [{{:"$1", :_, :"$2"}, target_guard, [{{:"$1", :"$2"}}]}]
__MODULE__
|> :ets.select(match_spec)
|> most_recent_request_time_per_target()
end
defp most_recent_request_time_per_target(target_times),
do: most_recent_request_time_per_target(target_times, %{})
defp most_recent_request_time_per_target([{target, time} | tail], result)
when time <= :erlang.map_get(target, result),
do: most_recent_request_time_per_target(tail, result)
defp most_recent_request_time_per_target([{target, time} | tail], result),
do: most_recent_request_time_per_target(tail, Map.put(result, target, time))
defp most_recent_request_time_per_target([], result), do: result
defp report_interval do
:wayfarer
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:report_interval, :timer.seconds(1))
end
end

View file

@ -0,0 +1,182 @@
defmodule Wayfarer.Target.Check do
@moduledoc """
A GenServer which represents a single check to an HTTP endpoint.
"""
use GenServer, restart: :transient
alias Mint.{HTTP, HTTP1, HTTP2, WebSocket}
alias Wayfarer.{Target, Target.TotalConnections, Telemetry}
require Logger
@type state :: %{
conn: struct(),
req: reference(),
scheme: :http | :https | :ws | :wss,
address: :inet.ip_address(),
port: :socket.port_number(),
uri: URI.t(),
ref: any,
method: String.t(),
headers: [{String.t(), String.t()}],
hostname: String.t(),
transport: :http1 | :http2 | :auto,
span: map
}
@doc false
@impl true
def init(state), do: {:ok, state, {:continue, :start_check}}
@doc false
@impl true
@spec handle_continue(:start_check, state) :: {:noreply, state, timeout} | {:stop, :normal, nil}
def handle_continue(:start_check, state) do
state =
state
|> Map.put(:span, %{
metadata: %{
target: %{scheme: state.scheme, address: state.address, port: state.port},
method: state.method,
uri: state.uri,
hostname: state.hostname,
telemetry_span_context: make_ref()
}
})
|> Telemetry.health_check_start()
with {:ok, state} <- connect(state),
{:ok, state} <- request(state) do
{:noreply, state, state.response_timeout}
else
{:error, reason} ->
check_failed(state, reason)
{:error, _conn, reason} ->
check_failed(state, reason)
end
end
@doc false
@impl true
def handle_info(:timeout, state), do: check_failed(state, :timeout)
def handle_info(message, state) do
with {:ok, conn, responses} <- WebSocket.stream(state.conn, message),
:ok <-
TotalConnections.health_check_connect(
{state.scheme, state.address, state.port, state.transport}
),
{:ok, status} <- get_status_response(conn, responses) do
if Enum.any?(state.success_codes, &Enum.member?(&1, status)) do
Target.check_passed(state.ref)
Telemetry.health_check_pass(state, status)
{:stop, :normal, nil}
else
check_failed(state, "received #{status} status code")
end
else
{:continue, conn} ->
{:noreply, Map.put(state, :conn, conn)}
:unknown ->
check_failed(state, "Received unknown message: `#{inspect(message)}`")
{:error, _conn, error, _responses} ->
check_failed(state, error)
end
end
defp connect(state) when state.scheme == :ws,
do: connect(%{state | scheme: :http})
defp connect(state) when state.scheme == :wss,
do: connect(%{state | scheme: :https})
defp connect(state) when state.transport == :http1 do
with {:ok, conn} <-
HTTP1.connect(state.scheme, state.address, state.port,
timeout: state.connect_timeout,
hostname: state.hostname
) do
{:ok, Telemetry.health_check_connect(Map.put(state, :conn, conn), :http1)}
end
end
defp connect(state) when state.transport == :http2 do
with {:ok, conn} <-
HTTP2.connect(state.scheme, state.address, state.port,
timeout: state.connect_timeout,
hostname: state.hostname
) do
{:ok, Telemetry.health_check_connect(Map.put(state, :conn, conn), :http2)}
end
end
defp connect(state) do
with {:ok, conn} <-
HTTP.connect(state.scheme, state.address, state.port,
timeout: state.connect_timeout,
hostname: state.hostname
) do
transport =
case conn do
%Mint.HTTP1{} -> :http1
%Mint.HTTP2{} -> :http2
end
{:ok, Telemetry.health_check_connect(Map.put(state, :conn, conn), transport)}
end
end
defp request(state) when state.scheme in [:ws, :wss] do
with {:ok, conn, req} <-
WebSocket.upgrade(state.scheme, state.conn, state.path, state.headers, []) do
state = Map.merge(state, %{conn: conn, req: req})
{:ok, Telemetry.health_check_request(state)}
end
end
defp request(state) do
with {:ok, conn, req} <-
HTTP.request(state.conn, state.method, state.path, state.headers, nil) do
state = Map.merge(state, %{conn: conn, req: req})
{:ok, Telemetry.health_check_request(state)}
end
end
defp check_failed(state, reason) when is_binary(reason) do
Target.check_failed(state.ref)
Telemetry.health_check_fail(state, reason)
Logger.warning(fn -> "Health check failed for #{state.method} #{state.uri}: #{reason}." end)
{:stop, :normal, nil}
end
defp check_failed(state, exception) when is_exception(exception) do
Target.check_failed(state.ref)
Telemetry.health_check_fail(state, exception)
Logger.warning(fn ->
"Health check failed for #{state.method} #{state.uri}: #{Exception.message(exception)}"
end)
{:stop, :normal, nil}
end
defp check_failed(state, reason) do
Target.check_failed(state.ref)
Telemetry.health_check_fail(state, reason)
Logger.warning(fn ->
"Health check failed for #{state.method} #{state.uri}: `#{inspect(reason)}`"
end)
{:stop, :normal, nil}
end
defp get_status_response(conn, []), do: {:continue, conn}
defp get_status_response(_conn, [{:status, _, status} | _]), do: {:ok, status}
defp get_status_response(conn, [_ | tail]), do: get_status_response(conn, tail)
end

View file

@ -0,0 +1,86 @@
defmodule Wayfarer.Target.Selector do
@moduledoc """
Given a list of targets and an algorithm decide which target the request
should be forwarded to.
"""
alias Plug.Conn
alias Wayfarer.{Router, Target.ActiveConnections, Target.TotalConnections}
import Plug.Conn
@typedoc """
The algorithm used to select which target to forward requests to (when there
is more than one matching target).
"""
@type algorithm :: :least_connections | :random | :round_robin | :sticky
@doc """
A guard for testing if an algorithm is supported.
"""
@spec is_algorithm(any) :: Macro.output()
defguard is_algorithm(algorithm)
when algorithm in ~w[least_connections random round_robin sticky]a
@doc """
Tries to choose a target from the list of targets to send the request to based
on the chosen algorithm.
"""
@spec choose(Conn.t(), [Router.target()], Router.algorithm()) :: {:ok, Router.target()} | :error
# We can't choose from an empty list.
def choose(_conn, [], _), do: :error
# When there's only one target, we don't need to choose anything.
def choose(_conn, [target], _), do: {:ok, target}
# Sticky targets try and use the same target for each request from the same
# client if possible.
def choose(conn, targets, :sticky) do
peer = get_peer_data(conn)
listener = conn.private.wayfarer.listener
index =
:erlang.phash2(
{listener.address, listener.port, conn.remote_ip, peer.address, peer.port},
length(targets)
)
targets
|> Enum.at(index, :error)
|> case do
:error -> :error
target -> {:ok, target}
end
end
def choose(_conn, targets, :random) do
{:ok, Enum.random(targets)}
end
# Choose the target with the fewest active connections from the list.
def choose(_conn, targets, :least_connections) do
targets
|> ActiveConnections.request_count()
|> case do
request_counts when map_size(request_counts) == 0 ->
{:ok, Enum.random(targets)}
request_counts ->
{:ok,
request_counts
|> Enum.min_by(&elem(&1, 1))
|> elem(0)}
end
end
# Select from targets sequentially.
def choose(_conn, targets, :round_robin) do
{target, _} =
targets
|> TotalConnections.proxy_count()
|> Enum.min_by(&elem(&1, 1), &<=/2, fn -> {Enum.random(targets), 0} end)
{:ok, target}
end
end

View file

@ -0,0 +1,18 @@
defmodule Wayfarer.Target.Supervisor do
@moduledoc false
use Supervisor
def start_link(arg), do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
@impl true
def init(_) do
[
{Registry, keys: :unique, name: Wayfarer.Target.Registry},
{DynamicSupervisor, name: Wayfarer.Target.DynamicSupervisor, strategy: :one_for_one},
Wayfarer.Target.ActiveConnections,
Wayfarer.Target.TotalConnections
]
|> Supervisor.init(strategy: :one_for_one)
end
end

View file

@ -0,0 +1,111 @@
defmodule Wayfarer.Target.TotalConnections do
@moduledoc """
A simple ETS table that tracks the total number of requests for each target.
"""
use GenServer
alias Wayfarer.{Router, Utils}
require Logger
@type state :: %{table: :ets.tid(), timer: :timer.tref()}
@doc """
Increment the proxy connection counter for the specified target.
"""
@spec proxy_connect(Router.target()) :: :ok
def proxy_connect(target) do
:ets.update_counter(__MODULE__, target, {2, 1}, {target, 0, 0})
:ok
end
@doc """
Increment the health check connection counter for the specified target.
"""
@spec health_check_connect(Router.target()) :: :ok
def health_check_connect(target) do
:ets.update_counter(__MODULE__, target, {3, 1}, {target, 0, 0})
:ok
end
@doc """
Return the total number of requests for the provided targets.
"""
@spec request_count(Router.target() | [Router.target()]) :: %{
Router.target() => %{proxied: non_neg_integer(), health_checks: non_neg_integer()}
}
def request_count(targets) do
# :ets.fun2ms(fn {target, proxied, checks} when target in [:a, :b] -> {target, proxied, checks} end)
targets = List.wrap(targets)
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
match_spec = [
{{:"$1", :"$2", :"$3"}, target_guard, [{{:"$1", :"$2", :"$3"}}]}
]
__MODULE__
|> :ets.select(match_spec)
|> Map.new(fn {target, proxied, checks} ->
{target, %{proxied: proxied, health_checks: checks}}
end)
end
@doc """
Return the total number of proxy requests for the provided targets.
"""
@spec proxy_count(Router.target() | [Router.target()]) :: %{
Router.target() => non_neg_integer()
}
def proxy_count(targets) do
# :ets.fun2ms(fn {target, proxied, _} when target in [:a, :b] -> {target, proxied} end)
targets = List.wrap(targets)
target_guard = Utils.targets_to_ms_guard(:"$1", targets)
match_spec = [{{:"$1", :"$2", :_}, target_guard, [{{:"$1", :"$2"}}]}]
__MODULE__
|> :ets.select(match_spec)
|> Map.new()
end
@doc false
@spec start_link(any) :: GenServer.on_start()
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
@doc false
@impl true
@spec init(any) :: {:ok, state} | {:stop, any}
def init(_) do
report_interval()
|> :timer.send_interval(:tick)
|> case do
{:ok, timer} ->
table =
__MODULE__
|> :ets.new([:public, :named_table, :set])
{:ok, %{table: table, timer: timer}}
{:error, reason} ->
{:stop, reason}
end
end
@impl true
@spec handle_info(:tick, state) :: {:noreply, state}
def handle_info(:tick, state) do
for {target, count} <- :ets.tab2list(state.table) do
Logger.debug("Total connections for #{inspect(target)}: #{count}")
end
{:noreply, state}
end
defp report_interval do
:wayfarer
|> Application.get_env(__MODULE__, [])
|> Keyword.get(:report_interval, :timer.seconds(10))
end
end

764
lib/wayfarer/telemetry.ex Normal file
View file

@ -0,0 +1,764 @@
defmodule Wayfarer.Telemetry do
@moduledoc """
Wayfarer emits a number of telemetry events and spans on top of the excellent
telemetry events emitted by `Bandit.Telemetry`.
"""
alias Plug.Conn
alias Wayfarer.{Router, Target.Check, Target.Selector}
@typedoc """
Information about the target a request has been routed to.
"""
@type target ::
%{
scheme: :http | :https | :ws | :wss,
address: :inet.ip_address(),
port: :socket.port_number()
}
| %{scheme: :plug, module: module, options: any}
@typedoc """
Information about the listener that received the request.
"""
@type listener ::
%{
scheme: :http | :https,
module: module(),
port: :socket.port_number(),
address: :inet.ip_address()
}
@typedoc """
The time that the event was emitted, in `:native` time units.
This is sources from `System.monotonic_time/0` which has some caveats but in
general is better for calculating durations as it should never go backwards.
"""
@type monotonic_time :: integer()
@typedoc """
The time passed since the beginning of the span, in `:native` time units.
The difference between the current `monotonic_time` and the first
`monotonic_time` at the start of the span.
"""
@type duration :: integer()
@typedoc "The HTTP protocol version of the request"
@type transport :: :http1 | :http2
@typedoc "A unique identifier for the span"
@type telemetry_span_context :: reference()
@typedoc "A convenience type for describing telemetry events"
@opaque event(measurements, metadata) :: {measurements, metadata}
@typedoc """
The `[:wayfarer, :request, :start]` event.
This event signals the start of a request span tracking a client request to
completion.
You can use the `telemetry_span_context` metadata value to correlate
subsequent events within the same span.
"""
@type request_start ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:wallclock_time) => DateTime.t()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context()
}
)
@typedoc """
The `[:wayfarer, :request, :routed]` event.
This event signals that the routing process has completed and a target has
been chosen to serve the request.
"""
@type request_routed ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm()
}
)
@typedoc """
The `[:wayfarer, :request, :exception]` event.
This event signals that something went wrong while processing the event. You
will likely still receive other events (eg `:stop`) for this span however.
"""
@type request_exception ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
optional(:target) => target(),
optional(:algorithm) => Selector.algorithm(),
required(:kind) => :throw | :exit | :error | :exception,
required(:reason) => any,
required(:stacktrace) => Exception.stacktrace()
}
)
@typedoc """
The `[:wayfarer, :request, :stop]` event.
This event signals that the request has completed.
The measurements will contain any incrementing counters accumulated during the
course of the request.
"""
@type request_stop ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
required(:status) => nil | 100..599,
optional(:req_body_bytes) => non_neg_integer(),
optional(:resp_body_bytes) => non_neg_integer(),
optional(:client_frame_bytes) => non_neg_integer(),
optional(:client_frame_count) => non_neg_integer(),
optional(:server_frame_bytes) => non_neg_integer(),
optional(:server_frame_count) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
optional(:target) => target(),
optional(:algorithm) => Selector.algorithm(),
optional(:status) => nil | 100..599,
optional(:kind) => :throw | :exit | :error | :exception,
optional(:reason) => any,
optional(:stacktrace) => Exception.stacktrace()
}
)
@typedoc """
The `[:wayfarer, :request, :received_status]` event.
This event signals that an HTTP status code has been received from the
upstream target.
"""
@type request_received_status ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
required(:status) => nil | 100..599,
optional(:req_body_bytes) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599
}
)
@typedoc """
The `[:wayfarer, :request, :req_body_chunk]` event.
This event is emitted while streaming the request body from the client to the
target. Under the hood `Plug.Conn.read_body/2` is being called with the
default options, meaning that each chunk is likely to be up to 8MB in size.
If there is no request body then this event will not be emitted and the
`req_body_bytes` and `req_body_chunks` counters will both be set to zero for
this request.
"""
@type request_req_body_chunk ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
required(:status) => nil | 100..599,
required(:req_body_bytes) => non_neg_integer(),
required(:req_body_chunks) => non_neg_integer(),
required(:chunk_bytes) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599
}
)
@typedoc """
The `[:wayfarer, :request, :resp_started]` event.
This event indicates that the HTTP status and headers have been received from
the target and the response will now start being sent to the target.
"""
@type request_resp_started ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
required(:status) => nil | 100..599,
optional(:req_body_bytes) => non_neg_integer(),
optional(:req_body_chunks) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599
}
)
@typedoc """
The `[:wayfarer, :request, :resp_body_chunk]` event.
This event is emitted every time a chunk of response body is received from the
target for streaming to the client. Under the hood, these are emitted every
time `Mint.HTTP.stream/2` returns a data frame.
"""
@type request_resp_body_chunk ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
optional(:req_body_bytes) => non_neg_integer(),
optional(:req_body_chunks) => non_neg_integer(),
required(:resp_body_bytes) => non_neg_integer(),
required(:resp_body_chunks) => non_neg_integer(),
required(:chunk_bytes) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599
}
)
@typedoc """
The `[:wayfarer, :request, :upgraded]` event.
This event is emitted when a client connection is upgraded to a WebSocket
connection.
"""
@type request_upgraded ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599
}
)
@typedoc """
The `[:wayfarer, :request, :client_frame]` event.
This event is emitted any time a WebSocket frame is received from the client
for transmission to the target.
"""
@type request_client_frame ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
required(:frame_size) => non_neg_integer(),
required(:client_frame_bytes) => non_neg_integer(),
required(:client_frame_count) => non_neg_integer(),
optional(:server_frame_bytes) => non_neg_integer(),
optional(:server_frame_count) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599,
required(:opcode) => :text | :binary | :ping | :pong | :close
}
)
@typedoc """
The `[:wayfarer, :request, :server_frame]` event.
This event is emitted any time a WebSocket frame is received from the target
for transmission to the client.
"""
@type request_server_frame ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:duration) => duration(),
required(:wallclock_time) => DateTime.t(),
required(:frame_size) => non_neg_integer(),
required(:server_frame_bytes) => non_neg_integer(),
required(:server_frame_count) => non_neg_integer(),
optional(:client_frame_bytes) => non_neg_integer(),
optional(:client_frame_count) => non_neg_integer()
},
%{
required(:conn) => Conn.t(),
required(:listener) => listener(),
required(:transport) => transport(),
required(:telemetry_span_context) => telemetry_span_context(),
required(:target) => target(),
required(:algorithm) => Selector.algorithm(),
required(:status) => nil | 100..599
}
)
@typedoc """
All the event types that make up the `[:wayfarer, :request, :*]` span.
"""
@type request_span ::
request_start
| request_routed
| request_exception
| request_stop
| request_received_status
| request_req_body_chunk
| request_resp_started
| request_resp_body_chunk
| request_upgraded
| request_client_frame
| request_server_frame
@typedoc """
The `[:wayfarer, :health_check, :start]` event.
This event signals the start of a health check span.
You can use the `telemetry_span_context` metadata value to correlate
subsequent events within the same span.
"""
@type health_check_start ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:wallclock_time) => DateTime.t()
},
%{
required(:telemetry_span_context) => telemetry_span_context(),
required(:hostname) => String.t(),
required(:uri) => URI.t(),
required(:target) => target(),
required(:method) => String.t()
}
)
@typedoc """
The `[:wayfarer, :health_check, :connect]` event.
This event signals that the outgoing TCP connection has been made to the
target.
"""
@type health_check_connect ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:wallclock_time) => DateTime.t(),
required(:duration) => duration()
},
%{
required(:telemetry_span_context) => telemetry_span_context(),
required(:hostname) => String.t(),
required(:uri) => URI.t(),
required(:target) => target(),
required(:method) => String.t(),
required(:transport) => transport()
}
)
@typedoc """
The `[:wayfarer, :health_check, :request]` event.
This event signals that the HTTP or WebSocket request has been sent.
"""
@type health_check_request ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:wallclock_time) => DateTime.t(),
required(:duration) => duration()
},
%{
required(:telemetry_span_context) => telemetry_span_context(),
required(:hostname) => String.t(),
required(:uri) => URI.t(),
required(:target) => target(),
required(:method) => String.t(),
required(:transport) => transport()
}
)
@typedoc """
The `[:wayfarer, :health_check, :pass]` event.
This event signals that the HTTP status code returned by the target matches
one of the configured success codes.
It also signals the end of the span.
"""
@type health_check_pass ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:wallclock_time) => DateTime.t(),
required(:duration) => duration(),
required(:status) => 100..599
},
%{
required(:telemetry_span_context) => telemetry_span_context(),
required(:hostname) => String.t(),
required(:uri) => URI.t(),
required(:target) => target(),
required(:method) => String.t(),
required(:transport) => transport()
}
)
@typedoc """
The `[:wayfarer, :health_check, :fail]` event.
This event signals that the HTTP status code returned by the target did not
match any of the configured success codes.
It also signals the end of the span.
"""
@type health_check_fail ::
event(
%{
required(:monotonic_time) => monotonic_time(),
required(:wallclock_time) => DateTime.t(),
required(:duration) => duration(),
required(:reason) => any
},
%{
required(:telemetry_span_context) => telemetry_span_context(),
required(:hostname) => String.t(),
required(:uri) => URI.t(),
required(:target) => target(),
required(:method) => String.t(),
required(:transport) => transport()
}
)
@typedoc """
All the event types that make up the `[:wayfarer, :health_check, :*]` span.
"""
@type health_check_span ::
health_check_start | health_check_request | health_check_pass | health_check_fail
@typedoc """
All the spans that can be emitted by Wayfarer.
"""
@type spans :: request_span | health_check_span
@doc false
@spec request_start(Conn.t()) :: Conn.t()
def request_start(conn) do
telemetry_span_context = make_ref()
metadata =
conn
|> Map.get(:private, %{})
|> Map.get(:wayfarer, %{})
|> Map.merge(%{telemetry_span_context: telemetry_span_context, conn: conn})
conn
|> execute_request_span_event(:start, %{}, metadata)
end
@doc false
@spec request_routed(Conn.t(), Router.target(), Router.algorithm()) :: Conn.t()
def request_routed(conn, target, algorithm) do
target =
case target do
{scheme, address, port, _transport} -> %{scheme: scheme, address: address, port: port}
{:plug, {module, opts}} -> %{scheme: :plug, module: module, options: opts}
{:plug, module} -> %{scheme: :plug, module: module, options: []}
end
conn
|> execute_request_span_event(:routed, %{}, %{
target: target,
algorithm: algorithm
})
end
@doc false
@spec request_exception(Conn.t(), kind :: any, reason :: any, stacktrace :: list | nil) ::
Conn.t()
def request_exception(conn, kind, reason, stacktrace \\ nil) do
conn
|> execute_request_span_event(:exception, %{}, %{
kind: kind,
reason: reason,
stacktrace: stacktrace || Process.info(self(), :current_stacktrace)
})
end
@doc false
def request_stop(conn) do
conn
|> execute_request_span_event(:stop, %{status: conn.status}, %{})
end
@doc false
@spec request_received_status(Conn.t(), non_neg_integer()) :: Conn.t()
def request_received_status(conn, status) do
conn
|> execute_request_span_event(:received_status, %{status: status}, %{status: status})
end
@doc false
@spec request_req_body_chunk(Conn.t(), non_neg_integer()) :: Conn.t()
def request_req_body_chunk(conn, chunk_bytes) do
conn
|> execute_request_span_event(:req_body_chunk, %{chunk_bytes: chunk_bytes}, %{})
end
@doc false
@spec request_resp_started(Conn.t()) :: Conn.t()
def request_resp_started(conn) do
metric = %{status: conn.status}
conn
|> execute_request_span_event(:resp_started, metric, metric)
end
@doc false
@spec request_resp_body_chunk(Conn.t(), non_neg_integer()) :: Conn.t()
def request_resp_body_chunk(conn, chunk_bytes) do
conn
|> execute_request_span_event(:resp_body_chunk, %{chunk_bytes: chunk_bytes}, %{})
end
@doc false
@spec request_upgraded(Conn.t()) :: Conn.t()
def request_upgraded(conn) do
conn
|> execute_request_span_event(:upgraded, %{}, %{})
end
@doc false
@spec request_client_frame(Conn.t(), non_neg_integer(), atom) :: Conn.t()
def request_client_frame(conn, bytes, opcode) do
conn
|> execute_request_span_event(:client_frame, %{frame_size: bytes}, %{opcode: opcode})
end
@doc false
@spec request_server_frame(Conn.t(), non_neg_integer(), atom) :: Conn.t()
def request_server_frame(conn, bytes, opcode) do
conn
|> execute_request_span_event(:server_frame, %{frame_size: bytes}, %{opcode: opcode})
end
@doc false
@spec set_metrics(Conn.t(), %{atom => number}) :: Conn.t()
def set_metrics(conn, metrics) do
update_metrics(conn, &Map.merge(&1, metrics))
end
@doc false
@spec increment_metrics(Conn.t(), %{atom => number}) :: Conn.t()
def increment_metrics(conn, to_increment) do
update_metrics(conn, fn metrics ->
Enum.reduce(to_increment, metrics, fn {metric_name, increment_by}, metrics ->
Map.update(metrics, metric_name, increment_by, &(&1 + increment_by))
end)
end)
end
@doc false
@spec health_check_start(Check.state()) :: Check.state()
def health_check_start(check) do
execute_check_span_event(check, :start, %{}, %{})
end
@doc false
@spec health_check_connect(Check.state(), atom) :: Check.state()
def health_check_connect(check, transport) do
execute_check_span_event(check, :connect, %{}, %{transport: transport})
end
@doc false
@spec health_check_request(Check.state()) :: Check.state()
def health_check_request(check) do
execute_check_span_event(check, :request, %{}, %{})
end
@doc false
@spec health_check_fail(Check.state(), any) :: Check.state()
def health_check_fail(check, reason) do
execute_check_span_event(check, :fail, %{}, %{reason: reason})
end
@doc false
@spec health_check_pass(Check.state(), 100..599) :: Check.state()
def health_check_pass(check, status) do
execute_check_span_event(check, :pass, %{}, %{status: status})
end
defp update_metrics(conn, callback) do
private =
conn
|> Map.get(:private, %{})
|> Map.get(:wayfarer, %{})
metrics =
private
|> Map.get(:metrics, %{})
|> callback.()
private = Map.put(private, :metrics, metrics)
conn
|> Conn.put_private(:wayfarer, private)
end
defp execute_request_span_event(conn, event, measurements, metadata) do
monotonic_time = System.monotonic_time()
now = DateTime.utc_now()
private =
conn
|> Map.get(:private, %{})
|> Map.get(:wayfarer, %{})
span_info =
private
|> Map.get(:request_span, %{})
metrics =
private
|> Map.get(:metrics, %{})
measurements =
if Map.has_key?(span_info, :start_time) do
%{
monotonic_time: monotonic_time,
duration: monotonic_time - span_info.start_time,
wallclock_time: now
}
else
%{
monotonic_time: monotonic_time,
wallclock_time: now
}
end
|> Map.merge(metrics)
|> Map.merge(measurements)
metadata =
span_info
|> Map.get(:metadata, %{})
|> Map.merge(metadata)
:telemetry.execute([:wayfarer, :request, event], measurements, Map.put(metadata, :conn, conn))
span_info =
span_info
|> Map.put(:metadata, metadata)
|> Map.put_new(:start_time, monotonic_time)
private =
private
|> Map.put(:request_span, span_info)
conn
|> Conn.put_private(:wayfarer, private)
end
defp execute_check_span_event(check, event, measurements, metadata) do
monotonic_time = System.monotonic_time()
now = DateTime.utc_now()
span_info = Map.get(check, :span, %{})
metrics = Map.get(span_info, :metrics, %{})
measurements =
if Map.has_key?(span_info, :start_time) do
%{
monotonic_time: monotonic_time,
duration: monotonic_time - span_info.start_time,
wallclock_time: now
}
else
%{
monotonic_time: monotonic_time,
wallclock_time: now
}
end
|> Map.merge(metrics)
|> Map.merge(measurements)
metadata =
span_info
|> Map.get(:metadata, %{})
|> Map.merge(metadata)
:telemetry.execute([:wayfarer, :health_check, event], measurements, metadata)
span_info =
span_info
|> Map.put(:metadata, metadata)
|> Map.put_new(:start_time, monotonic_time)
Map.put(check, :span, span_info)
end
end

100
lib/wayfarer/utils.ex Normal file
View file

@ -0,0 +1,100 @@
defmodule Wayfarer.Utils do
@moduledoc """
A grab-bag of useful functions which are used all over the codebase.
"""
@type address_input :: IP.Address.t() | String.t() | :inet.ip_address()
@type port_number :: 1..0xFFFF
@type scheme :: :http | :https | :ws | :wss
@type transport :: :http1 | :http2 | :auto
@doc """
Verify an IP address and convert it into a tuple.
"""
@spec sanitise_ip_address(address_input) :: {:ok, :inet.ip_address()} | {:error, any}
def sanitise_ip_address(address) when is_binary(address) do
with {:ok, address} <- IP.Address.from_string(address) do
{:ok, IP.Address.to_tuple(address)}
end
end
def sanitise_ip_address(address) when is_struct(address, IP.Address),
do: {:ok, IP.Address.to_tuple(address)}
def sanitise_ip_address(address) when is_tuple(address) do
with {:ok, _} <- IP.Address.from_tuple(address) do
{:ok, address}
end
end
def sanitise_ip_address(address),
do:
{:error,
ArgumentError.exception(message: "Value `#{inspect(address)}` is not a valid IP address.")}
@doc """
Verify a port number.
"""
@spec sanitise_port(port_number) :: {:ok, port_number} | {:error, any}
def sanitise_port(port) when is_integer(port) and port > 0 and port <= 0xFFFF, do: {:ok, port}
def sanitise_port(port),
do:
{:error,
ArgumentError.exception(message: "Value `#{inspect(port)}` is not a valid port number.")}
@doc """
Verify a scheme.
"""
@spec sanitise_scheme(scheme) :: {:ok, scheme} | {:error, any}
def sanitise_scheme(scheme) when scheme in [:http, :https, :ws, :wss], do: {:ok, scheme}
def sanitise_scheme(scheme),
do:
{:error,
ArgumentError.exception(
message: "Value `#{inspect(scheme)}` is not a supported URI scheme."
)}
@doc """
Convert a scheme, address, port tuple into a `URI`.
"""
@spec to_uri(scheme, address_input, port_number) :: {:ok, URI.t()} | {:error, any}
def to_uri(scheme, address, port) do
with {:ok, scheme} <- sanitise_scheme(scheme),
{:ok, address} <- sanitise_ip_address(address),
{:ok, address} <- IP.Address.from_tuple(address),
{:ok, port} <- sanitise_port(port) do
%URI{
scheme: to_string(scheme),
host: to_string(address),
port: port
}
|> URI.new()
end
end
@doc """
Convert a list of targets into a match spec guard.
"""
@spec targets_to_ms_guard(atom, [{scheme, :inet.ip_address(), port_number, transport}]) :: [
{atom, any, any}
]
def targets_to_ms_guard(_var, []), do: []
def targets_to_ms_guard(var, [head | tail]),
do: targets_to_ms_guard(var, tail, {:"=:=", var, target_to_ms(head)})
defp targets_to_ms_guard(_var, [], guard), do: [guard]
defp targets_to_ms_guard(var, [head | tail], guard),
do: targets_to_ms_guard(var, tail, {:orelse, {:"=:=", var, target_to_ms(head)}, guard})
@doc """
Convert a target tuple into a tuple safe for injection into a match spec.
"""
@spec target_to_ms({scheme, :inet.ip_address(), port_number, transport}) ::
{{scheme, {:inet.ip_address()}, port_number, transport}}
def target_to_ms({scheme, address, port, transport}) when is_tuple(address),
do: {{scheme, {address}, port, transport}}
end

73
mix.exs
View file

@ -5,7 +5,7 @@ defmodule Wayfarer.MixProject do
A runtime-configurable HTTP reverse proxy based on Bandit.
"""
@version "0.2.0"
@version "0.6.1"
def project do
[
@ -18,14 +18,47 @@ defmodule Wayfarer.MixProject do
deps: deps(),
description: @moduledoc,
package: package(),
source_url: "https://code.harton.nz/bivouac/wayfarer",
homepage_url: "https://code.harton.nz/bivouac/wayfarer",
source_url: "https://harton.dev/james/wayfarer",
homepage_url: "https://harton.dev/james/wayfarer",
aliases: aliases(),
dialyzer: [plt_add_apps: []],
dialyzer: [plt_ignore_apps: [:mint]],
docs: [
main: "Wayfarer",
extras: ["README.md"],
formatters: ["html"]
formatters: ["html"],
extra_section: "GUIDES",
filter_modules: ~r/^Elixir.Wayfarer/,
source_url_pattern: "https://harton.dev/james/wayfarer/src/branch/main/%{path}#L%{line}",
spark: [
extensions: [
%{
module: Wayfarer.Dsl,
name: "Wayfarer.Dsl",
target: "Wayfarer",
type: "Wayfarer"
}
]
],
extras:
["README.md"]
|> Enum.concat(Path.wildcard("documentation/**/*.{md,livemd,cheatmd}")),
groups_for_extras:
"documentation/*"
|> Path.wildcard()
|> Enum.map(fn dir ->
name =
dir
|> Path.split()
|> List.last()
|> String.split("_")
|> Enum.map_join(" ", &String.capitalize/1)
files =
dir
|> Path.join("**.{md,livemd,cheatmd}")
|> Path.wildcard()
{name, files}
end)
]
]
end
@ -34,11 +67,15 @@ defmodule Wayfarer.MixProject do
[
name: :wayfarer,
files: ~w[lib .formatter.exs mix.exs README.md LICENSE.md CHANGELOG.md],
maintainers: ["James Harton <james@harton.nz>"],
licenses: ["HL3-FULL"],
links: %{
"Source" => "https://code.harton.nz/bivouac/wayfarer"
"Source" => "https://harton.dev/james/wayfarer",
"GitHub" => "https://github.com/jimsynz/wayfarer",
"Changelog" => "https://docs.harton.nz/james/wayfarer/changelog.html",
"Sponsor" => "https://github.com/sponsors/jimsynz"
},
source_url: "https://code.harton.nz/bivouac/wayfarer"
source_url: "https://harton.dev/james/wayfarer"
]
end
@ -55,27 +92,37 @@ defmodule Wayfarer.MixProject do
opts = [only: ~w[dev test]a, runtime: false]
[
{:bandit, "~> 0.7"},
{:bandit, "~> 1.0"},
{:castore, "~> 1.0"},
{:ip, "~> 2.0"},
{:mint, "~> 1.5"},
{:mint_web_socket, "~> 1.0"},
{:nimble_options, "~> 1.0"},
{:plug, "~> 1.15"},
{:spark, "~> 2.0"},
{:telemetry, "~> 1.2"},
{:websock, "~> 0.5"},
{:websock_adapter, "~> 0.5"},
# Dev/test
{:credo, "~> 1.7", opts},
{:dialyxir, "~> 1.3", opts},
{:doctor, "~> 0.21", opts},
{:earmark, ">= 0.0.0", opts},
{:ex_check, "~> 0.15", opts},
{:ex_check, "~> 0.16", opts},
{:ex_doc, ">= 0.0.0", opts},
{:faker, "~> 0.17", opts},
{:finch, "~> 0.16", opts},
{:faker, "~> 0.18", opts},
{:git_ops, "~> 2.6", opts},
{:mimic, "~> 1.7", Keyword.delete(opts, :runtime)},
{:mix_audit, "~> 2.1", opts}
]
end
defp aliases, do: []
defp aliases,
do: [
"spark.formatter": "spark.formatter --extensions=Wayfarer.Dsl",
"spark.cheat_sheets": "spark.cheat_sheets --extensions=Wayfarer.Dsl"
]
defp elixirc_paths(env) when env in ~w[dev test]a, do: ~w[lib test/support]
defp elixirc_paths(_), do: ~w[lib]

View file

@ -1,37 +1,49 @@
%{
"bandit": {:hex, :bandit, "0.7.7", "48456d09022607a312cf723a91992236aeaffe4af50615e6e2d2e383fb6bef10", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.6.7", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "772f0a32632c2ce41026d85e24b13a469151bb8cea1891e597fb38fde103640a"},
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
"castore": {:hex, :castore, "1.0.4", "ff4d0fb2e6411c0479b1d965a814ea6d00e51eb2f58697446e9c41a97d940b28", [:mix], [], "hexpm", "9418c1b8144e11656f0be99943db4caf04612e3eaecefb5dae9a2a87565584f8"},
"credo": {:hex, :credo, "1.7.1", "6e26bbcc9e22eefbff7e43188e69924e78818e2fe6282487d0703652bc20fd62", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9871c6095a4c0381c89b6aa98bc6260a8ba6addccf7f6a53da8849c748a58a2"},
"bandit": {:hex, :bandit, "1.5.7", "6856b1e1df4f2b0cb3df1377eab7891bec2da6a7fd69dc78594ad3e152363a50", [:mix], [{:hpax, "~> 1.0.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "f2dd92ae87d2cbea2fa9aa1652db157b6cba6c405cb44d4f6dd87abba41371cd"},
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
"castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"},
"credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"},
"decimal": {:hex, :decimal, "2.1.1", "5611dca5d4b2c3dd497dec8f68751f1f1a54755e8ed2a966c2633cf885973ad6", [:mix], [], "hexpm", "53cfe5f497ed0e7771ae1a475575603d77425099ba5faef9394932b35020ffcc"},
"dialyxir": {:hex, :dialyxir, "1.4.1", "a22ed1e7bd3a3e3f197b68d806ef66acb61ee8f57b3ac85fc5d57354c5482a93", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "84b795d6d7796297cca5a3118444b80c7d94f7ce247d49886e7c291e1ae49801"},
"dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"},
"doctor": {:hex, :doctor, "0.21.0", "20ef89355c67778e206225fe74913e96141c4d001cb04efdeba1a2a9704f1ab5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "a227831daa79784eb24cdeedfa403c46a4cb7d0eab0e31232ec654314447e4e0"},
"earmark": {:hex, :earmark, "1.4.46", "8c7287bd3137e99d26ae4643e5b7ef2129a260e3dcf41f251750cb4563c8fb81", [:mix], [], "hexpm", "798d86db3d79964e759ddc0c077d5eb254968ed426399fbf5a62de2b5ff8910a"},
"earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"},
"earmark": {:hex, :earmark, "1.4.47", "7e7596b84fe4ebeb8751e14cbaeaf4d7a0237708f2ce43630cfd9065551f94ca", [:mix], [], "hexpm", "3e96bebea2c2d95f3b346a7ff22285bc68a99fbabdad9b655aa9c6be06c698f8"},
"earmark_parser": {:hex, :earmark_parser, "1.4.40", "f3534689f6b58f48aa3a9ac850d4f05832654fe257bf0549c08cc290035f70d5", [:mix], [], "hexpm", "cdb34f35892a45325bad21735fadb88033bcb7c4c296a999bde769783f53e46a"},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
"ex_check": {:hex, :ex_check, "0.15.0", "074b94c02de11c37bba1ca82ae5cc4926e6ccee862e57a485b6ba60fca2d8dc1", [:mix], [], "hexpm", "33848031a0c7e4209c3b4369ce154019788b5219956220c35ca5474299fb6a0e"},
"ex_doc": {:hex, :ex_doc, "0.30.6", "5f8b54854b240a2b55c9734c4b1d0dd7bdd41f71a095d42a70445c03cf05a281", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bd48f2ddacf4e482c727f9293d9498e0881597eae6ddc3d9562bd7923375109f"},
"faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
"finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"},
"ex_check": {:hex, :ex_check, "0.16.0", "07615bef493c5b8d12d5119de3914274277299c6483989e52b0f6b8358a26b5f", [:mix], [], "hexpm", "4d809b72a18d405514dda4809257d8e665ae7cf37a7aee3be6b74a34dec310f5"},
"ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"},
"faker": {:hex, :faker, "0.18.0", "943e479319a22ea4e8e39e8e076b81c02827d9302f3d32726c5bf82f430e6e14", [:mix], [], "hexpm", "bfbdd83958d78e2788e99ec9317c4816e651ad05e24cfd1196ce5db5b3e81797"},
"file_system": {:hex, :file_system, "1.0.0", "b689cc7dcee665f774de94b5a832e578bd7963c8e637ef940cd44327db7de2cd", [:mix], [], "hexpm", "6752092d66aec5a10e662aefeed8ddb9531d79db0bc145bb8c40325ca1d8536d"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"git_ops": {:hex, :git_ops, "2.6.0", "e0791ee1cf5db03f2c61b7ebd70e2e95cba2bb9b9793011f26609f22c0900087", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "b98fca849b18aaf490f4ac7d1dd8c6c469b0cc3e6632562d366cab095e666ffe"},
"hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"},
"jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"},
"makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"},
"makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"},
"mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"},
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
"mix_audit": {:hex, :mix_audit, "2.1.1", "653aa6d8f291fc4b017aa82bdb79a4017903902ebba57960ef199cbbc8c008a1", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "541990c3ab3a7bb8c4aaa2ce2732a4ae160ad6237e5dcd5ad1564f4f85354db1"},
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
"nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
"plug": {:hex, :plug, "1.15.1", "b7efd81c1a1286f13efb3f769de343236bd8b7d23b4a9f40d3002fc39ad8f74c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "459497bd94d041d98d948054ec6c0b76feacd28eec38b219ca04c0de13c79d30"},
"plug_crypto": {:hex, :plug_crypto, "2.0.0", "77515cc10af06645abbfb5e6ad7a3e9714f805ae118fa1a70205f80d2d70fe73", [:mix], [], "hexpm", "53695bae57cc4e54566d993eb01074e4d894b65a3766f1c43e2c61a1b0f45ea9"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"thousand_island": {:hex, :thousand_island, "0.6.7", "3a91a7e362ca407036c6691e8a4f6e01ac8e901db3598875863a149279ac8571", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "541a5cb26b88adf8d8180b6b96a90f09566b4aad7a6b3608dcac969648cf6765"},
"git_ops": {:hex, :git_ops, "2.6.1", "cc7799a68c26cf814d6d1a5121415b4f5bf813de200908f930b27a2f1fe9dad5", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "ce62d07e41fe993ec22c35d5edb11cf333a21ddaead6f5d9868fcb607d42039e"},
"glob_ex": {:hex, :glob_ex, "0.1.8", "f7ef872877ca2ae7a792ab1f9ff73d9c16bf46ecb028603a8a3c5283016adc07", [:mix], [], "hexpm", "9e39d01729419a60a937c9260a43981440c43aa4cadd1fa6672fecd58241c464"},
"ham": {:hex, :ham, "0.3.0", "7cd031b4a55fba219c11553e7b13ba73bd86eab4034518445eff1e038cb9a44d", [:mix], [], "hexpm", "7d6c6b73d7a6a83233876cc1b06a4d9b5de05562b228effda4532f9a49852bf6"},
"hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"},
"igniter": {:hex, :igniter, "0.3.24", "791a91650ffab9d66b9a3011c66491f767577ad55c363f820cc188554207ee6f", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:inflex, "~> 2.0", [hex: :inflex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:owl, "~> 0.9", [hex: :owl, repo: "hexpm", optional: false]}, {:rewrite, "~> 0.9", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}, {:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: false]}], "hexpm", "2e1d336534c6129bae0db043fae650303b96974c0488c290191d6d4c61ec9a9f"},
"inflex": {:hex, :inflex, "2.1.0", "a365cf0821a9dacb65067abd95008ca1b0bb7dcdd85ae59965deef2aa062924c", [:mix], [], "hexpm", "14c17d05db4ee9b6d319b0bff1bdf22aa389a25398d1952c7a0b5f3d93162dd8"},
"ip": {:hex, :ip, "2.0.3", "290d71c05b79ad62c99d8fe175e86130dc120489d119b8c2819cec16bad3c77c", [:mix], [], "hexpm", "19fa2f9c6f5cb288ca2192499888bd96f88af3564eaa7bbcfc1231ffdc5df8c2"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"},
"makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"},
"makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"},
"mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"},
"mimic": {:hex, :mimic, "1.10.0", "58ee13aa46addcadbb033ce311bb5ed8b0a825c2dec6e1d55ca767138a6374c8", [:mix], [{:ham, "~> 0.2", [hex: :ham, repo: "hexpm", optional: false]}], "hexpm", "ea639e48f6a5bd043218297b80c3a52e227541aafa3dc8a299cc0c01991523a5"},
"mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"},
"mint_web_socket": {:hex, :mint_web_socket, "1.0.4", "0b539116dbb3d3f861cdf5e15e269a933cb501c113a14db7001a3157d96ffafd", [:mix], [{:mint, ">= 1.4.1 and < 2.0.0-0", [hex: :mint, repo: "hexpm", optional: false]}], "hexpm", "027d4c5529c45a4ba0ce27a01c0f35f284a5468519c045ca15f43decb360a991"},
"mix_audit": {:hex, :mix_audit, "2.1.4", "0a23d5b07350cdd69001c13882a4f5fb9f90fbd4cbf2ebc190a2ee0d187ea3e9", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "fd807653cc8c1cada2911129c7eb9e985e3cc76ebf26f4dd628bb25bbcaa7099"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"},
"owl": {:hex, :owl, "0.11.0", "2cd46185d330aa2400f1c8c3cddf8d2ff6320baeff23321d1810e58127082cae", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "73f5783f0e963cc04a061be717a0dbb3e49ae0c4bfd55fb4b78ece8d33a65efe"},
"plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"},
"plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"},
"rewrite": {:hex, :rewrite, "0.10.5", "6afadeae0b9d843b27ac6225e88e165884875e0aed333ef4ad3bf36f9c101bed", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "51cc347a4269ad3a1e7a2c4122dbac9198302b082f5615964358b4635ebf3d4f"},
"sourceror": {:hex, :sourceror, "1.6.0", "9907884e1449a4bd7dbaabe95088ed4d9a09c3c791fb0103964e6316bc9448a7", [:mix], [], "hexpm", "e90aef8c82dacf32c89c8ef83d1416fc343cd3e5556773eeffd2c1e3f991f699"},
"spark": {:hex, :spark, "2.2.23", "78f0a1b0b713a91ad556fe9dc19ec92d977aaa0803cce2e255d90e58b9045c2a", [:mix], [{:igniter, ">= 0.2.6 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "a354b5cd7c3f021e3cd1da5a033b7643fe7b3c71c96b96d9f500a742f40c94db"},
"spitfire": {:hex, :spitfire, "0.1.3", "7ea0f544005dfbe48e615ed90250c9a271bfe126914012023fd5e4b6b82b7ec7", [:mix], [], "hexpm", "d53b5107bcff526a05c5bb54c95e77b36834550affd5830c9f58760e8c543657"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"thousand_island": {:hex, :thousand_island, "1.3.5", "6022b6338f1635b3d32406ff98d68b843ba73b3aa95cfc27154223244f3a6ca5", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2be6954916fdfe4756af3239fb6b6d75d0b8063b5df03ba76fd8a4c87849e180"},
"ucwidth": {:hex, :ucwidth, "0.2.0", "1f0a440f541d895dff142275b96355f7e91e15bca525d4a0cc788ea51f0e3441", [:mix], [], "hexpm", "c1efd1798b8eeb11fb2bec3cafa3dd9c0c3647bee020543f0340b996177355bf"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.7", "65fa74042530064ef0570b75b43f5c49bb8b235d6515671b3d250022cb8a1f9e", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "d0f478ee64deddfec64b800673fd6e0c8888b079d9f3444dd96d2a98383bdbd1"},
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
"yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"},
"yaml_elixir": {:hex, :yaml_elixir, "2.11.0", "9e9ccd134e861c66b84825a3542a1c22ba33f338d82c07282f4f1f52d847bd50", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "53cc28357ee7eb952344995787f4bb8cc3cecbf189652236e9b163e8ce1bc242"},
}

25
test/support/example.ex Normal file
View file

@ -0,0 +1,25 @@
defmodule Support.Example do
@moduledoc false
use Wayfarer
config "Example" do
listeners do
http "0.0.0.0", 8000
end
targets do
http "127.0.0.1", 4000
end
health_checks do
check do
interval :timer.seconds(5)
success_codes 200..399
end
end
host_patterns do
pattern "localhost"
end
end
end

View file

@ -0,0 +1,97 @@
defmodule Support.HttpRequest do
@moduledoc false
alias Mint.HTTP
import Wayfarer.Utils
defmacro __using__(_) do
quote do
import Support.HttpRequest
import IP.Sigil
end
end
@type headers :: [{String.t(), String.t()}]
@type options :: [
host: String.t(),
method: String.t(),
path: String.t(),
headers: headers,
body: iodata,
options: Keyword.t()
]
@doc """
Perform an HTTP request.
This is a little more annoying than most of the Elixir HTTP clients (Tesla,
Finch, etc) can handle easily because you need to make a potentially SNI SSL
request to an arbitrary address without resolving it via DNS.
"""
@spec request(
:http | :https,
:inet.ip_address() | String.t() | IP.Address.t(),
:inet.port_number(),
options
) ::
{:ok, %{status: nil | non_neg_integer(), headers: headers, body: iodata()}}
| {:error, any}
def request(scheme, address, port, options \\ []) do
host = Keyword.get(options, :host, "example.com")
method = Keyword.get(options, :method, "GET")
path = Keyword.get(options, :path, "/")
headers = Keyword.get(options, :headers, [])
body = Keyword.get(options, :body, [])
options =
options
|> Keyword.get(:options, [])
|> Keyword.put_new(:hostname, host)
Task.async(fn ->
with {:ok, address} <- sanitise_ip_address(address),
{:ok, mint} <- HTTP.connect(scheme, address, port, options),
{:ok, mint, req} <- HTTP.request(mint, method, path, headers, body) do
handle_response(mint, req, %{status: nil, headers: [], body: []})
end
end)
|> Task.await()
end
defp handle_response(mint, req, state) do
receive do
message ->
case HTTP.stream(mint, message) do
:unknown -> {:error, {:unknown_message, message}}
{:ok, mint, responses} -> handle_responses(responses, mint, req, state)
{:error, _, reason, _} -> {:error, reason}
end
end
end
defp handle_responses([], mint, req, state), do: handle_response(mint, req, state)
defp handle_responses([{:status, req, status} | responses], mint, req, state),
do: handle_responses(responses, mint, req, %{state | status: status})
defp handle_responses([{:headers, req, headers} | responses], mint, req, state),
do:
handle_responses(
responses,
mint,
req,
Map.update!(state, :headers, &Enum.concat(&1, headers))
)
defp handle_responses([{:data, req, body} | responses], mint, req, state),
do:
handle_responses(
responses,
mint,
req,
Map.update!(state, :body, &Enum.concat(&1, [body]))
)
defp handle_responses([{:done, req} | _], _mint, req, state), do: {:ok, state}
defp handle_responses([{:error, req, reason}], _mint, req, _state), do: {:error, reason}
end

View file

@ -0,0 +1,60 @@
defmodule Support.HttpServer do
@moduledoc """
A basic HTTP server which returns a canned response.
"""
@behaviour Plug
import Plug.Conn
@doc false
@spec start_link(:inet.port_number(), 100..599, String.t()) :: Supervisor.on_start()
def start_link(port, status, body, notify \\ false) do
notify =
case notify do
true -> self()
pid when is_pid(pid) -> pid
_ -> false
end
Bandit.start_link(
scheme: :http,
port: port,
ip: {127, 0, 0, 1},
plug: {__MODULE__, {status, body, notify}}
)
end
@doc false
@impl true
@spec init(any) :: any
def init({status, body, notify}), do: {status, body, notify}
@doc false
@impl true
@spec call(Plug.Conn.t(), any) :: Plug.Conn.t()
def call(conn, {status, body, notify}) do
if is_pid(notify) do
Process.send_after(notify, {:request, __MODULE__, conn}, 20)
end
conn
|> put_resp_content_type("text/plain")
|> send_resp(status, body)
end
@doc "Wait for the next HTTP request to be served"
@spec await_request :: Plug.Conn.t()
def await_request do
receive do
{:request, __MODULE__, conn} -> conn
end
end
@doc "Wait for a certain number of requests to have been served"
@spec await_requests(non_neg_integer()) :: [Plug.Conn.t()]
def await_requests(how_many \\ 1) when how_many > 0 and is_integer(how_many) do
Enum.map(1..how_many, fn _ ->
await_request()
end)
end
end

View file

@ -0,0 +1,61 @@
defmodule Support.PortTracker do
@moduledoc """
Generates port numbers which aren't currently being used by any processes.
"""
use GenServer
require Logger
defmacro __using__(_) do
quote do
defp random_port(opts \\ []) do
unquote(__MODULE__).allocate(opts)
end
end
end
@doc false
@spec start_link(any) :: GenServer.on_start()
def start_link(arg), do: GenServer.start_link(__MODULE__, arg, name: __MODULE__)
@doc false
@impl true
def init(_) do
table = :ets.new(__MODULE__, [:public, :named_table, :set])
{:ok, table}
end
@doc false
@impl true
def handle_cast({:monitor, pid}, table) do
Process.monitor(pid)
{:noreply, table}
end
@doc false
@impl true
def handle_info({:DOWN, _, :process, pid, _}, table) do
:ets.match_delete(table, {:_, pid})
{:noreply, table}
end
@doc """
Allocate an unused random port between `min_port` and `max_port`.
"""
def allocate(opts) do
port = random_port(opts)
if :ets.insert_new(__MODULE__, {port, self()}) do
GenServer.cast(__MODULE__, {:monitor, self()})
port
else
allocate(opts)
end
end
defp random_port(opts) do
max_port = Keyword.get(opts, :max_port, 0xFFFF)
min_port = Keyword.get(opts, :min_port, 20_000)
:rand.uniform(max_port - min_port) + min_port
end
end

View file

@ -1 +1,8 @@
{:ok, _} = Support.PortTracker.start_link([])
Mimic.copy(Mint.HTTP)
Mimic.copy(Wayfarer.Router)
Mimic.copy(Wayfarer.Target.ActiveConnections)
Mimic.copy(Wayfarer.Target.TotalConnections)
Mimic.copy(Wayfarer.Target.Selector)
ExUnit.start()

View file

@ -1,139 +1,54 @@
defmodule Wayfarer.ListenerTest do
@moduledoc false
use ExUnit.Case, async: false
alias Wayfarer.Listener
import ExUnit.CaptureLog
use Support.PortTracker
use Support.HttpRequest
alias Wayfarer.{Listener, Router}
import IP.Sigil
setup do
start_supervised!(Wayfarer.Listener.Supervisor)
start_supervised!(
{Finch,
name: :test_client,
pools: %{
default: [
conn_opts: [
transport_opts: [
verify: :verify_none
]
]
]
}}
)
{:ok, _table} = Router.init(Support.Example)
:ok
end
describe "start_listener/1" do
test "it returns an error when the scheme option is missing" do
assert {:error, {:required_option, :scheme}} = Listener.start_listener([])
end
test "it can start an HTTP listener" do
port = random_port()
test "it returns an error when an option is incorrect" do
assert {:error, _} = Listener.start_listener(scheme: "Marty McFly", port: random_port())
end
assert {:ok, _pid} =
Listener.start_link(
scheme: :http,
address: ~i"127.0.0.1",
port: port,
module: Support.Example
)
test "it can start an HTTP listener" do
port = random_port()
assert {:ok, _pid} =
Listener.start_listener(
scheme: :http,
ip: {127, 0, 0, 1},
port: port
)
assert {:ok, %{status: 502}} =
:get
|> Finch.build("http://127.0.0.1:#{port}/")
|> Finch.request(:test_client)
end
test "it can start an HTTPS listener" do
port = random_port()
certfile = Path.join(__DIR__, "../support/test.cert")
keyfile = Path.join(__DIR__, "../support/test.key")
assert {:ok, _pid} =
Listener.start_listener(
scheme: :https,
ip: {127, 0, 0, 1},
port: port,
certfile: certfile,
keyfile: keyfile
)
assert {:ok, %{status: 502}} =
:get
|> Finch.build("https://127.0.0.1:#{port}/")
|> Finch.request(:test_client)
end
test "it restarts listeners when they crash" do
port = random_port()
assert {:ok, _pid} =
Listener.start_listener(
scheme: :http,
ip: {127, 0, 0, 1},
port: port
)
# It's up
assert {:ok, %{status: 502}} =
:get
|> Finch.build("http://127.0.0.1:#{port}/")
|> Finch.request(:test_client)
# Crash it
capture_log(fn ->
[{_, pid}] = Registry.lookup(Listener.Registry, {{127, 0, 0, 1}, port})
Process.exit(pid, :kill)
end)
# It's up again
assert {:ok, %{status: 502}} =
:get
|> Finch.build("http://127.0.0.1:#{port}/")
|> Finch.request(:test_client)
end
assert {:ok, %{status: 502}} = request(:http, ~i"127.0.0.1", port)
end
describe "stop_listener/2" do
test "it can shut down a listener" do
port = random_port()
test "it can start an HTTPS listener" do
port = random_port()
assert {:ok, pid} =
Listener.start_listener(
scheme: :http,
ip: {127, 0, 0, 1},
port: port
)
certfile = Path.join(__DIR__, "../support/test.cert")
keyfile = Path.join(__DIR__, "../support/test.key")
assert {:ok, %{status: 502}} =
:get
|> Finch.build("http://127.0.0.1:#{port}/")
|> Finch.request(:test_client)
assert {:ok, _pid} =
Listener.start_link(
scheme: :https,
address: ~i"127.0.0.1",
port: port,
certfile: certfile,
keyfile: keyfile,
module: Support.Example,
cipher_suite: :compatible
)
Listener.stop_listener({127, 0, 0, 1}, port)
wait_until_dead(pid)
assert {:error, _} =
:get
|> Finch.build("http://127.0.0.1:#{port}/")
|> Finch.request(:test_client)
end
end
defp random_port, do: :rand.uniform(0xFFFF - 1000) + 1000
defp wait_until_dead(pid) do
if Process.alive?(pid) do
wait_until_dead(pid)
else
:ok
end
assert {:ok, %{status: 502}} =
request(:https, ~i"127.0.0.1", port,
host: "www.example.com",
options: [transport_opts: [verify: :verify_none]]
)
end
end

View file

@ -0,0 +1,280 @@
defmodule Wayfarer.RouterTest do
@moduledoc false
use ExUnit.Case, async: false
use Support.PortTracker
alias Wayfarer.Router
import IP.Sigil
describe "init/1" do
test "it creates a new ETS table" do
assert {:ok, table} = Router.init(Support.Example)
assert [] = :ets.tab2list(table)
end
end
describe "add_route/5" do
test "when the listener is not valid it returns an error" do
{:ok, table} = Router.init(Support.Example)
assert {:error, error} =
Router.add_route(
table,
:wat,
{:http, ~i"127.0.0.1", random_port()},
["example.com"],
:round_robin
)
assert Exception.message(error) =~ ~r/not a valid listener/i
end
test "when the target is not valid it returns an error" do
{:ok, table} = Router.init(Support.Example)
assert {:error, error} =
Router.add_route(
table,
{:http, ~i"127.0.0.1", random_port()},
:wat,
["example.com"],
:round_robin
)
assert Exception.message(error) =~ ~r/not a valid target/i
end
test "it converts wildcard hostnames into tuples" do
{:ok, table} = Router.init(Support.Example)
listener = {:http, {127, 0, 0, 1}, random_port()}
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
assert :ok = Router.add_route(table, listener, target, ["*.example.com"], :round_robin)
assert :ets.tab2list(table) == [
{listener, {:_, "example", "com"}, target, :round_robin, :initial}
]
end
test "it converts normal hostnames into tuples" do
{:ok, table} = Router.init(Support.Example)
listener = {:http, {127, 0, 0, 1}, random_port()}
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
assert :ok = Router.add_route(table, listener, target, ["www.example.com"], :round_robin)
assert :ets.tab2list(table) == [
{listener, {"www", "example", "com"}, target, :round_robin, :initial}
]
end
test "it converts multiple hostnames into multiple routes" do
{:ok, table} = Router.init(Support.Example)
listener = {:http, {127, 0, 0, 1}, random_port()}
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
assert :ok =
Router.add_route(
table,
listener,
target,
["example.com", "*.example.com"],
:round_robin
)
all_routes =
table
|> :ets.tab2list()
|> Enum.sort()
assert all_routes == [
{listener, {"example", "com"}, target, :round_robin, :initial},
{listener, {:_, "example", "com"}, target, :round_robin, :initial}
]
end
test "when not a valid algorithm it returns an error" do
{:ok, table} = Router.init(Support.Example)
listener = {:http, {127, 0, 0, 1}, random_port()}
target = {:http, {127, 0, 0, 1}, random_port()}
assert {:error, error} =
Router.add_route(table, listener, target, ["www.example.com"], :marty)
assert Exception.message(error) =~ ~r/not a valid load balancing algorithm/i
end
end
describe "import_routes/2" do
test "it inserts multiple routes into the routing table" do
{:ok, table} = Router.init(Support.Example)
listener0 = {:http, {127, 0, 0, 1}, random_port()}
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
listener1 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
assert :ok =
Router.import_routes(table, [
{listener0, target0, ["0.example.com"], :round_robin},
{listener1, target1, ["1.example.com"], :random}
])
all_routes =
table
|> :ets.tab2list()
|> Enum.sort()
assert all_routes ==
Enum.sort([
{listener0, {"0", "example", "com"}, target0, :round_robin, :initial},
{listener1, {"1", "example", "com"}, target1, :random, :initial}
])
end
end
describe "remove_listener/2" do
test "removes any matching routes from the table" do
{:ok, table} = Router.init(Support.Example)
listener0 = {:http, {127, 0, 0, 1}, random_port()}
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
listener1 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
Router.import_routes(table, [
{listener0, target0, ["0.example.com"], :round_robin},
{listener1, target1, ["1.example.com"], :random}
])
Router.remove_listener(table, listener0)
assert :ets.tab2list(table) == [
{listener1, {"1", "example", "com"}, target1, :random, :initial}
]
end
end
describe "remove_target/2" do
test "removes any matching routes from the table" do
{:ok, table} = Router.init(Support.Example)
listener0 = {:http, {127, 0, 0, 1}, random_port()}
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
listener1 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
Router.import_routes(table, [
{listener0, target0, ["0.example.com"], :round_robin},
{listener1, target1, ["1.example.com"], :random}
])
Router.remove_target(table, target0)
assert :ets.tab2list(table) == [
{listener1, {"1", "example", "com"}, target1, :random, :initial}
]
end
end
describe "update_target_health_status/3" do
test "updates all routes to the specified target with the new health status" do
{:ok, table} = Router.init(Support.Example)
listener0 = {:http, {127, 0, 0, 1}, random_port()}
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
listener1 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
Router.import_routes(table, [
{listener0, target0, ["0.example.com"], :round_robin},
{listener1, target1, ["1.example.com"], :random}
])
assert :ok = Router.update_target_health_status(table, target1, :healthy)
all_routes =
table
|> :ets.tab2list()
|> Enum.sort()
assert all_routes ==
Enum.sort([
{listener0, {"0", "example", "com"}, target0, :round_robin, :initial},
{listener1, {"1", "example", "com"}, target1, :random, :healthy}
])
end
end
describe "find_healthy_targets/3" do
setup do
{:ok, table} = Router.init(Support.Example)
listener = {:http, {127, 0, 0, 1}, random_port()}
target0 = {:http, {127, 0, 0, 1}, random_port(), :auto}
target1 = {:http, {127, 0, 0, 1}, random_port(), :auto}
:ok =
Router.import_routes(table, [
{listener, target0, ["*.example.com"], :random},
{listener, target1, ["example.com"], :random}
])
:ok = Router.update_target_health_status(table, target0, :healthy)
:ok = Router.update_target_health_status(table, target1, :healthy)
{:ok, table: table, listener: listener, target0: target0, target1: target1}
end
test "when the request matches a wildcard route it returns the target", %{
table: table,
listener: listener,
target0: target0
} do
assert {:ok, [{^target0, :random}]} =
Router.find_healthy_targets(table, listener, "www.example.com")
end
test "when the request matches a specific route it returns the target", %{
table: table,
listener: listener,
target1: target1
} do
assert {:ok, [{^target1, :random}]} =
Router.find_healthy_targets(table, listener, "example.com")
end
test "when the request matches a multiple targets it returns them both", %{
table: table,
listener: listener,
target0: target0,
target1: target1
} do
:ok =
Router.import_routes(table, [
{listener, target1, ["www.example.com"], :random}
])
assert {:ok, targets} = Router.find_healthy_targets(table, listener, "www.example.com")
assert Enum.sort(targets) == Enum.sort([{target0, :random}, {target1, :random}])
end
test "when the request matches the same target multiple times it returns it once", %{
table: table,
listener: listener,
target0: target0
} do
:ok =
Router.import_routes(table, [
{listener, target0, ["www.example.com"], :random}
])
assert {:ok, [{^target0, :random}]} =
Router.find_healthy_targets(table, listener, "www.example.com")
end
end
end

View file

@ -0,0 +1,102 @@
defmodule Wayfarer.Server.PlugTest do
@moduledoc false
use ExUnit.Case, async: false
use Mimic
use Plug.Test
use Support.PortTracker
alias Support.HttpServer
alias Wayfarer.Router
alias Wayfarer.Server.Plug, as: SUT
alias Wayfarer.Target.Selector
setup do
{:ok, table} = Router.init(Support.Example)
start_supervised!(Wayfarer.Server.Supervisor)
start_supervised!(Wayfarer.Target.Supervisor)
{:ok, table: table}
end
describe "init/1" do
test "it mappifies it's argument" do
assert %{a: 1} == SUT.init(a: 1)
end
end
describe "call/2" do
test "it results in a bad gateway when the config map contains a wayfarer module" do
conn =
:gen
|> conn("/")
|> SUT.call(%{})
assert conn.status == 502
end
test "it stores the listener config in the conn private" do
port = random_port()
conn =
:get
|> conn("/")
|> SUT.call(%{module: Support.Example, scheme: :http, address: {127, 0, 0, 1}, port: port})
assert conn.private.wayfarer.listener == %{
module: Support.Example,
port: port,
scheme: :http,
address: {127, 0, 0, 1}
}
end
test "it looks for healthy targets in the router" do
listener = {:http, {127, 0, 0, 1}, random_port()}
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
{:ok, _} = HttpServer.start_link(elem(target, 2), 200, "OK")
Router
|> expect(:find_healthy_targets, fn module, ^listener, host ->
assert module == Support.Example
assert host == "www.example.com"
{:ok, [{target, :random}]}
end)
:get
|> conn("/")
|> SUT.call(%{
module: Support.Example,
scheme: elem(listener, 0),
address: elem(listener, 1),
port: elem(listener, 2)
})
end
test "it selects a target to proxy to" do
listener = {:http, {127, 0, 0, 1}, random_port()}
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
{:ok, _} = HttpServer.start_link(elem(target, 2), 200, "OK")
Router
|> stub(:find_healthy_targets, fn _, _, _ -> {:ok, [{target, :random}]} end)
Selector
|> expect(:choose, fn _conn, targets, :random ->
assert targets == [target]
{:ok, target}
end)
:get
|> conn("/")
|> SUT.call(%{
module: Support.Example,
scheme: elem(listener, 0),
address: elem(listener, 1),
port: elem(listener, 2)
})
end
end
end

View file

@ -0,0 +1,235 @@
defmodule Wayfarer.Server.ProxyTest do
@moduledoc false
use ExUnit.Case, async: false
alias Mint.HTTP
alias Wayfarer.Server.Proxy
alias Wayfarer.Target.ActiveConnections
use Mimic
use Plug.Test
use Support.PortTracker
import IP.Sigil
setup do
start_supervised!(Wayfarer.Target.Supervisor)
listener = {:http, {127, 0, 0, 1}, random_port()}
peer_data = %{address: {192, 0, 2, 2}, port: random_port(), ssl_cert: nil}
conn =
:get
|> conn("/")
|> put_private(:wayfarer, %{
listener: %{
scheme: elem(listener, 0),
address: elem(listener, 1),
port: elem(listener, 2)
}
})
|> put_peer_data(peer_data)
|> put_req_header("accept", "*/*")
{:ok, listener: listener, conn: conn, peer: peer_data}
end
defp stub_request(HTTP, responses \\ [:done]) do
req = make_ref()
responses =
responses
|> Enum.map(fn
:done -> {:done, req}
{:status, status} -> {:status, req, status}
{:headers, headers} -> {:headers, req, headers}
{:data, chunk} -> {:data, req, chunk}
{:error, reason} -> {:error, req, reason}
end)
HTTP
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|> stub(:stream_request_body, fn mint, _, _ -> {:ok, mint} end)
|> stub(:request, fn mint, _, _, _, _ ->
send(self(), :ignore)
{:ok, mint, req}
end)
|> stub(:stream, fn mint, :ignore -> {:ok, mint, responses} end)
|> stub(:stream, fn mint, _ -> {:ok, mint, [{:status, req, 200}, {:done, req}]} end)
end
describe "request/2" do
test "it opens an HTTP connection to the target and sends the request", %{conn: conn} do
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
req = make_ref()
HTTP
|> expect(:connect, fn scheme, address, port, options ->
assert scheme == elem(target, 0)
assert address == elem(target, 1)
assert port == elem(target, 2)
assert options[:hostname] == "www.example.com"
assert options[:timeout] == 5000
{:ok, :fake_conn}
end)
|> expect(:stream_request_body, fn mint, _, _ -> {:ok, mint} end)
|> expect(:request, fn mint, _, _, _, _ ->
send(self(), :ignore)
{:ok, mint, req}
end)
|> expect(:stream, fn mint, :ignore ->
{:ok, mint, [{:status, req, 200}, {:done, req}]}
end)
assert conn = Proxy.request(conn, target)
assert conn.halted
end
test "it records the outgoing connection", %{conn: conn} do
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
ActiveConnections
|> expect(:connect, fn ^target -> :ok end)
HTTP
|> stub_request()
assert conn = Proxy.request(conn, target)
assert conn.halted
end
test "when there is a connection error, it returns a bad gateway error", %{conn: conn} do
HTTP
|> stub(:connect, fn _, _, _, _ ->
{:error, %Mint.TransportError{reason: :protocol_not_negotiated}}
end)
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
assert conn = Proxy.request(conn, target)
assert conn.status == 502
end
test "when there is a timeout during connection, it returns a gateway timeout error", %{
conn: conn
} do
HTTP
|> stub(:connect, fn _, _, _, _ ->
{:error, %Mint.TransportError{reason: :timeout}}
end)
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
assert conn = Proxy.request(conn, target)
assert conn.status == 504
end
test "it injects the correct `forwarded` header", %{
conn: conn,
peer: peer,
listener: listener
} do
HTTP
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|> stub(:request, fn _, _, _, headers, _ ->
[forwarded] =
headers
|> parse_forwarded_headers()
assert forwarded[:by] == "#{:inet.ntoa(elem(listener, 1))}:#{elem(listener, 2)}"
assert forwarded[:for] == "#{:inet.ntoa(peer.address)}:#{peer.port}"
assert forwarded[:host] == "www.example.com"
assert forwarded[:proto] == "http"
{:error, :ignore}
end)
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
Proxy.request(conn, target)
end
test "when the listener and peer are IPv6 address, it injects the correct `forwarded` header",
%{conn: conn, peer: peer, listener: listener} do
peer = %{peer | address: IP.Address.to_tuple(~i"2001:db8::1")}
listener = put_elem(listener, 1, IP.Address.to_tuple(~i"2001:db8::2"))
conn =
conn
|> put_peer_data(peer)
|> put_private(:wayfarer, %{
listener: %{
scheme: elem(listener, 0),
address: elem(listener, 1),
port: elem(listener, 2)
}
})
HTTP
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|> stub(:request, fn _, _, _, headers, _ ->
[forwarded] =
headers
|> parse_forwarded_headers()
assert forwarded[:by] == "[#{:inet.ntoa(elem(listener, 1))}]:#{elem(listener, 2)}"
assert forwarded[:for] == "[#{:inet.ntoa(peer.address)}]:#{peer.port}"
assert forwarded[:host] == "www.example.com"
assert forwarded[:proto] == "http"
{:error, :ignore}
end)
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
Proxy.request(conn, target)
end
test "when the request has already been forwarded by another proxy, it still injects the correct `forwarded` header",
%{conn: conn, peer: peer, listener: listener} do
origin_header =
"by=[2001:db8::1]:#{random_port()};for=[2001:db8::2]:#{random_port()};host=test.example.com;proto=https"
conn = put_req_header(conn, "forwarded", origin_header)
[origin_header] = parse_forwarded_headers([{"forwarded", origin_header}])
HTTP
|> stub(:connect, fn _, _, _, _ -> {:ok, :fake_conn} end)
|> stub(:request, fn _, _, _, headers, _ ->
[forwarded, origin] =
headers
|> parse_forwarded_headers()
assert forwarded[:by] == "#{:inet.ntoa(elem(listener, 1))}:#{elem(listener, 2)}"
assert forwarded[:for] == "#{:inet.ntoa(peer.address)}:#{peer.port}"
assert forwarded[:host] == "www.example.com"
assert forwarded[:proto] == "http"
assert origin == origin_header
{:error, :ignore}
end)
target = {:http, {127, 0, 0, 1}, random_port(), :auto}
Proxy.request(conn, target)
end
end
defp parse_forwarded_headers(headers) do
headers
|> Enum.filter(&(elem(&1, 0) == "forwarded"))
|> Enum.map(fn {_, header} ->
header
|> String.split(";")
|> Enum.map(&parse_forwarded_header/1)
end)
end
defp parse_forwarded_header(header) do
header
|> String.split("=")
|> case do
["by", f_by] -> {:by, f_by}
["for", f_for] -> {:for, f_for}
["host", host] -> {:host, host}
["proto", proto] -> {:proto, proto}
end
end
end

View file

@ -0,0 +1,70 @@
defmodule Wayfarer.ServerTest do
@moduledoc false
use ExUnit.Case, async: false
use Support.PortTracker
use Support.HttpRequest
import IP.Sigil
setup do
start_supervised!(Wayfarer.Listener.Supervisor)
start_supervised!(Wayfarer.Target.Supervisor)
start_supervised!(Wayfarer.Server.Supervisor)
:ok
end
describe "init/1" do
test "it requires a module option which implements the `Wayfarer.Server` behaviour" do
assert {:stop, reason} = Wayfarer.Server.init(module: URI)
assert reason =~ ~r/does not implement/
end
test "a list of listeners can be passed as options and they are started" do
port = random_port()
assert {:ok, _state} =
Wayfarer.Server.init(
module: Support.Example,
listeners: [[address: ~i"127.0.0.1", port: port, scheme: :http]]
)
assert {:ok, %{status: 502}} = request(:http, ~i"127.0.0.1", port)
end
test "a list of targets can be passed as options and they are started" do
port = random_port()
assert {:ok, _state} =
Wayfarer.Server.init(
module: Support.Example,
targets: [[address: ~i"127.0.0.1", port: port, scheme: :http]]
)
assert {:ok, :initial} =
Wayfarer.Target.current_status({Support.Example, :http, ~i"127.0.0.1", port})
end
test "an initial routing table can be passed as options" do
listen_port = random_port()
target_port = random_port()
assert {:ok, _state} =
Wayfarer.Server.init(
module: Support.Example,
routing_table: [
{
{:http, ~i"127.0.0.1", listen_port},
{:http, ~i"127.0.0.1", target_port, :auto},
["example.com"],
:round_robin
}
]
)
assert :ets.tab2list(Support.Example) == [
{{:http, {127, 0, 0, 1}, listen_port}, {"example", "com"},
{:http, {127, 0, 0, 1}, target_port, :auto}, :round_robin, :initial}
]
end
end
end

View file

@ -0,0 +1,114 @@
defmodule Wayfarer.Target.SelectorTest do
@moduledoc false
use ExUnit.Case, async: false
use Plug.Test
use Mimic
use Support.PortTracker
alias Wayfarer.Target.{ActiveConnections, Selector, TotalConnections}
@algorithms ~w[least_connections random round_robin sticky]a
describe "choose/3" do
test "when there are no targets to choose from it returns an error regardless of algorithm" do
conn = conn(:get, "/")
for algorithm <- @algorithms do
assert :error = Selector.choose(conn, [], algorithm)
end
end
test "when there is only one target to choose it is used regardless of algorithm" do
conn = conn(:get, "/")
target = {:http, {127, 0, 0, 1}, random_port()}
for algorithm <- @algorithms do
assert {:ok, ^target} = Selector.choose(conn, [target], algorithm)
end
end
test "sticky targets route repeated requests from the same client to the same target" do
paths = ["/", "/sign-in", "/dashboard"]
target0 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port()}
wayfarer = %{
listener: %{
address: {127, 0, 0, 1},
port: random_port(),
remote_ip: {192, 0, 2, 1}
}
}
peer_data = %{address: {192, 0, 2, 2}, port: random_port(), ssl_cert: nil}
assert {:http, {127, 0, 0, 1}, _} =
paths
|> Enum.reduce(nil, fn path, last_target ->
conn =
conn(:get, path)
|> put_private(:wayfarer, wayfarer)
|> put_peer_data(peer_data)
assert {:ok, target} = Selector.choose(conn, [target0, target1], :sticky)
if is_nil(last_target) do
target
else
assert target == last_target
last_target
end
end)
end
test "random targets route requests to random targets" do
paths = ["/", "/sign-in", "/dashboard"]
target0 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port()}
for path <- paths do
conn = conn(:get, path)
assert {:ok, target} = Selector.choose(conn, [target0, target1], :random)
assert target in [target0, target1]
end
end
test "least_connections targets route requests to the target with the least connections" do
paths = ["/", "/sign-in", "/dashboard"]
target0 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port()}
ActiveConnections
|> stub(:request_count, fn _targets ->
%{target0 => 37, target1 => 3}
end)
for path <- paths do
conn = conn(:get, path)
assert {:ok, ^target1} = Selector.choose(conn, [target0, target1], :least_connections)
end
end
test "round_robin targets route requests to the target with lowest total connections" do
paths = ["/", "/sign-in", "/dashboard"]
target0 = {:http, {127, 0, 0, 1}, random_port()}
target1 = {:http, {127, 0, 0, 1}, random_port()}
TotalConnections
|> stub(:proxy_count, fn _targets ->
%{target0 => 37, target1 => 3}
end)
for path <- paths do
conn = conn(:get, path)
assert {:ok, ^target1} = Selector.choose(conn, [target0, target1], :round_robin)
end
end
end
end

View file

@ -0,0 +1,73 @@
defmodule Wayfarer.TargetTest do
@moduledoc false
use ExUnit.Case, async: false
use Support.PortTracker
alias Support.HttpServer
alias Wayfarer.Target
import IP.Sigil
setup do
start_supervised!(Target.Supervisor)
:ok
end
describe "start_link/1" do
test "it can start a target checker" do
port = random_port()
assert {:ok, pid} =
Target.start_link(
scheme: :http,
address: ~i"127.0.0.1",
port: port,
module: Support.Example
)
assert {:ok, :initial} = Target.current_status(pid)
end
test "it becomes healthy when the target is okay" do
port = random_port()
HttpServer.start_link(port, 200, "OK", self())
assert {:ok, pid} =
Target.start_link(
scheme: :http,
address: ~i"127.0.0.1",
port: port,
module: Support.Example,
health_checks: [[interval: 10, threshold: 3]]
)
assert {:ok, :initial} = Target.current_status(pid)
HttpServer.await_requests(3)
assert {:ok, :healthy} = Target.current_status(pid)
end
end
test "it becomes unhealthy when the target is not okay" do
port = random_port()
HttpServer.start_link(port, 500, "ISE", self())
assert {:ok, pid} =
Target.start_link(
scheme: :http,
address: ~i"127.0.0.1",
port: port,
module: Support.Example,
health_checks: [[interval: 10, threshold: 3]]
)
assert {:ok, :initial} = Target.current_status(pid)
HttpServer.await_requests(2)
assert {:ok, :unhealthy} = Target.current_status(pid)
end
end

View file

@ -0,0 +1,67 @@
defmodule Wayfarer.UtilsTest do
@moduledoc false
use ExUnit.Case, async: true
use Support.PortTracker
import IP.Sigil
import Wayfarer.Utils
describe "sanitise_ip_address/1" do
test "when passed a valid IPv6 string it returns the parsed tuple" do
assert {:ok, {8193, 3512, 0, 0, 0, 0, 0, 1}} = sanitise_ip_address("2001:db8::1")
end
test "when passed a valid IPv4 string it returns a parsed tuple" do
assert {:ok, {192, 0, 2, 1}} = sanitise_ip_address("192.0.2.1")
end
test "when passed an IP address struct it returns a tuple" do
assert {:ok, {8193, 3512, 0, 0, 0, 0, 0, 1}} = sanitise_ip_address(~i"2001:db8::1")
end
test "when passed a valid IPv6 tuple it returns it" do
assert {:ok, {8193, 3512, 0, 0, 0, 0, 0, 1}} =
sanitise_ip_address({8193, 3512, 0, 0, 0, 0, 0, 1})
end
test "when passed a valid IPv4 tuple it returns it" do
assert {:ok, {192, 0, 2, 1}} = sanitise_ip_address({192, 0, 2, 1})
end
test "when passed an invalid IPv4 tuple, it returns an error" do
assert {:error, "Invalid address"} = sanitise_ip_address({192, 0, 2, 257})
end
test "when passed an invalid IPv6 tuple, it returns an error" do
assert {:error, "Invalid address"} =
sanitise_ip_address({8193, 3512, 0, 0, 0, 0, 0, 0xFFFF1})
end
end
describe "sanitise_scheme/1" do
test "when passed `:http` it is ok" do
assert {:ok, :http} = sanitise_scheme(:http)
end
test "when passed `:https` it is ok" do
assert {:ok, :https} = sanitise_scheme(:https)
end
test "when passed an unsupported scheme it returns an error" do
assert {:error, _} = sanitise_scheme(:spdy)
end
end
describe "to_uri/3" do
test "when given an IPv6 address it correctly encodes the URI" do
port = random_port()
assert {:ok, uri} = to_uri(:http, ~i"2001:db8::1", port)
assert "http://[2001:db8::1]:#{port}" == to_string(uri)
end
test "when given an IPv4 address it correctly encodes the URI" do
port = random_port()
assert {:ok, uri} = to_uri(:http, ~i"192.0.2.1", port)
assert "http://192.0.2.1:#{port}" == to_string(uri)
end
end
end

View file

@ -1,5 +1,109 @@
defmodule WayfarerTest do
@moduledoc false
use ExUnit.Case
use ExUnit.Case, async: false
doctest Wayfarer
alias Support.HttpServer
alias Wayfarer.Target.TotalConnections
use Support.HttpRequest
use Support.PortTracker
import IP.Sigil
setup do
start_supervised!(Wayfarer.Target.Supervisor)
start_supervised!(Wayfarer.Listener.Supervisor)
start_supervised!(Wayfarer.Server.Supervisor)
:ok
end
defmodule IntegrationProxy do
@moduledoc false
use Wayfarer.Server
end
describe "integration tests" do
test "round robin integration test" do
listener_port = random_port()
target0_port = random_port()
target1_port = random_port()
{:ok, _} = HttpServer.start_link(target0_port, 200, "OK", true)
{:ok, _} = HttpServer.start_link(target1_port, 200, "OK", true)
{:ok, _proxy} =
IntegrationProxy.start_link(
listeners: [[scheme: :http, address: ~i"127.0.0.1", port: listener_port]],
targets: [
[
scheme: :http,
address: ~i"127.0.0.1",
port: target0_port,
health_checks: [[interval: 100]]
],
[
scheme: :http,
address: ~i"127.0.0.1",
port: target1_port,
health_checks: [[interval: 100]]
]
],
routing_table: [
{{:http, ~i"127.0.0.1", listener_port}, {:http, ~i"127.0.0.1", target0_port, :auto},
["www.example.com"], :round_robin},
{{:http, ~i"127.0.0.1", listener_port}, {:http, ~i"127.0.0.1", target1_port, :auto},
["www.example.com"], :round_robin}
]
)
wait_for_target_state({IntegrationProxy, :http, ~i"127.0.0.1", target0_port}, :healthy)
wait_for_target_state({IntegrationProxy, :http, ~i"127.0.0.1", target1_port}, :healthy)
assert {:ok, %{status: 200}} =
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
assert {:ok, %{status: 200}} =
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
assert [1, 1] =
[
{:http, {127, 0, 0, 1}, target0_port, :auto},
{:http, {127, 0, 0, 1}, target1_port, :auto}
]
|> TotalConnections.proxy_count()
|> Enum.map(&elem(&1, 1))
for _ <- 1..10 do
assert {:ok, %{status: 200}} =
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
assert {:ok, %{status: 200}} =
request(:http, ~i"127.0.0.1", listener_port, host: "www.example.com")
end
assert [11, 11] =
[
{:http, {127, 0, 0, 1}, target0_port, :auto},
{:http, {127, 0, 0, 1}, target1_port, :auto}
]
|> TotalConnections.proxy_count()
|> Enum.map(&elem(&1, 1))
end
end
defp wait_for_target_state(key, state) do
case Wayfarer.Target.current_status(key) do
{:ok, ^state} ->
:ok
{:ok, _} ->
Process.sleep(100)
wait_for_target_state(key, state)
{:error, reason} ->
raise reason
end
end
end