Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ python -m skillopt_webui.app
| `--host` | `127.0.0.1` | Bind address |
| `--share` | off | Create a public Gradio share link |

The default host is loopback-only; pass `--host 0.0.0.0` to expose the dashboard.
The default host is loopback-only. Exposing the dashboard (`--host 0.0.0.0` or `--share`) requires
`--auth-user`/`--auth-pass` (or `SKILLOPT_WEBUI_USER`/`SKILLOPT_WEBUI_PASS`).

---

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/first-experiment.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ python -m skillopt_webui.app
```

Then open `http://localhost:7860` in your browser to configure parameters and
launch training. The default host is `0.0.0.0`; pass `--host 127.0.0.1` for a
local-only dashboard.
launch training. The default host is `127.0.0.1`; exposing it with `--host 0.0.0.0`
requires `--auth-user`/`--auth-pass`.

## Next Steps

Expand Down
3 changes: 2 additions & 1 deletion docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -447,4 +447,5 @@ python -m skillopt_webui.app [--port PORT] [--share]
| `--host` | `127.0.0.1` | Server bind address |
| `--share` | false | Create public Gradio link |

The default host binds loopback only; pass `--host 0.0.0.0` to expose the dashboard.
The default host binds loopback only. Exposing the dashboard (`--host 0.0.0.0` or `--share`) requires
`--auth-user`/`--auth-pass` (or `SKILLOPT_WEBUI_USER`/`SKILLOPT_WEBUI_PASS`).
21 changes: 19 additions & 2 deletions skillopt_webui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -760,12 +760,16 @@ def main():
parser.add_argument("--port", type=int, default=7860)
parser.add_argument("--share", action="store_true")
parser.add_argument("--host", type=str, default="127.0.0.1",
help="Server host. Default is localhost; use 0.0.0.0 "
"to expose publicly (no auth, use with care).")
help="Server host. Default is localhost; binding any "
"other address requires --auth-user/--auth-pass.")
parser.add_argument("--auth-user", type=str, default=None,
help="Username for basic auth (or set SKILLOPT_WEBUI_USER).")
parser.add_argument("--auth-pass", type=str, default=None,
help="Password for basic auth (or set SKILLOPT_WEBUI_PASS).")
parser.add_argument("--allow-unauthenticated", action="store_true",
help="Allow --share or a non-loopback --host without "
"auth. Anyone who can reach the UI can then launch "
"training jobs with your API keys.")
args = parser.parse_args()

if args.host and args.host not in ("127.0.0.1", "localhost", "::1"):
Expand Down Expand Up @@ -803,6 +807,19 @@ def main():
)
sys.exit(1)

# The training controls launch jobs with the API keys from .env and
# .secrets/, so never expose them beyond loopback without login unless
# the operator explicitly opts in.
exposed = args.share or args.host not in ("127.0.0.1", "localhost", "::1")
if exposed and not auth and not args.allow_unauthenticated:
print(
"Refusing to start: --share / a non-loopback --host requires "
"--auth-user and --auth-pass (or SKILLOPT_WEBUI_USER and "
"SKILLOPT_WEBUI_PASS). Pass --allow-unauthenticated to override.",
file=sys.stderr,
)
sys.exit(1)

app = build_ui()
launch_kwargs = build_launch_kwargs(args.host, args.port, args.share)
if auth:
Expand Down
38 changes: 36 additions & 2 deletions tests/test_webui_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def test_main_warns_on_public_host(webui, monkeypatch, capsys):
app_mock = mock.MagicMock()
app_mock.launch = launcher
monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock)
monkeypatch.setattr(sys, "argv", ["app.py", "--host", "0.0.0.0"])
monkeypatch.setattr(sys, "argv", ["app.py", "--host", "0.0.0.0", "--allow-unauthenticated"])

webui_mod.main()

Expand All @@ -65,7 +65,7 @@ def test_main_warns_on_share(webui, monkeypatch, capsys):
app_mock = mock.MagicMock()
app_mock.launch = launcher
monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock)
monkeypatch.setattr(sys, "argv", ["app.py", "--share"])
monkeypatch.setattr(sys, "argv", ["app.py", "--share", "--allow-unauthenticated"])

webui_mod.main()

Expand All @@ -74,6 +74,40 @@ def test_main_warns_on_share(webui, monkeypatch, capsys):
assert "public" in captured.err.lower() or "tunnel" in captured.err.lower()


@pytest.mark.parametrize("argv", [["--host", "0.0.0.0"], ["--share"]])
def test_main_refuses_exposed_without_auth(webui, monkeypatch, argv):
"""A public bind or share link without auth must fail closed (never launch)."""
webui_mod = webui
launcher = mock.MagicMock()
app_mock = mock.MagicMock()
app_mock.launch = launcher
monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock)
monkeypatch.setattr(sys, "argv", ["app.py", *argv])
monkeypatch.delenv("SKILLOPT_WEBUI_USER", raising=False)
monkeypatch.delenv("SKILLOPT_WEBUI_PASS", raising=False)
with pytest.raises(SystemExit):
webui_mod.main()
launcher.assert_not_called()


def test_main_allows_exposed_with_auth(webui, monkeypatch):
"""A public bind with auth configured must launch with that auth."""
webui_mod = webui
launcher = mock.MagicMock()
app_mock = mock.MagicMock()
app_mock.launch = launcher
monkeypatch.setattr(webui_mod, "build_ui", lambda: app_mock)
monkeypatch.setattr(
sys, "argv", ["app.py", "--host", "0.0.0.0", "--auth-user", "admin", "--auth-pass", "s3cret"]
)

webui_mod.main()

_args, kwargs = launcher.call_args
assert kwargs["server_name"] == "0.0.0.0"
assert kwargs.get("auth") == ("admin", "s3cret")


def test_main_auth_via_cli_args(webui, monkeypatch):
"""--auth-user and --auth-pass must enable Gradio basic auth."""
webui_mod = webui
Expand Down
Loading