@hummingbirdworks/proxy 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -68
- package/bin/hummingbird-proxy.js +1 -1
- package/hummingbird_proxy/__init__.py +10 -0
- package/hummingbird_proxy/addon.py +99 -0
- package/hummingbird_proxy/config.py +142 -0
- package/hummingbird_proxy/matching.py +55 -0
- package/hummingbird_proxy/serving.py +46 -0
- package/hummingbird_proxy/tls.py +52 -0
- package/package.json +2 -1
- package/powerapp_dev_proxy.py +8 -375
- package/proxy.config.example.toml +7 -0
package/README.md
CHANGED
|
@@ -2,43 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
A [mitmproxy](https://www.mitmproxy.org/)-based dev proxy that redirects
|
|
4
4
|
Dataverse / Dynamics 365 **web resources** and **PCF control** assets to your
|
|
5
|
-
local dev builds or a running dev server (e.g. Vite)
|
|
6
|
-
|
|
7
|
-
every change.
|
|
8
|
-
|
|
9
|
-
## How it works
|
|
10
|
-
|
|
11
|
-
The package ships a Python mitmproxy addon plus a small Node CLI
|
|
12
|
-
(`hummingbird-proxy`) that launches `mitmdump` with the addon and points it at
|
|
13
|
-
your config file. Redirect rules live in a `proxy.config.toml` file that you
|
|
14
|
-
check into your own repo. **All relative paths in the config are resolved
|
|
15
|
-
relative to the config file**, so the config is portable across machines.
|
|
5
|
+
local dev builds or a running dev server (e.g. Vite), so you can iterate
|
|
6
|
+
locally without deploying on every change.
|
|
16
7
|
|
|
17
8
|
## Prerequisites
|
|
18
9
|
|
|
19
|
-
- **Node.js** >= 16
|
|
20
|
-
- **mitmproxy**
|
|
21
|
-
(Python 3.11+, for stdlib TOML support).
|
|
22
|
-
Install from <https://www.mitmproxy.org/> — e.g. `pipx install mitmproxy`.
|
|
10
|
+
- **Node.js** >= 16.
|
|
11
|
+
- **mitmproxy** — install from <https://www.mitmproxy.org/> — e.g. `pipx install mitmproxy`.
|
|
23
12
|
|
|
24
|
-
## Install
|
|
13
|
+
## Install and Configure
|
|
25
14
|
|
|
26
15
|
```sh
|
|
27
16
|
npm install -D @hummingbirdworks/proxy
|
|
28
|
-
```
|
|
29
|
-
|
|
30
|
-
## Configure
|
|
31
|
-
|
|
32
|
-
Create a config in your repo with the `init` command:
|
|
33
|
-
|
|
34
|
-
```sh
|
|
35
17
|
npx hummingbird-proxy init
|
|
36
18
|
```
|
|
37
19
|
|
|
38
|
-
This
|
|
39
|
-
script (`"proxy": "hummingbird-proxy"`) to your `package.json`.
|
|
40
|
-
to overwrite an existing config/script. (You can also copy the example manually
|
|
41
|
-
from `node_modules/@hummingbirdworks/proxy/proxy.config.example.toml`.)
|
|
20
|
+
This creates a proxy.config.toml in the current directory and adds a `proxy`
|
|
21
|
+
script (`"proxy": "hummingbird-proxy"`) to your `package.json`.
|
|
42
22
|
|
|
43
23
|
The config is a TOML file. It's a table with a `rules` array-of-tables; each
|
|
44
24
|
`[[rules]]` entry is one redirect:
|
|
@@ -76,25 +56,30 @@ Optional keys on any rule:
|
|
|
76
56
|
- `domain`: a host string, or an array of host strings. Omit for all hosts.
|
|
77
57
|
- `disabled`: `true` to skip the rule.
|
|
78
58
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
Run the proxy from the folder containing `proxy.config.toml`:
|
|
59
|
+
### Combining several projects with `include`
|
|
82
60
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
# or, without the added script:
|
|
86
|
-
npx hummingbird-proxy
|
|
87
|
-
```
|
|
61
|
+
To proxy several projects against the same environment from one running proxy,
|
|
62
|
+
`include` their configs instead of switching proxies as you navigate:
|
|
88
63
|
|
|
89
|
-
|
|
64
|
+
```toml
|
|
65
|
+
[[rules]]
|
|
66
|
+
type = "include"
|
|
67
|
+
path = "../invoice-editor/proxy.config.toml"
|
|
90
68
|
|
|
91
|
-
|
|
92
|
-
|
|
69
|
+
[[rules]]
|
|
70
|
+
type = "include"
|
|
71
|
+
# Use single quotes to support backslashes in absolute paths
|
|
72
|
+
path = 'C:\Users\me\repos\myproject\proxy.config.toml'
|
|
93
73
|
```
|
|
94
74
|
|
|
95
|
-
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
Run the proxy from the folder containing `proxy.config.toml` (or pass a config
|
|
78
|
+
path and any extra `mitmdump` args):
|
|
96
79
|
|
|
97
80
|
```sh
|
|
81
|
+
npm run proxy
|
|
82
|
+
# or a specific config, forwarding args to mitmdump (e.g. change the port)
|
|
98
83
|
npx hummingbird-proxy ./proxy.config.toml -p 8888
|
|
99
84
|
```
|
|
100
85
|
|
|
@@ -108,42 +93,29 @@ chrome.exe --proxy-server="http://localhost:8080"
|
|
|
108
93
|
|
|
109
94
|
### First-time setup
|
|
110
95
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
instructions for your OS/browser.
|
|
114
|
-
|
|
115
|
-
### Notes
|
|
96
|
+
Install the mitmproxy root certificate so HTTPS interception works: with the
|
|
97
|
+
proxied browser open, visit <http://mitm.it/> and follow the instructions.
|
|
116
98
|
|
|
117
|
-
|
|
118
|
-
close all Chrome/Edge windows before starting the proxy, or use a separate
|
|
119
|
-
profile for the proxied browser:
|
|
99
|
+
### ⚠️ **Notes**
|
|
120
100
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
open DevTools → Application → Service Workers and check "Bypass for network"
|
|
128
|
-
to disable the service worker cache.
|
|
101
|
+
- Chrome/Edge share one background process across windows. Close all
|
|
102
|
+
Chrome/Edge windows first, or use a separate profile:
|
|
103
|
+
`msedge.exe --user-data-dir="%LOCALAPPDATA%\mitmproxy-browser-profile" --proxy-server="http://localhost:8080"`
|
|
104
|
+
- Power Apps caches assets in the browser. If changes don't show, hard refresh
|
|
105
|
+
(`Ctrl+Shift+R`); you may also need to bypass the service worker cache
|
|
106
|
+
(DevTools → Application → Service Workers → "Bypass for network").
|
|
129
107
|
|
|
130
108
|
## Using with Vite (HMR)
|
|
131
109
|
|
|
132
110
|
A `devserver` rule routes a web resource's requests to a running Vite dev
|
|
133
|
-
server
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
the proxy relays requests (including the HMR websocket) to `localhost`. Because
|
|
137
|
-
the Dynamics page is HTTPS, the browser would block an insecure `ws://` HMR
|
|
138
|
-
socket as mixed content, so point the HMR client at `wss` and let the proxy
|
|
139
|
-
forward it to the HTTP dev server. No dev-server cert (or `vite-plugin-mkcert`)
|
|
140
|
-
is needed.
|
|
111
|
+
server for hot module reload on the Dynamics-hosted page. The dev server stays
|
|
112
|
+
on plain HTTP; the proxy relays everything (including the HMR websocket) to
|
|
113
|
+
`localhost`, so no dev-server cert is needed.
|
|
141
114
|
|
|
142
115
|
### Vite config
|
|
143
116
|
|
|
144
|
-
The package ships a Vite plugin that
|
|
145
|
-
|
|
146
|
-
off cache busting since powerapps already has cache busting when you publish customizations.
|
|
117
|
+
The package ships a Vite plugin that sets the base path, enables HMR through the
|
|
118
|
+
proxy, and disables cache busting (Power Apps already busts caches on publish).
|
|
147
119
|
|
|
148
120
|
```ts
|
|
149
121
|
import { defineConfig } from 'vite'
|
|
@@ -192,5 +164,4 @@ npm run proxy
|
|
|
192
164
|
|
|
193
165
|
Open the Dynamics page hosting the web resource. Edit → save → the page updates
|
|
194
166
|
without a manual refresh. If HMR doesn't trigger, confirm Vite's port matches
|
|
195
|
-
the rule `url
|
|
196
|
-
worker cache is bypassed (see Notes above).
|
|
167
|
+
the rule `url` and that the service worker cache is bypassed (see Notes above).
|
package/bin/hummingbird-proxy.js
CHANGED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Dev proxy addon package for Dataverse / Dynamics 365 web resources and PCF controls.
|
|
2
|
+
|
|
3
|
+
The mitmproxy entrypoint lives in ``powerapp_dev_proxy.py`` at the package root;
|
|
4
|
+
it imports :class:`DataverseProxy` from here. See README.md for usage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from hummingbird_proxy.addon import DataverseProxy
|
|
8
|
+
from hummingbird_proxy.config import config_path
|
|
9
|
+
|
|
10
|
+
__all__ = ["DataverseProxy", "config_path"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""The mitmproxy addon: routes matching requests to local files or dev servers."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
|
|
6
|
+
from mitmproxy import ctx, http, tls
|
|
7
|
+
|
|
8
|
+
from hummingbird_proxy import config, matching, serving
|
|
9
|
+
from hummingbird_proxy.tls import start_dev_server_tls
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DataverseProxy:
|
|
13
|
+
def __init__(self, config_path: str) -> None:
|
|
14
|
+
self.config_path = config_path
|
|
15
|
+
rules, self._config_paths = config.load_rules(config_path)
|
|
16
|
+
self.config = config.validate_config(rules)
|
|
17
|
+
self._config_mtimes = config.config_mtimes(self._config_paths)
|
|
18
|
+
self._watch_task: asyncio.Task | None = None
|
|
19
|
+
|
|
20
|
+
def running(self) -> None:
|
|
21
|
+
# Start watching the config files for changes once the event loop is up.
|
|
22
|
+
if self._watch_task is None:
|
|
23
|
+
self._watch_task = asyncio.ensure_future(self._watch_config())
|
|
24
|
+
|
|
25
|
+
def done(self) -> None:
|
|
26
|
+
if self._watch_task is not None:
|
|
27
|
+
self._watch_task.cancel()
|
|
28
|
+
self._watch_task = None
|
|
29
|
+
|
|
30
|
+
async def _watch_config(self) -> None:
|
|
31
|
+
while True:
|
|
32
|
+
await asyncio.sleep(config.CONFIG_POLL_INTERVAL)
|
|
33
|
+
mtimes = config.config_mtimes(self._config_paths)
|
|
34
|
+
if mtimes == self._config_mtimes:
|
|
35
|
+
continue
|
|
36
|
+
self._config_mtimes = mtimes
|
|
37
|
+
self._reload_config()
|
|
38
|
+
|
|
39
|
+
def _reload_config(self) -> None:
|
|
40
|
+
try:
|
|
41
|
+
rules, paths = config.load_rules(self.config_path)
|
|
42
|
+
new_config = config.validate_config(rules)
|
|
43
|
+
except (OSError, ValueError) as err:
|
|
44
|
+
ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
|
|
45
|
+
return
|
|
46
|
+
self.config = new_config
|
|
47
|
+
self._config_paths = paths
|
|
48
|
+
self._config_mtimes = config.config_mtimes(paths)
|
|
49
|
+
matching.clear_pcf_cache()
|
|
50
|
+
ctx.log.info(
|
|
51
|
+
f"Reloaded proxy config from {self.config_path} "
|
|
52
|
+
f"({len(new_config)} active rules across {len(paths)} files)"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def tls_start_server(self, data: tls.TlsData) -> None:
|
|
56
|
+
start_dev_server_tls(data)
|
|
57
|
+
|
|
58
|
+
def request(self, flow: http.HTTPFlow) -> None:
|
|
59
|
+
host = flow.request.pretty_host
|
|
60
|
+
web_resource = matching.web_resource_name(flow.request)
|
|
61
|
+
|
|
62
|
+
for item in self.config:
|
|
63
|
+
if not matching.domain_matches(item.get("domain"), host):
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
item_type = item["type"]
|
|
67
|
+
|
|
68
|
+
if item_type == "devserver":
|
|
69
|
+
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
70
|
+
serving.proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
elif item_type == "single":
|
|
74
|
+
if web_resource == item["name"]:
|
|
75
|
+
serving.serve_file(flow, item["file"], item_type, item["name"], item["_base_dir"])
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
elif item_type == "folder":
|
|
79
|
+
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
80
|
+
relative = web_resource[len(item["name"]):]
|
|
81
|
+
serving.serve_file(
|
|
82
|
+
flow,
|
|
83
|
+
os.path.join(item["folder"], *relative.split("/")),
|
|
84
|
+
item_type,
|
|
85
|
+
item["name"],
|
|
86
|
+
item["_base_dir"],
|
|
87
|
+
)
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
elif item_type == "pcf":
|
|
91
|
+
hit = matching.pcf_match(flow.request.path, item["name"])
|
|
92
|
+
if hit is not None:
|
|
93
|
+
relative, is_css = hit
|
|
94
|
+
parts = [item["folder"]]
|
|
95
|
+
if is_css:
|
|
96
|
+
parts.append("css")
|
|
97
|
+
parts.extend(segment for segment in relative.split("/") if segment)
|
|
98
|
+
serving.serve_file(flow, os.path.join(*parts), item_type, item["name"], item["_base_dir"])
|
|
99
|
+
return
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Loading, validation and change-watching of the TOML proxy config."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import tomllib
|
|
5
|
+
|
|
6
|
+
# type -> the set of keys an entry of that type must contain.
|
|
7
|
+
_CONFIG_SCHEMA: dict[str, set[str]] = {
|
|
8
|
+
"single": {"name", "file"},
|
|
9
|
+
"folder": {"name", "folder"},
|
|
10
|
+
"devserver": {"name", "url"},
|
|
11
|
+
"pcf": {"name", "folder"},
|
|
12
|
+
}
|
|
13
|
+
_OPTIONAL_KEYS = {"type", "domain", "disabled"}
|
|
14
|
+
|
|
15
|
+
# An "include" entry pulls in the rules from another config file. It is expanded
|
|
16
|
+
# away at load time, so it never reaches validate_config / the request handler.
|
|
17
|
+
_INCLUDE_KEYS = {"path"}
|
|
18
|
+
_INCLUDE_OPTIONAL_KEYS = {"type", "disabled"}
|
|
19
|
+
|
|
20
|
+
# Keys the loader attaches to each rule internally; not user-provided.
|
|
21
|
+
_INTERNAL_KEYS = {"_base_dir"}
|
|
22
|
+
|
|
23
|
+
# Safety net against pathological include nesting (cycles are caught separately).
|
|
24
|
+
_MAX_INCLUDE_DEPTH = 20
|
|
25
|
+
|
|
26
|
+
# How often (seconds) to poll the config files for changes.
|
|
27
|
+
CONFIG_POLL_INTERVAL = 1.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def config_path() -> str:
|
|
31
|
+
"""Absolute path of the TOML config file to load."""
|
|
32
|
+
override = os.environ.get("HUMMINGBIRD_PROXY_CONFIG")
|
|
33
|
+
if override:
|
|
34
|
+
return os.path.abspath(override)
|
|
35
|
+
return os.path.abspath(os.path.join(os.getcwd(), "proxy.config.toml"))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _load_config(path: str) -> list[dict]:
|
|
39
|
+
"""Load and parse the TOML config file into a list of rule dicts."""
|
|
40
|
+
try:
|
|
41
|
+
with open(path, "rb") as handle:
|
|
42
|
+
data = tomllib.load(handle)
|
|
43
|
+
except FileNotFoundError:
|
|
44
|
+
raise FileNotFoundError(
|
|
45
|
+
f"proxy config file not found: {path}. "
|
|
46
|
+
"Create a proxy.config.toml or set HUMMINGBIRD_PROXY_CONFIG."
|
|
47
|
+
)
|
|
48
|
+
except tomllib.TOMLDecodeError as err:
|
|
49
|
+
raise ValueError(f"invalid TOML in proxy config {path}: {err}")
|
|
50
|
+
rules = data.get("rules", [])
|
|
51
|
+
if not isinstance(rules, list):
|
|
52
|
+
raise ValueError("proxy config 'rules' must be an array of tables")
|
|
53
|
+
return rules
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_rules(path: str, _seen: frozenset[str] = frozenset(), _depth: int = 0) -> tuple[list[dict], set[str]]:
|
|
57
|
+
"""Load rules from a config file, expanding any "include" entries.
|
|
58
|
+
|
|
59
|
+
Each returned rule carries a "_base_dir" key giving the directory its
|
|
60
|
+
relative paths resolve against, so rules pulled in from another config keep
|
|
61
|
+
resolving against that config's own location. Returns
|
|
62
|
+
(rules, config_paths) where config_paths is every config file touched, used
|
|
63
|
+
to watch them all for changes.
|
|
64
|
+
"""
|
|
65
|
+
path = os.path.abspath(path)
|
|
66
|
+
if path in _seen:
|
|
67
|
+
raise ValueError(f"include cycle detected at config {path}")
|
|
68
|
+
if _depth > _MAX_INCLUDE_DEPTH:
|
|
69
|
+
raise ValueError(f"include nesting too deep (>{_MAX_INCLUDE_DEPTH}) at {path}")
|
|
70
|
+
_seen = _seen | {path}
|
|
71
|
+
base_dir = os.path.dirname(path)
|
|
72
|
+
rules: list[dict] = []
|
|
73
|
+
config_paths: set[str] = {path}
|
|
74
|
+
|
|
75
|
+
for index, item in enumerate(_load_config(path)):
|
|
76
|
+
if isinstance(item, dict) and item.get("type") == "include":
|
|
77
|
+
label = f"{path} include[{index}]"
|
|
78
|
+
missing = _INCLUDE_KEYS - item.keys()
|
|
79
|
+
if missing:
|
|
80
|
+
raise ValueError(f"{label}: missing keys {sorted(missing)}")
|
|
81
|
+
unknown = item.keys() - (_INCLUDE_KEYS | _INCLUDE_OPTIONAL_KEYS)
|
|
82
|
+
if unknown:
|
|
83
|
+
raise ValueError(f"{label}: unknown keys {sorted(unknown)}")
|
|
84
|
+
if item.get("disabled", False):
|
|
85
|
+
continue
|
|
86
|
+
target = item["path"]
|
|
87
|
+
if not os.path.isabs(target):
|
|
88
|
+
target = os.path.normpath(os.path.join(base_dir, target))
|
|
89
|
+
sub_rules, sub_paths = load_rules(target, _seen, _depth + 1)
|
|
90
|
+
rules.extend(sub_rules)
|
|
91
|
+
config_paths |= sub_paths
|
|
92
|
+
elif isinstance(item, dict):
|
|
93
|
+
rules.append({**item, "_base_dir": base_dir})
|
|
94
|
+
else:
|
|
95
|
+
rules.append(item)
|
|
96
|
+
|
|
97
|
+
return rules, config_paths
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def resolve_path(path: str, base_dir: str) -> str:
|
|
101
|
+
"""Convert a path to absolute, relative to its config file's directory."""
|
|
102
|
+
if os.path.isabs(path):
|
|
103
|
+
return path
|
|
104
|
+
return os.path.normpath(os.path.join(base_dir, path))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_config(config: list[dict]) -> list[dict]:
|
|
108
|
+
"""Validate CONFIG and return the entries that are not disabled."""
|
|
109
|
+
enabled: list[dict] = []
|
|
110
|
+
for index, item in enumerate(config):
|
|
111
|
+
label = f"CONFIG[{index}]"
|
|
112
|
+
if not isinstance(item, dict):
|
|
113
|
+
raise ValueError(f"{label}: expected a dict, got {type(item).__name__}")
|
|
114
|
+
item_type = item.get("type")
|
|
115
|
+
if item_type not in _CONFIG_SCHEMA:
|
|
116
|
+
raise ValueError(
|
|
117
|
+
f"{label}: unknown or missing 'type' {item_type!r}; "
|
|
118
|
+
f"expected one of {sorted(_CONFIG_SCHEMA)}"
|
|
119
|
+
)
|
|
120
|
+
required = _CONFIG_SCHEMA[item_type]
|
|
121
|
+
missing = required - item.keys()
|
|
122
|
+
if missing:
|
|
123
|
+
raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
|
|
124
|
+
unknown = item.keys() - (required | _OPTIONAL_KEYS | _INTERNAL_KEYS)
|
|
125
|
+
if unknown:
|
|
126
|
+
raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
|
|
127
|
+
if not item.get("disabled", False):
|
|
128
|
+
enabled.append(item)
|
|
129
|
+
return enabled
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _config_mtime(path: str) -> float | None:
|
|
133
|
+
"""Modification time of the config file, or None if it is missing."""
|
|
134
|
+
try:
|
|
135
|
+
return os.path.getmtime(path)
|
|
136
|
+
except OSError:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def config_mtimes(paths: set[str]) -> dict[str, float | None]:
|
|
141
|
+
"""Modification times for every config file, keyed by path."""
|
|
142
|
+
return {path: _config_mtime(path) for path in paths}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""URL matching helpers for web resource, PCF and domain rules."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from mitmproxy import http
|
|
6
|
+
|
|
7
|
+
Domain = str | list[str] | None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def domain_matches(domain: Domain, host: str) -> bool:
|
|
11
|
+
if domain is None:
|
|
12
|
+
return True
|
|
13
|
+
host = host.lower()
|
|
14
|
+
if isinstance(domain, str):
|
|
15
|
+
return host == domain.lower()
|
|
16
|
+
return any(host == d.lower() for d in domain)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def web_resource_name(request: http.Request) -> str | None:
|
|
20
|
+
"""Return the path following the ``webresources`` segment, or None."""
|
|
21
|
+
components = request.path_components
|
|
22
|
+
for i, component in enumerate(components):
|
|
23
|
+
if component.lower() == "webresources":
|
|
24
|
+
name = "/".join(components[i + 1:])
|
|
25
|
+
# path_components drops a trailing slash; keep it so dev-server root
|
|
26
|
+
# requests (e.g. the Vite HMR base URL) still match a rule prefix.
|
|
27
|
+
if name and request.path.split("?", 1)[0].endswith("/"):
|
|
28
|
+
name += "/"
|
|
29
|
+
return name
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_pcf_patterns: dict[str, re.Pattern[str]] = {}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def pcf_match(path: str, name: str) -> tuple[str, bool] | None:
|
|
37
|
+
"""Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
|
|
38
|
+
pattern = _pcf_patterns.get(name)
|
|
39
|
+
if pattern is None:
|
|
40
|
+
pattern = re.compile(
|
|
41
|
+
r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
|
|
42
|
+
+ re.escape(name)
|
|
43
|
+
+ r"(?:\.|/)(?P<rest>[^?]*)",
|
|
44
|
+
re.IGNORECASE,
|
|
45
|
+
)
|
|
46
|
+
_pcf_patterns[name] = pattern
|
|
47
|
+
match = pattern.search(path)
|
|
48
|
+
if match is None:
|
|
49
|
+
return None
|
|
50
|
+
return match.group("rest"), bool(match.group("css"))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def clear_pcf_cache() -> None:
|
|
54
|
+
"""Drop compiled PCF patterns; called when rules are reloaded."""
|
|
55
|
+
_pcf_patterns.clear()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Response builders: serve local files and relay to a dev server."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
|
|
7
|
+
from mitmproxy import ctx, http
|
|
8
|
+
|
|
9
|
+
from hummingbird_proxy.config import resolve_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
|
|
13
|
+
ctx.log.info(
|
|
14
|
+
f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str, base_dir: str) -> None:
|
|
19
|
+
absolute_path = resolve_path(filepath, base_dir)
|
|
20
|
+
try:
|
|
21
|
+
with open(absolute_path, "rb") as handle:
|
|
22
|
+
content = handle.read()
|
|
23
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
24
|
+
ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
|
|
25
|
+
flow.response = http.Response.make(
|
|
26
|
+
404,
|
|
27
|
+
f"dataverseproxy: local file not found: {absolute_path}".encode(),
|
|
28
|
+
{"Content-Type": "text/plain"},
|
|
29
|
+
)
|
|
30
|
+
return
|
|
31
|
+
content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
|
|
32
|
+
flow.response = http.Response.make(200, content, {"Content-Type": content_type})
|
|
33
|
+
_log_redirect(flow, rule_type, rule_name, absolute_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
|
|
37
|
+
target = urlsplit(url)
|
|
38
|
+
query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
|
|
39
|
+
new_path = "/webresources/" + web_resource
|
|
40
|
+
if query:
|
|
41
|
+
new_path += "?" + query
|
|
42
|
+
flow.request.scheme = target.scheme
|
|
43
|
+
flow.request.host = target.hostname or "localhost"
|
|
44
|
+
flow.request.port = target.port or (443 if target.scheme == "https" else 80)
|
|
45
|
+
flow.request.path = new_path
|
|
46
|
+
_log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""TLS handling for upstream connections to local dev servers.
|
|
2
|
+
|
|
3
|
+
Verification is skipped only for loopback dev servers (self-signed / mkcert
|
|
4
|
+
certs); every real upstream keeps full certificate verification because the
|
|
5
|
+
built-in TlsConfig addon handles any connection we leave untouched.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import ipaddress
|
|
9
|
+
|
|
10
|
+
from mitmproxy import connection, ctx, tls
|
|
11
|
+
from mitmproxy.net import tls as net_tls
|
|
12
|
+
from OpenSSL import SSL
|
|
13
|
+
|
|
14
|
+
# Upstream hosts for which TLS verification is skipped.
|
|
15
|
+
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def start_dev_server_tls(data: tls.TlsData) -> None:
|
|
19
|
+
"""Attach a no-verify TLS context when connecting to a loopback dev server.
|
|
20
|
+
|
|
21
|
+
Runs before the built-in TlsConfig addon; once ``data.ssl_conn`` is set,
|
|
22
|
+
TlsConfig skips the connection, so other upstreams keep full verification.
|
|
23
|
+
"""
|
|
24
|
+
server = data.conn
|
|
25
|
+
if data.ssl_conn is not None or not isinstance(server, connection.Server):
|
|
26
|
+
return
|
|
27
|
+
if not server.address or server.address[0] not in LOOPBACK_HOSTS:
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
ssl_ctx = net_tls.create_proxy_server_context(
|
|
31
|
+
method=net_tls.Method.TLS_CLIENT_METHOD,
|
|
32
|
+
min_version=net_tls.Version[ctx.options.tls_version_server_min],
|
|
33
|
+
max_version=net_tls.Version[ctx.options.tls_version_server_max],
|
|
34
|
+
cipher_list=None,
|
|
35
|
+
ecdh_curve=None,
|
|
36
|
+
verify=net_tls.Verify.VERIFY_NONE,
|
|
37
|
+
ca_path=None,
|
|
38
|
+
ca_pemfile=None,
|
|
39
|
+
client_cert=None,
|
|
40
|
+
legacy_server_connect=False,
|
|
41
|
+
)
|
|
42
|
+
ssl_conn = SSL.Connection(ssl_ctx)
|
|
43
|
+
sni = server.sni or server.address[0]
|
|
44
|
+
try:
|
|
45
|
+
ipaddress.ip_address(sni)
|
|
46
|
+
except ValueError:
|
|
47
|
+
ssl_conn.set_tlsext_host_name(sni.encode("idna"))
|
|
48
|
+
alpn_offers = server.alpn_offers or data.context.client.alpn_offers
|
|
49
|
+
if alpn_offers:
|
|
50
|
+
ssl_conn.set_alpn_protos(list(alpn_offers))
|
|
51
|
+
ssl_conn.set_connect_state()
|
|
52
|
+
data.ssl_conn = ssl_conn
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hummingbirdworks/proxy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "mitmproxy-based dev proxy that redirects Dataverse / Dynamics 365 web resources and PCF control assets to local dev builds or a running dev server.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"hummingbird-proxy": "bin/hummingbird-proxy.js"
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"files": [
|
|
16
16
|
"bin/",
|
|
17
17
|
"vite/",
|
|
18
|
+
"hummingbird_proxy/",
|
|
18
19
|
"powerapp_dev_proxy.py",
|
|
19
20
|
"proxy.config.example.toml",
|
|
20
21
|
"README.md"
|
package/powerapp_dev_proxy.py
CHANGED
|
@@ -1,382 +1,15 @@
|
|
|
1
1
|
r"""
|
|
2
2
|
mitmproxy addon that redirects Dataverse / Dynamics 365 web resources and PCF
|
|
3
|
-
control assets to local dev builds or a running dev server.
|
|
3
|
+
control assets to local dev builds or a running dev server. See README.md for
|
|
4
|
+
setup and config usage.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
1. Install mitmproxy: https://www.mitmproxy.org/ (e.g. `pipx install mitmproxy`)
|
|
9
|
-
2. Run `npx hummingbird-proxy` from the folder containing proxy.config.toml
|
|
10
|
-
3. Run `msedge.exe --proxy-server="http://localhost:8080"` or `chrome.exe --proxy-server="http://localhost:8080"`
|
|
11
|
-
4. Visit http://mitm.it/ in the new Chrome window and follow the instructions to install the mitmproxy root certificate.
|
|
12
|
-
--------------------------------------------------------------------------------
|
|
13
|
-
Subsequent usage:
|
|
14
|
-
1. Run `npx hummingbird-proxy` from the folder containing proxy.config.toml
|
|
15
|
-
2. Run `msedge.exe --proxy-server="http://localhost:8080"` or `chrome.exe --proxy-server="http://localhost:8080"`
|
|
16
|
-
--------------------------------------------------------------------------------
|
|
17
|
-
NOTE: Chrome and Edge run a single background process for all windows.
|
|
18
|
-
You need to either close all Chrome or Edge windows before starting the proxy, or use a separate user profile for the proxied browser.
|
|
19
|
-
For example, run `msedge.exe --user-data-dir="%LOCALAPPDATA%\mitmproxy-browser-profile" --proxy-server="http://localhost:8080"` to use a separate profile.
|
|
20
|
-
--------------------------------------------------------------------------------
|
|
21
|
-
NOTE: Power Apps will cache web resource and PCF controls in the browser. If you run into issues,
|
|
22
|
-
try ctrl+shift+r to force a full refresh of the page and clear the cache. Sometimes you may need
|
|
23
|
-
to open Chrome/Edge DevTools and open the Application tab, select "Service Workers" in the left menu,
|
|
24
|
-
and check "Bypass for network" to disable the service worker cache.
|
|
25
|
-
--------------------------------------------------------------------------------
|
|
26
|
-
Config:
|
|
27
|
-
|
|
28
|
-
Rules are loaded from a TOML config file. By default this is proxy.config.toml
|
|
29
|
-
in the current working directory; the `hummingbird-proxy` CLI (or the
|
|
30
|
-
HUMMINGBIRD_PROXY_CONFIG environment variable) can point at a different file.
|
|
31
|
-
All relative file/folder paths in the config are resolved relative to the
|
|
32
|
-
config file's own location, NOT this script.
|
|
33
|
-
|
|
34
|
-
The config is a table with a "rules" array-of-tables. Each [[rules]] entry is
|
|
35
|
-
one redirect. The config is validated at startup, so a malformed entry raises a
|
|
36
|
-
clear error.
|
|
37
|
-
|
|
38
|
-
Tip: use TOML single-quoted literal strings for Windows absolute paths so
|
|
39
|
-
backslashes are not treated as escapes, e.g. folder = 'C:\Users\me\out'.
|
|
40
|
-
|
|
41
|
-
Entry types:
|
|
42
|
-
|
|
43
|
-
Redirect a folder of web resources to a local Vite dev server:
|
|
44
|
-
[[rules]]
|
|
45
|
-
type = "devserver"
|
|
46
|
-
name = "test_/bookings-editor/"
|
|
47
|
-
url = "https://localhost:5173"
|
|
48
|
-
|
|
49
|
-
Redirect a single web resource file to a local file:
|
|
50
|
-
[[rules]]
|
|
51
|
-
type = "single"
|
|
52
|
-
name = "test_/ribbonscript/opportunity.js"
|
|
53
|
-
file = "./src/webresources/ribbonscript/opportunity.js"
|
|
54
|
-
|
|
55
|
-
Redirect a folder of web resources to a local folder:
|
|
56
|
-
[[rules]]
|
|
57
|
-
type = "folder"
|
|
58
|
-
name = "test_/custom-app/"
|
|
59
|
-
folder = "./src/webresources/custom-app"
|
|
60
|
-
|
|
61
|
-
Redirect a PCF control to its local build output folder:
|
|
62
|
-
[[rules]]
|
|
63
|
-
type = "pcf"
|
|
64
|
-
name = "test.BookingsEditor"
|
|
65
|
-
folder = "./bookings-editor/out/controls/BookingsEditor"
|
|
66
|
-
|
|
67
|
-
Optional keys on any entry:
|
|
68
|
-
"domain": omit for all hosts, a host string, or an array of host strings
|
|
69
|
-
"disabled": true to skip the entry
|
|
70
|
-
|
|
71
|
-
Only redirect for a specific host:
|
|
72
|
-
[[rules]]
|
|
73
|
-
type = "single"
|
|
74
|
-
name = "test_/ribbonscript/opportunity-from-bom.js"
|
|
75
|
-
file = "./src/webresources/opportunity.js"
|
|
76
|
-
domain = "myorg.crm.dynamics.com"
|
|
77
|
-
--------------------------------------------------------------------------------
|
|
6
|
+
mitmdump loads this file via ``-s``; its directory is placed on ``sys.path``
|
|
7
|
+
during load, so the sibling ``hummingbird_proxy`` package resolves here. All
|
|
8
|
+
implementation lives in that package; this module is just the entrypoint.
|
|
78
9
|
"""
|
|
79
10
|
|
|
80
|
-
#
|
|
81
|
-
# Proxy engine. Redirect rules are loaded from the TOML config file described
|
|
82
|
-
# in the docstring above; you generally shouldn't need to edit anything below.
|
|
83
|
-
# ==============================================================================
|
|
84
|
-
|
|
85
|
-
# Version 2026-08-20
|
|
86
|
-
|
|
87
|
-
import asyncio
|
|
88
|
-
import ipaddress
|
|
89
|
-
import mimetypes
|
|
90
|
-
import os
|
|
91
|
-
import re
|
|
92
|
-
import tomllib
|
|
93
|
-
from urllib.parse import urlsplit
|
|
94
|
-
|
|
95
|
-
from mitmproxy import connection, ctx, http, tls
|
|
96
|
-
from mitmproxy.net import tls as net_tls
|
|
97
|
-
from OpenSSL import SSL
|
|
98
|
-
|
|
99
|
-
Domain = str | list[str] | None
|
|
100
|
-
|
|
101
|
-
# TLS verification is skipped only for upstream connections to these loopback
|
|
102
|
-
# hosts (local dev servers with self-signed / mkcert certs).
|
|
103
|
-
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
104
|
-
|
|
105
|
-
# type -> the set of keys an entry of that type must contain.
|
|
106
|
-
_CONFIG_SCHEMA: dict[str, set[str]] = {
|
|
107
|
-
"single": {"name", "file"},
|
|
108
|
-
"folder": {"name", "folder"},
|
|
109
|
-
"devserver": {"name", "url"},
|
|
110
|
-
"pcf": {"name", "folder"},
|
|
111
|
-
}
|
|
112
|
-
_OPTIONAL_KEYS = {"type", "domain", "disabled"}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _config_path() -> str:
|
|
116
|
-
"""Absolute path of the TOML config file to load."""
|
|
117
|
-
override = os.environ.get("HUMMINGBIRD_PROXY_CONFIG")
|
|
118
|
-
if override:
|
|
119
|
-
return os.path.abspath(override)
|
|
120
|
-
return os.path.abspath(os.path.join(os.getcwd(), "proxy.config.toml"))
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def _load_config(path: str) -> list[dict]:
|
|
124
|
-
"""Load and parse the TOML config file into a list of rule dicts."""
|
|
125
|
-
try:
|
|
126
|
-
with open(path, "rb") as handle:
|
|
127
|
-
data = tomllib.load(handle)
|
|
128
|
-
except FileNotFoundError:
|
|
129
|
-
raise FileNotFoundError(
|
|
130
|
-
f"proxy config file not found: {path}. "
|
|
131
|
-
"Create a proxy.config.toml or set HUMMINGBIRD_PROXY_CONFIG."
|
|
132
|
-
)
|
|
133
|
-
except tomllib.TOMLDecodeError as err:
|
|
134
|
-
raise ValueError(f"invalid TOML in proxy config {path}: {err}")
|
|
135
|
-
rules = data.get("rules", [])
|
|
136
|
-
if not isinstance(rules, list):
|
|
137
|
-
raise ValueError("proxy config 'rules' must be an array of tables")
|
|
138
|
-
return rules
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
def _resolve_path(path: str) -> str:
|
|
142
|
-
"""Convert a path to absolute, relative to the config file directory."""
|
|
143
|
-
if os.path.isabs(path):
|
|
144
|
-
return path
|
|
145
|
-
return os.path.normpath(os.path.join(CONFIG_DIR, path))
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
def _validate_config(config: list[dict]) -> list[dict]:
|
|
149
|
-
"""Validate CONFIG and return the entries that are not disabled."""
|
|
150
|
-
enabled: list[dict] = []
|
|
151
|
-
for index, item in enumerate(config):
|
|
152
|
-
label = f"CONFIG[{index}]"
|
|
153
|
-
if not isinstance(item, dict):
|
|
154
|
-
raise ValueError(f"{label}: expected a dict, got {type(item).__name__}")
|
|
155
|
-
item_type = item.get("type")
|
|
156
|
-
if item_type not in _CONFIG_SCHEMA:
|
|
157
|
-
raise ValueError(
|
|
158
|
-
f"{label}: unknown or missing 'type' {item_type!r}; "
|
|
159
|
-
f"expected one of {sorted(_CONFIG_SCHEMA)}"
|
|
160
|
-
)
|
|
161
|
-
required = _CONFIG_SCHEMA[item_type]
|
|
162
|
-
missing = required - item.keys()
|
|
163
|
-
if missing:
|
|
164
|
-
raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
|
|
165
|
-
unknown = item.keys() - (required | _OPTIONAL_KEYS)
|
|
166
|
-
if unknown:
|
|
167
|
-
raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
|
|
168
|
-
if not item.get("disabled", False):
|
|
169
|
-
enabled.append(item)
|
|
170
|
-
return enabled
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def _domain_matches(domain: Domain, host: str) -> bool:
|
|
174
|
-
if domain is None:
|
|
175
|
-
return True
|
|
176
|
-
host = host.lower()
|
|
177
|
-
if isinstance(domain, str):
|
|
178
|
-
return host == domain.lower()
|
|
179
|
-
return any(host == d.lower() for d in domain)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _web_resource_name(request: http.Request) -> str | None:
|
|
183
|
-
"""Return the path following the ``webresources`` segment, or None."""
|
|
184
|
-
components = request.path_components
|
|
185
|
-
for i, component in enumerate(components):
|
|
186
|
-
if component.lower() == "webresources":
|
|
187
|
-
name = "/".join(components[i + 1:])
|
|
188
|
-
# path_components drops a trailing slash; keep it so dev-server root
|
|
189
|
-
# requests (e.g. the Vite HMR base URL) still match a rule prefix.
|
|
190
|
-
if name and request.path.split("?", 1)[0].endswith("/"):
|
|
191
|
-
name += "/"
|
|
192
|
-
return name
|
|
193
|
-
return None
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
_pcf_patterns: dict[str, re.Pattern[str]] = {}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
def _pcf_match(path: str, name: str) -> tuple[str, bool] | None:
|
|
200
|
-
"""Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
|
|
201
|
-
pattern = _pcf_patterns.get(name)
|
|
202
|
-
if pattern is None:
|
|
203
|
-
pattern = re.compile(
|
|
204
|
-
r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
|
|
205
|
-
+ re.escape(name)
|
|
206
|
-
+ r"(?:\.|/)(?P<rest>[^?]*)",
|
|
207
|
-
re.IGNORECASE,
|
|
208
|
-
)
|
|
209
|
-
_pcf_patterns[name] = pattern
|
|
210
|
-
match = pattern.search(path)
|
|
211
|
-
if match is None:
|
|
212
|
-
return None
|
|
213
|
-
return match.group("rest"), bool(match.group("css"))
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
|
|
217
|
-
ctx.log.info(
|
|
218
|
-
f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
|
|
219
|
-
)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str) -> None:
|
|
223
|
-
absolute_path = _resolve_path(filepath)
|
|
224
|
-
try:
|
|
225
|
-
with open(absolute_path, "rb") as handle:
|
|
226
|
-
content = handle.read()
|
|
227
|
-
except (FileNotFoundError, IsADirectoryError):
|
|
228
|
-
ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
|
|
229
|
-
flow.response = http.Response.make(
|
|
230
|
-
404,
|
|
231
|
-
f"dataverseproxy: local file not found: {absolute_path}".encode(),
|
|
232
|
-
{"Content-Type": "text/plain"},
|
|
233
|
-
)
|
|
234
|
-
return
|
|
235
|
-
content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
|
|
236
|
-
flow.response = http.Response.make(200, content, {"Content-Type": content_type})
|
|
237
|
-
_log_redirect(flow, rule_type, rule_name, absolute_path)
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
def _proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
|
|
241
|
-
target = urlsplit(url)
|
|
242
|
-
query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
|
|
243
|
-
new_path = "/webresources/" + web_resource
|
|
244
|
-
if query:
|
|
245
|
-
new_path += "?" + query
|
|
246
|
-
flow.request.scheme = target.scheme
|
|
247
|
-
flow.request.host = target.hostname or "localhost"
|
|
248
|
-
flow.request.port = target.port or (443 if target.scheme == "https" else 80)
|
|
249
|
-
flow.request.path = new_path
|
|
250
|
-
_log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
# How often (seconds) to poll the config file for changes.
|
|
254
|
-
_CONFIG_POLL_INTERVAL = 1.0
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
def _config_mtime(path: str) -> float | None:
|
|
258
|
-
"""Modification time of the config file, or None if it is missing."""
|
|
259
|
-
try:
|
|
260
|
-
return os.path.getmtime(path)
|
|
261
|
-
except OSError:
|
|
262
|
-
return None
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
class DataverseProxy:
|
|
266
|
-
def __init__(self, config: list[dict]) -> None:
|
|
267
|
-
self.config = _validate_config(config)
|
|
268
|
-
self._config_mtime = _config_mtime(CONFIG_PATH)
|
|
269
|
-
self._watch_task: asyncio.Task | None = None
|
|
270
|
-
|
|
271
|
-
def running(self) -> None:
|
|
272
|
-
# Start watching the config file for changes once the event loop is up.
|
|
273
|
-
if self._watch_task is None:
|
|
274
|
-
self._watch_task = asyncio.ensure_future(self._watch_config())
|
|
275
|
-
|
|
276
|
-
def done(self) -> None:
|
|
277
|
-
if self._watch_task is not None:
|
|
278
|
-
self._watch_task.cancel()
|
|
279
|
-
self._watch_task = None
|
|
280
|
-
|
|
281
|
-
async def _watch_config(self) -> None:
|
|
282
|
-
while True:
|
|
283
|
-
await asyncio.sleep(_CONFIG_POLL_INTERVAL)
|
|
284
|
-
mtime = _config_mtime(CONFIG_PATH)
|
|
285
|
-
if mtime is None or mtime == self._config_mtime:
|
|
286
|
-
continue
|
|
287
|
-
self._config_mtime = mtime
|
|
288
|
-
self._reload_config()
|
|
289
|
-
|
|
290
|
-
def _reload_config(self) -> None:
|
|
291
|
-
try:
|
|
292
|
-
config = _validate_config(_load_config(CONFIG_PATH))
|
|
293
|
-
except (OSError, ValueError) as err:
|
|
294
|
-
ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
|
|
295
|
-
return
|
|
296
|
-
self.config = config
|
|
297
|
-
_pcf_patterns.clear()
|
|
298
|
-
ctx.log.info(f"Reloaded proxy config from {CONFIG_PATH} ({len(config)} active rules)")
|
|
299
|
-
|
|
300
|
-
def tls_start_server(self, data: tls.TlsData) -> None:
|
|
301
|
-
# Provide a no-verify TLS context for localhost dev servers only. This runs
|
|
302
|
-
# before the built-in TlsConfig addon; once data.ssl_conn is set, TlsConfig
|
|
303
|
-
# skips the connection, so every other (real) upstream keeps full
|
|
304
|
-
# certificate verification.
|
|
305
|
-
server = data.conn
|
|
306
|
-
if data.ssl_conn is not None or not isinstance(server, connection.Server):
|
|
307
|
-
return
|
|
308
|
-
if not server.address or server.address[0] not in LOOPBACK_HOSTS:
|
|
309
|
-
return
|
|
310
|
-
|
|
311
|
-
ssl_ctx = net_tls.create_proxy_server_context(
|
|
312
|
-
method=net_tls.Method.TLS_CLIENT_METHOD,
|
|
313
|
-
min_version=net_tls.Version[ctx.options.tls_version_server_min],
|
|
314
|
-
max_version=net_tls.Version[ctx.options.tls_version_server_max],
|
|
315
|
-
cipher_list=None,
|
|
316
|
-
ecdh_curve=None,
|
|
317
|
-
verify=net_tls.Verify.VERIFY_NONE,
|
|
318
|
-
ca_path=None,
|
|
319
|
-
ca_pemfile=None,
|
|
320
|
-
client_cert=None,
|
|
321
|
-
legacy_server_connect=False,
|
|
322
|
-
)
|
|
323
|
-
ssl_conn = SSL.Connection(ssl_ctx)
|
|
324
|
-
sni = server.sni or server.address[0]
|
|
325
|
-
try:
|
|
326
|
-
ipaddress.ip_address(sni)
|
|
327
|
-
except ValueError:
|
|
328
|
-
ssl_conn.set_tlsext_host_name(sni.encode("idna"))
|
|
329
|
-
alpn_offers = server.alpn_offers or data.context.client.alpn_offers
|
|
330
|
-
if alpn_offers:
|
|
331
|
-
ssl_conn.set_alpn_protos(list(alpn_offers))
|
|
332
|
-
ssl_conn.set_connect_state()
|
|
333
|
-
data.ssl_conn = ssl_conn
|
|
334
|
-
|
|
335
|
-
def request(self, flow: http.HTTPFlow) -> None:
|
|
336
|
-
host = flow.request.pretty_host
|
|
337
|
-
web_resource = _web_resource_name(flow.request)
|
|
338
|
-
|
|
339
|
-
for item in self.config:
|
|
340
|
-
if not _domain_matches(item.get("domain"), host):
|
|
341
|
-
continue
|
|
342
|
-
|
|
343
|
-
item_type = item["type"]
|
|
344
|
-
|
|
345
|
-
if item_type == "devserver":
|
|
346
|
-
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
347
|
-
_proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
|
|
348
|
-
return
|
|
349
|
-
|
|
350
|
-
elif item_type == "single":
|
|
351
|
-
if web_resource == item["name"]:
|
|
352
|
-
_serve_file(flow, item["file"], item_type, item["name"])
|
|
353
|
-
return
|
|
354
|
-
|
|
355
|
-
elif item_type == "folder":
|
|
356
|
-
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
357
|
-
relative = web_resource[len(item["name"]):]
|
|
358
|
-
_serve_file(
|
|
359
|
-
flow,
|
|
360
|
-
os.path.join(item["folder"], *relative.split("/")),
|
|
361
|
-
item_type,
|
|
362
|
-
item["name"],
|
|
363
|
-
)
|
|
364
|
-
return
|
|
365
|
-
|
|
366
|
-
elif item_type == "pcf":
|
|
367
|
-
hit = _pcf_match(flow.request.path, item["name"])
|
|
368
|
-
if hit is not None:
|
|
369
|
-
relative, is_css = hit
|
|
370
|
-
parts = [item["folder"]]
|
|
371
|
-
if is_css:
|
|
372
|
-
parts.append("css")
|
|
373
|
-
parts.extend(segment for segment in relative.split("/") if segment)
|
|
374
|
-
_serve_file(flow, os.path.join(*parts), item_type, item["name"])
|
|
375
|
-
return
|
|
376
|
-
|
|
11
|
+
# Version 2026-08-21
|
|
377
12
|
|
|
378
|
-
|
|
379
|
-
CONFIG_DIR = os.path.dirname(CONFIG_PATH)
|
|
380
|
-
CONFIG = _load_config(CONFIG_PATH)
|
|
13
|
+
from hummingbird_proxy import DataverseProxy, config_path
|
|
381
14
|
|
|
382
|
-
addons = [DataverseProxy(
|
|
15
|
+
addons = [DataverseProxy(config_path())]
|
|
@@ -25,3 +25,10 @@ domain = "myorg.crm.dynamics.com"
|
|
|
25
25
|
type = "pcf"
|
|
26
26
|
name = "test.BookingsEditor"
|
|
27
27
|
folder = "./bookings-editor/out/controls/BookingsEditor"
|
|
28
|
+
|
|
29
|
+
# Pull in the rules from another project's config so several projects can proxy
|
|
30
|
+
# the same Power Apps environment at once. The included config's relative paths
|
|
31
|
+
# resolve against its own location, and it is watched for live reloads too.
|
|
32
|
+
[[rules]]
|
|
33
|
+
type = "include"
|
|
34
|
+
path = "../bookings-editor/proxy.config.toml"
|