@hummingbirdworks/proxy 0.2.1 → 0.3.1
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/LICENSE +1 -1
- package/README.md +35 -45
- package/bin/hummingbird-proxy.js +13 -8
- package/hummingbird_proxy/__init__.py +10 -0
- package/hummingbird_proxy/addon.py +107 -0
- package/hummingbird_proxy/config.py +147 -0
- package/hummingbird_proxy/matching.py +55 -0
- package/hummingbird_proxy/serving.py +89 -0
- package/hummingbird_proxy/tls.py +52 -0
- package/package.json +14 -1
- package/powerapp_dev_proxy.py +7 -451
- package/proxy.config.example.toml +7 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -2,16 +2,13 @@
|
|
|
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.
|
|
5
|
+
local dev builds or a running dev server (e.g. Vite), so you can iterate
|
|
6
|
+
locally without deploying on every change.
|
|
8
7
|
|
|
9
8
|
## Prerequisites
|
|
10
9
|
|
|
11
|
-
- **Node.js** >= 16
|
|
12
|
-
- **mitmproxy**
|
|
13
|
-
(Python 3.11+, for stdlib TOML support).
|
|
14
|
-
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`.
|
|
15
12
|
|
|
16
13
|
## Install and Configure
|
|
17
14
|
|
|
@@ -59,23 +56,30 @@ Optional keys on any rule:
|
|
|
59
56
|
- `domain`: a host string, or an array of host strings. Omit for all hosts.
|
|
60
57
|
- `disabled`: `true` to skip the rule.
|
|
61
58
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
Run the proxy from the folder containing `proxy.config.toml`:
|
|
59
|
+
### Combining several projects with `include`
|
|
65
60
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
```
|
|
61
|
+
To proxy several projects against the same environment from one running proxy,
|
|
62
|
+
`include` their configs instead of switching proxies as you navigate:
|
|
69
63
|
|
|
70
|
-
|
|
64
|
+
```toml
|
|
65
|
+
[[rules]]
|
|
66
|
+
type = "include"
|
|
67
|
+
path = "../invoice-editor/proxy.config.toml"
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
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'
|
|
74
73
|
```
|
|
75
74
|
|
|
76
|
-
|
|
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):
|
|
77
79
|
|
|
78
80
|
```sh
|
|
81
|
+
npm run proxy
|
|
82
|
+
# or a specific config, forwarding args to mitmdump (e.g. change the port)
|
|
79
83
|
npx hummingbird-proxy ./proxy.config.toml -p 8888
|
|
80
84
|
```
|
|
81
85
|
|
|
@@ -89,42 +93,29 @@ chrome.exe --proxy-server="http://localhost:8080"
|
|
|
89
93
|
|
|
90
94
|
### First-time setup
|
|
91
95
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
instructions for your OS/browser.
|
|
96
|
+
Install the mitmproxy root certificate so HTTPS interception works: with the
|
|
97
|
+
proxied browser open, visit <http://mitm.it/> and follow the instructions.
|
|
95
98
|
|
|
96
99
|
### ⚠️ **Notes**
|
|
97
100
|
|
|
98
|
-
- Chrome
|
|
99
|
-
|
|
100
|
-
profile
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
```
|
|
105
|
-
|
|
106
|
-
- Power Apps caches web resources and PCF controls in the browser. If changes
|
|
107
|
-
don't show up, force a full refresh (`Ctrl+Shift+R`). You may also need to
|
|
108
|
-
open DevTools → Application → Service Workers and check "Bypass for network"
|
|
109
|
-
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").
|
|
110
107
|
|
|
111
108
|
## Using with Vite (HMR)
|
|
112
109
|
|
|
113
110
|
A `devserver` rule routes a web resource's requests to a running Vite dev
|
|
114
|
-
server
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
the proxy relays requests (including the HMR websocket) to `localhost`. Because
|
|
118
|
-
the Dynamics page is HTTPS, the browser would block an insecure `ws://` HMR
|
|
119
|
-
socket as mixed content, so point the HMR client at `wss` and let the proxy
|
|
120
|
-
forward it to the HTTP dev server. No dev-server cert (or `vite-plugin-mkcert`)
|
|
121
|
-
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.
|
|
122
114
|
|
|
123
115
|
### Vite config
|
|
124
116
|
|
|
125
|
-
The package ships a Vite plugin that
|
|
126
|
-
|
|
127
|
-
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).
|
|
128
119
|
|
|
129
120
|
```ts
|
|
130
121
|
import { defineConfig } from 'vite'
|
|
@@ -173,5 +164,4 @@ npm run proxy
|
|
|
173
164
|
|
|
174
165
|
Open the Dynamics page hosting the web resource. Edit → save → the page updates
|
|
175
166
|
without a manual refresh. If HMR doesn't trigger, confirm Vite's port matches
|
|
176
|
-
the rule `url
|
|
177
|
-
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
|
@@ -95,15 +95,20 @@ function main() {
|
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
98
|
+
// Only the first token can be the config path, and only when it isn't a
|
|
99
|
+
// flag; everything else is forwarded to mitmdump so a flag's value (e.g. the
|
|
100
|
+
// `8888` in `-p 8888`) is never mistaken for the config path.
|
|
99
101
|
let configArg;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
let passthrough;
|
|
103
|
+
if (argv.length > 0 && !argv[0].startsWith("-")) {
|
|
104
|
+
configArg = argv[0];
|
|
105
|
+
passthrough = argv.slice(1);
|
|
106
|
+
} else {
|
|
107
|
+
passthrough = argv.slice();
|
|
108
|
+
}
|
|
109
|
+
// Drop a leading `--` separator; the rest already goes to mitmdump.
|
|
110
|
+
if (passthrough[0] === "--") {
|
|
111
|
+
passthrough.shift();
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
const configPath = path.resolve(process.cwd(), configArg || DEFAULT_CONFIG);
|
|
@@ -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,107 @@
|
|
|
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
|
+
root=item["folder"],
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
elif item_type == "pcf":
|
|
92
|
+
hit = matching.pcf_match(flow.request.path, item["name"])
|
|
93
|
+
if hit is not None:
|
|
94
|
+
relative, is_css = hit
|
|
95
|
+
parts = [item["folder"]]
|
|
96
|
+
if is_css:
|
|
97
|
+
parts.append("css")
|
|
98
|
+
parts.extend(segment for segment in relative.split("/") if segment)
|
|
99
|
+
serving.serve_file(
|
|
100
|
+
flow,
|
|
101
|
+
os.path.join(*parts),
|
|
102
|
+
item_type,
|
|
103
|
+
item["name"],
|
|
104
|
+
item["_base_dir"],
|
|
105
|
+
root=item["folder"],
|
|
106
|
+
)
|
|
107
|
+
return
|
|
@@ -0,0 +1,147 @@
|
|
|
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
|
+
# Prefix matching relies on a trailing slash to mark the name boundary,
|
|
128
|
+
# so normalize it rather than silently mismatching a sibling resource.
|
|
129
|
+
if item_type in ("folder", "devserver") and isinstance(item.get("name"), str):
|
|
130
|
+
if not item["name"].endswith("/"):
|
|
131
|
+
item["name"] += "/"
|
|
132
|
+
if not item.get("disabled", False):
|
|
133
|
+
enabled.append(item)
|
|
134
|
+
return enabled
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _config_mtime(path: str) -> float | None:
|
|
138
|
+
"""Modification time of the config file, or None if it is missing."""
|
|
139
|
+
try:
|
|
140
|
+
return os.path.getmtime(path)
|
|
141
|
+
except OSError:
|
|
142
|
+
return None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def config_mtimes(paths: set[str]) -> dict[str, float | None]:
|
|
146
|
+
"""Modification times for every config file, keyed by path."""
|
|
147
|
+
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,89 @@
|
|
|
1
|
+
"""Response builders: serve local files and relay to a dev server."""
|
|
2
|
+
|
|
3
|
+
from urllib.parse import urlsplit
|
|
4
|
+
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
|
|
8
|
+
from mitmproxy import ctx, http
|
|
9
|
+
|
|
10
|
+
from hummingbird_proxy.config import resolve_path
|
|
11
|
+
|
|
12
|
+
# Explicit types for common web assets; the OS mimetypes DB is unreliable here
|
|
13
|
+
# (e.g. Windows maps .js to text/plain, which browsers refuse to execute).
|
|
14
|
+
_CONTENT_TYPES = {
|
|
15
|
+
".js": "text/javascript",
|
|
16
|
+
".mjs": "text/javascript",
|
|
17
|
+
".cjs": "text/javascript",
|
|
18
|
+
".css": "text/css",
|
|
19
|
+
".html": "text/html",
|
|
20
|
+
".htm": "text/html",
|
|
21
|
+
".json": "application/json",
|
|
22
|
+
".map": "application/json",
|
|
23
|
+
".svg": "image/svg+xml",
|
|
24
|
+
".wasm": "application/wasm",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
|
|
29
|
+
ctx.log.info(
|
|
30
|
+
f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_within(root: str, path: str) -> bool:
|
|
35
|
+
"""True if PATH is ROOT itself or nested under it (after normalization)."""
|
|
36
|
+
try:
|
|
37
|
+
return os.path.commonpath([root, path]) == root
|
|
38
|
+
except ValueError:
|
|
39
|
+
# Raised when the paths live on different drives (Windows).
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def serve_file(
|
|
44
|
+
flow: http.HTTPFlow,
|
|
45
|
+
filepath: str,
|
|
46
|
+
rule_type: str,
|
|
47
|
+
rule_name: str,
|
|
48
|
+
base_dir: str,
|
|
49
|
+
root: str | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
absolute_path = resolve_path(filepath, base_dir)
|
|
52
|
+
if root is not None and not _is_within(resolve_path(root, base_dir), absolute_path):
|
|
53
|
+
ctx.log.warn(f"Blocked path traversal for {rule_type}:{rule_name} -> {absolute_path}")
|
|
54
|
+
flow.response = http.Response.make(
|
|
55
|
+
403,
|
|
56
|
+
b"dataverseproxy: path escapes the configured folder",
|
|
57
|
+
{"Content-Type": "text/plain"},
|
|
58
|
+
)
|
|
59
|
+
return
|
|
60
|
+
try:
|
|
61
|
+
with open(absolute_path, "rb") as handle:
|
|
62
|
+
content = handle.read()
|
|
63
|
+
except (FileNotFoundError, IsADirectoryError):
|
|
64
|
+
ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
|
|
65
|
+
flow.response = http.Response.make(
|
|
66
|
+
404,
|
|
67
|
+
f"dataverseproxy: local file not found: {absolute_path}".encode(),
|
|
68
|
+
{"Content-Type": "text/plain"},
|
|
69
|
+
)
|
|
70
|
+
return
|
|
71
|
+
ext = os.path.splitext(absolute_path)[1].lower()
|
|
72
|
+
content_type = (
|
|
73
|
+
_CONTENT_TYPES.get(ext) or mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
|
|
74
|
+
)
|
|
75
|
+
flow.response = http.Response.make(200, content, {"Content-Type": content_type})
|
|
76
|
+
_log_redirect(flow, rule_type, rule_name, absolute_path)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
|
|
80
|
+
target = urlsplit(url)
|
|
81
|
+
query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
|
|
82
|
+
new_path = "/webresources/" + web_resource
|
|
83
|
+
if query:
|
|
84
|
+
new_path += "?" + query
|
|
85
|
+
flow.request.scheme = target.scheme
|
|
86
|
+
flow.request.host = target.hostname or "localhost"
|
|
87
|
+
flow.request.port = target.port or (443 if target.scheme == "https" else 80)
|
|
88
|
+
flow.request.path = new_path
|
|
89
|
+
_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.1",
|
|
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"
|
|
@@ -12,9 +12,22 @@
|
|
|
12
12
|
},
|
|
13
13
|
"./package.json": "./package.json"
|
|
14
14
|
},
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/ps-Hummingbird/powerapps-mitm-proxy-addon.git"
|
|
18
|
+
},
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/ps-Hummingbird/powerapps-mitm-proxy-addon/issues"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/ps-Hummingbird/powerapps-mitm-proxy-addon#readme",
|
|
23
|
+
"author": {
|
|
24
|
+
"name": "Hummingbird",
|
|
25
|
+
"url": "https://www.hummingbirdworks.ai/"
|
|
26
|
+
},
|
|
15
27
|
"files": [
|
|
16
28
|
"bin/",
|
|
17
29
|
"vite/",
|
|
30
|
+
"hummingbird_proxy/",
|
|
18
31
|
"powerapp_dev_proxy.py",
|
|
19
32
|
"proxy.config.example.toml",
|
|
20
33
|
"README.md"
|
package/powerapp_dev_proxy.py
CHANGED
|
@@ -1,457 +1,13 @@
|
|
|
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
|
-
Pull in the rules from another config file (so several projects can proxy the
|
|
68
|
-
same Power Apps environment at once without switching proxies). Includes are
|
|
69
|
-
resolved recursively; each included config's relative paths resolve against its
|
|
70
|
-
own location, and every included file is watched for live reloads:
|
|
71
|
-
[[rules]]
|
|
72
|
-
type = "include"
|
|
73
|
-
path = "../bookings-editor/proxy.config.toml"
|
|
74
|
-
|
|
75
|
-
Optional keys on any entry:
|
|
76
|
-
"domain": omit for all hosts, a host string, or an array of host strings
|
|
77
|
-
"disabled": true to skip the entry
|
|
78
|
-
|
|
79
|
-
Only redirect for a specific host:
|
|
80
|
-
[[rules]]
|
|
81
|
-
type = "single"
|
|
82
|
-
name = "test_/ribbonscript/opportunity-from-bom.js"
|
|
83
|
-
file = "./src/webresources/opportunity.js"
|
|
84
|
-
domain = "myorg.crm.dynamics.com"
|
|
85
|
-
--------------------------------------------------------------------------------
|
|
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.
|
|
86
9
|
"""
|
|
87
10
|
|
|
88
|
-
|
|
89
|
-
# Proxy engine. Redirect rules are loaded from the TOML config file described
|
|
90
|
-
# in the docstring above; you generally shouldn't need to edit anything below.
|
|
91
|
-
# ==============================================================================
|
|
92
|
-
|
|
93
|
-
# Version 2026-08-20
|
|
94
|
-
|
|
95
|
-
import asyncio
|
|
96
|
-
import ipaddress
|
|
97
|
-
import mimetypes
|
|
98
|
-
import os
|
|
99
|
-
import re
|
|
100
|
-
import tomllib
|
|
101
|
-
from urllib.parse import urlsplit
|
|
102
|
-
|
|
103
|
-
from mitmproxy import connection, ctx, http, tls
|
|
104
|
-
from mitmproxy.net import tls as net_tls
|
|
105
|
-
from OpenSSL import SSL
|
|
106
|
-
|
|
107
|
-
Domain = str | list[str] | None
|
|
108
|
-
|
|
109
|
-
# TLS verification is skipped only for upstream connections to these loopback
|
|
110
|
-
# hosts (local dev servers with self-signed / mkcert certs).
|
|
111
|
-
LOOPBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
112
|
-
|
|
113
|
-
# type -> the set of keys an entry of that type must contain.
|
|
114
|
-
_CONFIG_SCHEMA: dict[str, set[str]] = {
|
|
115
|
-
"single": {"name", "file"},
|
|
116
|
-
"folder": {"name", "folder"},
|
|
117
|
-
"devserver": {"name", "url"},
|
|
118
|
-
"pcf": {"name", "folder"},
|
|
119
|
-
}
|
|
120
|
-
_OPTIONAL_KEYS = {"type", "domain", "disabled"}
|
|
121
|
-
|
|
122
|
-
# An "include" entry pulls in the rules from another config file. It is expanded
|
|
123
|
-
# away at load time, so it never reaches _validate_config / the request handler.
|
|
124
|
-
_INCLUDE_KEYS = {"path"}
|
|
125
|
-
_INCLUDE_OPTIONAL_KEYS = {"type", "disabled"}
|
|
126
|
-
|
|
127
|
-
# Keys the loader attaches to each rule internally; not user-provided.
|
|
128
|
-
_INTERNAL_KEYS = {"_base_dir"}
|
|
129
|
-
|
|
130
|
-
# Safety net against pathological include nesting (cycles are caught separately).
|
|
131
|
-
_MAX_INCLUDE_DEPTH = 20
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _config_path() -> str:
|
|
135
|
-
"""Absolute path of the TOML config file to load."""
|
|
136
|
-
override = os.environ.get("HUMMINGBIRD_PROXY_CONFIG")
|
|
137
|
-
if override:
|
|
138
|
-
return os.path.abspath(override)
|
|
139
|
-
return os.path.abspath(os.path.join(os.getcwd(), "proxy.config.toml"))
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def _load_config(path: str) -> list[dict]:
|
|
143
|
-
"""Load and parse the TOML config file into a list of rule dicts."""
|
|
144
|
-
try:
|
|
145
|
-
with open(path, "rb") as handle:
|
|
146
|
-
data = tomllib.load(handle)
|
|
147
|
-
except FileNotFoundError:
|
|
148
|
-
raise FileNotFoundError(
|
|
149
|
-
f"proxy config file not found: {path}. "
|
|
150
|
-
"Create a proxy.config.toml or set HUMMINGBIRD_PROXY_CONFIG."
|
|
151
|
-
)
|
|
152
|
-
except tomllib.TOMLDecodeError as err:
|
|
153
|
-
raise ValueError(f"invalid TOML in proxy config {path}: {err}")
|
|
154
|
-
rules = data.get("rules", [])
|
|
155
|
-
if not isinstance(rules, list):
|
|
156
|
-
raise ValueError("proxy config 'rules' must be an array of tables")
|
|
157
|
-
return rules
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _load_rules(path: str, _seen: frozenset[str] = frozenset(), _depth: int = 0) -> tuple[list[dict], set[str]]:
|
|
161
|
-
"""Load rules from a config file, expanding any "include" entries.
|
|
162
|
-
|
|
163
|
-
Each returned rule carries a "_base_dir" key giving the directory its
|
|
164
|
-
relative paths resolve against, so rules pulled in from another config keep
|
|
165
|
-
resolving against that config's own location. Returns
|
|
166
|
-
(rules, config_paths) where config_paths is every config file touched, used
|
|
167
|
-
to watch them all for changes.
|
|
168
|
-
"""
|
|
169
|
-
path = os.path.abspath(path)
|
|
170
|
-
if path in _seen:
|
|
171
|
-
raise ValueError(f"include cycle detected at config {path}")
|
|
172
|
-
if _depth > _MAX_INCLUDE_DEPTH:
|
|
173
|
-
raise ValueError(f"include nesting too deep (>{_MAX_INCLUDE_DEPTH}) at {path}")
|
|
174
|
-
_seen = _seen | {path}
|
|
175
|
-
base_dir = os.path.dirname(path)
|
|
176
|
-
rules: list[dict] = []
|
|
177
|
-
config_paths: set[str] = {path}
|
|
178
|
-
|
|
179
|
-
for index, item in enumerate(_load_config(path)):
|
|
180
|
-
if isinstance(item, dict) and item.get("type") == "include":
|
|
181
|
-
label = f"{path} include[{index}]"
|
|
182
|
-
missing = _INCLUDE_KEYS - item.keys()
|
|
183
|
-
if missing:
|
|
184
|
-
raise ValueError(f"{label}: missing keys {sorted(missing)}")
|
|
185
|
-
unknown = item.keys() - (_INCLUDE_KEYS | _INCLUDE_OPTIONAL_KEYS)
|
|
186
|
-
if unknown:
|
|
187
|
-
raise ValueError(f"{label}: unknown keys {sorted(unknown)}")
|
|
188
|
-
if item.get("disabled", False):
|
|
189
|
-
continue
|
|
190
|
-
target = item["path"]
|
|
191
|
-
if not os.path.isabs(target):
|
|
192
|
-
target = os.path.normpath(os.path.join(base_dir, target))
|
|
193
|
-
sub_rules, sub_paths = _load_rules(target, _seen, _depth + 1)
|
|
194
|
-
rules.extend(sub_rules)
|
|
195
|
-
config_paths |= sub_paths
|
|
196
|
-
elif isinstance(item, dict):
|
|
197
|
-
rules.append({**item, "_base_dir": base_dir})
|
|
198
|
-
else:
|
|
199
|
-
rules.append(item)
|
|
200
|
-
|
|
201
|
-
return rules, config_paths
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def _resolve_path(path: str, base_dir: str) -> str:
|
|
205
|
-
"""Convert a path to absolute, relative to its config file's directory."""
|
|
206
|
-
if os.path.isabs(path):
|
|
207
|
-
return path
|
|
208
|
-
return os.path.normpath(os.path.join(base_dir, path))
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def _validate_config(config: list[dict]) -> list[dict]:
|
|
212
|
-
"""Validate CONFIG and return the entries that are not disabled."""
|
|
213
|
-
enabled: list[dict] = []
|
|
214
|
-
for index, item in enumerate(config):
|
|
215
|
-
label = f"CONFIG[{index}]"
|
|
216
|
-
if not isinstance(item, dict):
|
|
217
|
-
raise ValueError(f"{label}: expected a dict, got {type(item).__name__}")
|
|
218
|
-
item_type = item.get("type")
|
|
219
|
-
if item_type not in _CONFIG_SCHEMA:
|
|
220
|
-
raise ValueError(
|
|
221
|
-
f"{label}: unknown or missing 'type' {item_type!r}; "
|
|
222
|
-
f"expected one of {sorted(_CONFIG_SCHEMA)}"
|
|
223
|
-
)
|
|
224
|
-
required = _CONFIG_SCHEMA[item_type]
|
|
225
|
-
missing = required - item.keys()
|
|
226
|
-
if missing:
|
|
227
|
-
raise ValueError(f"{label} (type={item_type!r}): missing keys {sorted(missing)}")
|
|
228
|
-
unknown = item.keys() - (required | _OPTIONAL_KEYS | _INTERNAL_KEYS)
|
|
229
|
-
if unknown:
|
|
230
|
-
raise ValueError(f"{label} (type={item_type!r}): unknown keys {sorted(unknown)}")
|
|
231
|
-
if not item.get("disabled", False):
|
|
232
|
-
enabled.append(item)
|
|
233
|
-
return enabled
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def _domain_matches(domain: Domain, host: str) -> bool:
|
|
237
|
-
if domain is None:
|
|
238
|
-
return True
|
|
239
|
-
host = host.lower()
|
|
240
|
-
if isinstance(domain, str):
|
|
241
|
-
return host == domain.lower()
|
|
242
|
-
return any(host == d.lower() for d in domain)
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
def _web_resource_name(request: http.Request) -> str | None:
|
|
246
|
-
"""Return the path following the ``webresources`` segment, or None."""
|
|
247
|
-
components = request.path_components
|
|
248
|
-
for i, component in enumerate(components):
|
|
249
|
-
if component.lower() == "webresources":
|
|
250
|
-
name = "/".join(components[i + 1:])
|
|
251
|
-
# path_components drops a trailing slash; keep it so dev-server root
|
|
252
|
-
# requests (e.g. the Vite HMR base URL) still match a rule prefix.
|
|
253
|
-
if name and request.path.split("?", 1)[0].endswith("/"):
|
|
254
|
-
name += "/"
|
|
255
|
-
return name
|
|
256
|
-
return None
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
_pcf_patterns: dict[str, re.Pattern[str]] = {}
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
def _pcf_match(path: str, name: str) -> tuple[str, bool] | None:
|
|
263
|
-
"""Match a PCF asset URL. Returns (relative asset path, is_css) or None."""
|
|
264
|
-
pattern = _pcf_patterns.get(name)
|
|
265
|
-
if pattern is None:
|
|
266
|
-
pattern = re.compile(
|
|
267
|
-
r"(?P<css>/css)?(?:/(?:cc_)?|cc_)"
|
|
268
|
-
+ re.escape(name)
|
|
269
|
-
+ r"(?:\.|/)(?P<rest>[^?]*)",
|
|
270
|
-
re.IGNORECASE,
|
|
271
|
-
)
|
|
272
|
-
_pcf_patterns[name] = pattern
|
|
273
|
-
match = pattern.search(path)
|
|
274
|
-
if match is None:
|
|
275
|
-
return None
|
|
276
|
-
return match.group("rest"), bool(match.group("css"))
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _log_redirect(flow: http.HTTPFlow, rule_type: str, rule_name: str, destination: str) -> None:
|
|
280
|
-
ctx.log.info(
|
|
281
|
-
f"Redirected {flow.request.pretty_url} via {rule_type}:{rule_name} -> {destination}"
|
|
282
|
-
)
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def _serve_file(flow: http.HTTPFlow, filepath: str, rule_type: str, rule_name: str, base_dir: str) -> None:
|
|
286
|
-
absolute_path = _resolve_path(filepath, base_dir)
|
|
287
|
-
try:
|
|
288
|
-
with open(absolute_path, "rb") as handle:
|
|
289
|
-
content = handle.read()
|
|
290
|
-
except (FileNotFoundError, IsADirectoryError):
|
|
291
|
-
ctx.log.warn(f"Local file not found for {rule_type}:{rule_name} -> {absolute_path}")
|
|
292
|
-
flow.response = http.Response.make(
|
|
293
|
-
404,
|
|
294
|
-
f"dataverseproxy: local file not found: {absolute_path}".encode(),
|
|
295
|
-
{"Content-Type": "text/plain"},
|
|
296
|
-
)
|
|
297
|
-
return
|
|
298
|
-
content_type = mimetypes.guess_type(absolute_path)[0] or "application/octet-stream"
|
|
299
|
-
flow.response = http.Response.make(200, content, {"Content-Type": content_type})
|
|
300
|
-
_log_redirect(flow, rule_type, rule_name, absolute_path)
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
def _proxy_to_dev_server(flow: http.HTTPFlow, url: str, web_resource: str, rule_name: str) -> None:
|
|
304
|
-
target = urlsplit(url)
|
|
305
|
-
query = flow.request.path.split("?", 1)[1] if "?" in flow.request.path else ""
|
|
306
|
-
new_path = "/webresources/" + web_resource
|
|
307
|
-
if query:
|
|
308
|
-
new_path += "?" + query
|
|
309
|
-
flow.request.scheme = target.scheme
|
|
310
|
-
flow.request.host = target.hostname or "localhost"
|
|
311
|
-
flow.request.port = target.port or (443 if target.scheme == "https" else 80)
|
|
312
|
-
flow.request.path = new_path
|
|
313
|
-
_log_redirect(flow, "devserver", rule_name, f"{url}{new_path}")
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
# How often (seconds) to poll the config file for changes.
|
|
317
|
-
_CONFIG_POLL_INTERVAL = 1.0
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
def _config_mtime(path: str) -> float | None:
|
|
321
|
-
"""Modification time of the config file, or None if it is missing."""
|
|
322
|
-
try:
|
|
323
|
-
return os.path.getmtime(path)
|
|
324
|
-
except OSError:
|
|
325
|
-
return None
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
def _config_mtimes(paths: set[str]) -> dict[str, float | None]:
|
|
329
|
-
"""Modification times for every config file, keyed by path."""
|
|
330
|
-
return {path: _config_mtime(path) for path in paths}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
class DataverseProxy:
|
|
334
|
-
def __init__(self, config_path: str) -> None:
|
|
335
|
-
self.config_path = config_path
|
|
336
|
-
rules, self._config_paths = _load_rules(config_path)
|
|
337
|
-
self.config = _validate_config(rules)
|
|
338
|
-
self._config_mtimes = _config_mtimes(self._config_paths)
|
|
339
|
-
self._watch_task: asyncio.Task | None = None
|
|
340
|
-
|
|
341
|
-
def running(self) -> None:
|
|
342
|
-
# Start watching the config files for changes once the event loop is up.
|
|
343
|
-
if self._watch_task is None:
|
|
344
|
-
self._watch_task = asyncio.ensure_future(self._watch_config())
|
|
345
|
-
|
|
346
|
-
def done(self) -> None:
|
|
347
|
-
if self._watch_task is not None:
|
|
348
|
-
self._watch_task.cancel()
|
|
349
|
-
self._watch_task = None
|
|
350
|
-
|
|
351
|
-
async def _watch_config(self) -> None:
|
|
352
|
-
while True:
|
|
353
|
-
await asyncio.sleep(_CONFIG_POLL_INTERVAL)
|
|
354
|
-
mtimes = _config_mtimes(self._config_paths)
|
|
355
|
-
if mtimes == self._config_mtimes:
|
|
356
|
-
continue
|
|
357
|
-
self._config_mtimes = mtimes
|
|
358
|
-
self._reload_config()
|
|
359
|
-
|
|
360
|
-
def _reload_config(self) -> None:
|
|
361
|
-
try:
|
|
362
|
-
rules, paths = _load_rules(self.config_path)
|
|
363
|
-
config = _validate_config(rules)
|
|
364
|
-
except (OSError, ValueError) as err:
|
|
365
|
-
ctx.log.warn(f"Config reload failed, keeping previous rules: {err}")
|
|
366
|
-
return
|
|
367
|
-
self.config = config
|
|
368
|
-
self._config_paths = paths
|
|
369
|
-
self._config_mtimes = _config_mtimes(paths)
|
|
370
|
-
_pcf_patterns.clear()
|
|
371
|
-
ctx.log.info(
|
|
372
|
-
f"Reloaded proxy config from {self.config_path} "
|
|
373
|
-
f"({len(config)} active rules across {len(paths)} files)"
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
def tls_start_server(self, data: tls.TlsData) -> None:
|
|
377
|
-
# Provide a no-verify TLS context for localhost dev servers only. This runs
|
|
378
|
-
# before the built-in TlsConfig addon; once data.ssl_conn is set, TlsConfig
|
|
379
|
-
# skips the connection, so every other (real) upstream keeps full
|
|
380
|
-
# certificate verification.
|
|
381
|
-
server = data.conn
|
|
382
|
-
if data.ssl_conn is not None or not isinstance(server, connection.Server):
|
|
383
|
-
return
|
|
384
|
-
if not server.address or server.address[0] not in LOOPBACK_HOSTS:
|
|
385
|
-
return
|
|
386
|
-
|
|
387
|
-
ssl_ctx = net_tls.create_proxy_server_context(
|
|
388
|
-
method=net_tls.Method.TLS_CLIENT_METHOD,
|
|
389
|
-
min_version=net_tls.Version[ctx.options.tls_version_server_min],
|
|
390
|
-
max_version=net_tls.Version[ctx.options.tls_version_server_max],
|
|
391
|
-
cipher_list=None,
|
|
392
|
-
ecdh_curve=None,
|
|
393
|
-
verify=net_tls.Verify.VERIFY_NONE,
|
|
394
|
-
ca_path=None,
|
|
395
|
-
ca_pemfile=None,
|
|
396
|
-
client_cert=None,
|
|
397
|
-
legacy_server_connect=False,
|
|
398
|
-
)
|
|
399
|
-
ssl_conn = SSL.Connection(ssl_ctx)
|
|
400
|
-
sni = server.sni or server.address[0]
|
|
401
|
-
try:
|
|
402
|
-
ipaddress.ip_address(sni)
|
|
403
|
-
except ValueError:
|
|
404
|
-
ssl_conn.set_tlsext_host_name(sni.encode("idna"))
|
|
405
|
-
alpn_offers = server.alpn_offers or data.context.client.alpn_offers
|
|
406
|
-
if alpn_offers:
|
|
407
|
-
ssl_conn.set_alpn_protos(list(alpn_offers))
|
|
408
|
-
ssl_conn.set_connect_state()
|
|
409
|
-
data.ssl_conn = ssl_conn
|
|
410
|
-
|
|
411
|
-
def request(self, flow: http.HTTPFlow) -> None:
|
|
412
|
-
host = flow.request.pretty_host
|
|
413
|
-
web_resource = _web_resource_name(flow.request)
|
|
414
|
-
|
|
415
|
-
for item in self.config:
|
|
416
|
-
if not _domain_matches(item.get("domain"), host):
|
|
417
|
-
continue
|
|
418
|
-
|
|
419
|
-
item_type = item["type"]
|
|
420
|
-
|
|
421
|
-
if item_type == "devserver":
|
|
422
|
-
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
423
|
-
_proxy_to_dev_server(flow, item["url"], web_resource, item["name"])
|
|
424
|
-
return
|
|
425
|
-
|
|
426
|
-
elif item_type == "single":
|
|
427
|
-
if web_resource == item["name"]:
|
|
428
|
-
_serve_file(flow, item["file"], item_type, item["name"], item["_base_dir"])
|
|
429
|
-
return
|
|
430
|
-
|
|
431
|
-
elif item_type == "folder":
|
|
432
|
-
if web_resource is not None and web_resource.startswith(item["name"]):
|
|
433
|
-
relative = web_resource[len(item["name"]):]
|
|
434
|
-
_serve_file(
|
|
435
|
-
flow,
|
|
436
|
-
os.path.join(item["folder"], *relative.split("/")),
|
|
437
|
-
item_type,
|
|
438
|
-
item["name"],
|
|
439
|
-
item["_base_dir"],
|
|
440
|
-
)
|
|
441
|
-
return
|
|
442
|
-
|
|
443
|
-
elif item_type == "pcf":
|
|
444
|
-
hit = _pcf_match(flow.request.path, item["name"])
|
|
445
|
-
if hit is not None:
|
|
446
|
-
relative, is_css = hit
|
|
447
|
-
parts = [item["folder"]]
|
|
448
|
-
if is_css:
|
|
449
|
-
parts.append("css")
|
|
450
|
-
parts.extend(segment for segment in relative.split("/") if segment)
|
|
451
|
-
_serve_file(flow, os.path.join(*parts), item_type, item["name"], item["_base_dir"])
|
|
452
|
-
return
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
CONFIG_PATH = _config_path()
|
|
11
|
+
from hummingbird_proxy import DataverseProxy, config_path
|
|
456
12
|
|
|
457
|
-
addons = [DataverseProxy(
|
|
13
|
+
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"
|