Skip to content

feat(nodes): hysteria2 inbound kind — creds in DB + config generation - #123

Merged
Postlog merged 4 commits into
mainfrom
feat/hysteria2-render
Aug 11, 2026
Merged

feat(nodes): hysteria2 inbound kind — creds in DB + config generation#123
Postlog merged 4 commits into
mainfrom
feat/hysteria2-render

Conversation

@Postlog

@Postlog Postlog commented Aug 11, 2026

Copy link
Copy Markdown
Owner

A node inbound can now be kind hysteria2 (alongside panel-managed vless), with its
client creds stored in subgen and rendered as a plain mihomo type: hysteria2 node. This
backs the RU1 China dual-stack (Design A): hysteria2 has no 3x-ui inbound (Xray-core can't
host it), so subgen sources it from stored creds instead of the panel. Access is granted
via user_connections, identically to vless
— both are full inbounds.

What's in it

  • migration 0005: node_inbounds + kind + settings (JSON)
  • entity: Inbound.Kind + Hysteria2Settings; Connection.Kind
  • nodes repo: persist/load kind + creds (create/update/get/list)
  • fleet: render hysteria2 proxies from user_connections + stored creds (DB, not panel)
  • render: proxyToMap emits type: hysteria2
  • provisioning: skip hysteria2 inbounds on panels (no client; avoids a same-port VLESS
    mismatch in panelLookup)
  • API: node_save accepts kind + creds (password required); nodes_get returns
    kind + non-secret params (password is write-only)
  • admin UI: kind selector + cred fields in the node form

Testing

Repo round-trip, fleet-build generation, handler mapping, and render — unit + integration
all green
; gofmt clean; node --check on app.js OK.

Known follow-up

On re-edit the hysteria2 password must be re-entered (write-only; empty → "password
required", a safe failure, not silent loss). A "empty keeps existing" preserve in the repo
Update would smooth this.

Server side (Design A on RU1) is already deployed + validated and lives in the
vpn-toolchain repo (hysteria2/, docs/hysteria2.md).

🤖 Generated with Claude Code

A node inbound can now be kind "hysteria2" (alongside panel-managed "vless"), with its
client creds stored in subgen and rendered as a plain mihomo hysteria2 node. This backs
the RU1 China dual-stack (Design A): hysteria2 has no 3x-ui inbound (Xray-core can't host
it), so subgen sources it from stored creds instead of the panel. Access is still granted
via user_connections, identically to vless.

- migration 0005: node_inbounds + kind + settings (JSON)
- entity: Inbound.Kind + Hysteria2Settings; Connection.Kind
- nodes repo: persist/load kind + creds (create/update/get/list)
- fleet: render hysteria2 proxies from user_connections + stored creds (DB, not panel)
- render: proxyToMap emits type: hysteria2
- provisioning: skip hysteria2 inbounds on panels (no client; avoids a same-port VLESS
  mismatch in panelLookup)
- API: node_save accepts kind + creds (password required); nodes_get returns kind +
  non-secret params (password write-only)
- admin UI: kind selector + cred fields in the node form

Tests: repo round-trip, fleet-build generation, handler mapping, render — unit +
integration all green. Follow-up: on re-edit the hysteria2 password must be re-entered
(write-only; empty -> "password required", a safe failure, not silent loss).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Comment thread internal/entity/fleet.go
Comment thread internal/entity/fleet.go Outdated
// Protocol selects the mihomo proxy type. Empty (the default) and "vless" render a
// VLESS proxy; "hysteria2" renders the QUIC outer transport (see the hysteria2 fields
// below). Kept optional so every existing (panel-sourced) proxy stays VLESS untouched.
Protocol string

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Empty (the default)

Никаких дефолтов. Если нужна миграция в бд - надо ее добавить

Comment thread internal/entity/fleet.go Outdated
// DialerProxy chains this proxy THROUGH another proxy/group (mihomo `dialer-proxy`).
// Used by the inner VLESS hop to ride the outer hysteria2 (Design C), so the inner
// carries the per-user UUID into in-12466 while hysteria2 crosses the GFW.
DialerProxy string

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Зачем нам DialerProxy - мы же отказались от vless over hy2?

Comment thread internal/entity/node.go Outdated
ID int64 // node_inbounds.id (0 until persisted); referenced by user_connections
Name string // ASCII letters/digits/-, unique within the node (e.g. "force")
Port int
Kind string // "" / "vless" (panel-managed) | "hysteria2" (static; Hysteria2 set)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

То же самое что и выше - пустое значение (дефолтное) недопустимо

Comment thread internal/entity/node.go
Comment thread internal/handlers/nodes_get/handler.go Outdated
for _, in := range n.Inbounds {
inbounds = append(inbounds, oas.NodesGetOKNodesItemInboundsItem{ID: in.ID, Name: in.Name, Port: in.Port})
item := oas.NodesGetOKNodesItemInboundsItem{ID: in.ID, Name: in.Name, Port: in.Port}
if in.Kind != "" {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

пустые недопустимы, не нужно проверять на пустоту

Comment thread internal/handlers/nodes_get/handler.go Outdated
}
// Return the non-secret hysteria2 params so the edit form prefills them; the
// password is write-only and deliberately never sent back.
if in.IsHysteria2() && in.Hysteria2 != nil {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Достаточно проверять in.Hysteria2 != nil

Comment thread internal/repository/nodes/settings.go Outdated
// vless) and the JSON `settings` blob (the hysteria2 creds; empty for vless).
func inboundKindSettings(in entity.Inbound) (kind, settings string) {
kind = in.Kind
if kind == "" {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

пустое значение недопустимо

Comment thread migrations/0005-inbound-kind.sql Outdated
-- settings — no behaviour change. A hysteria2 inbound is NOT provisioned on 3x-ui (Xray has
-- no hysteria2): the fleet renders it from `settings`, and provisioning skips it.
ALTER TABLE node_inbounds ADD COLUMN kind TEXT NOT NULL DEFAULT 'vless';
ALTER TABLE node_inbounds ADD COLUMN settings TEXT NOT NULL DEFAULT '';

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

почему settings not null? Должны быть nullable, для vless нет сеттингов

Comment thread internal/service/fleet/build.go
- entity.Proxy: split into discriminated VLESSProxy / Hysteria2Proxy variants
  (no fat union struct), dropping the Protocol string and the dead DialerProxy
  field (a leftover from the abandoned vless-over-hysteria2 design)
- Inbound.Kind is always explicit (vless|hysteria2): the handler defaults to
  vless, no empty-string default in the repo; migration backfills existing rows
- entity.Hysteria2Settings: drop json tags — (un)marshalled via a repo-local DTO
  at the persistence boundary
- migration 0005: settings is NULLABLE (NULL for vless, JSON for hysteria2)
- nodes_get: emit kind unconditionally; simplify the guard to in.Hysteria2 != nil
- tests updated (render / build / repo / handlers) — unit + integration green

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- gosec G101/G117: nolint on the hysteria2 "requires a password" validation
  message (a user-facing string) and on the settings-blob Marshal (the password
  is intentionally persisted)
- wsl_v5: blank line before the two range loops in syncPanels — the `if …
  continue` first statement no longer shares the just-declared map, so the
  cuddle isn't allowed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Kind is now required and validated on every inbound (vless | hysteria2):
- vless inbounds must carry no hysteria2 settings;
- hysteria2 inbounds are validated in full — password required; obfs (when
  set) must be "salamander" and obfs + obfs-password are all-or-nothing
  (Salamander needs a password); up/down (when set) must be a bandwidth;
  SNI (when set) must be a valid host.

Adds five ErrValidation* sentinels + their node_save messages/mapErr cases,
and TestValidateNode / TestIsBandwidth coverage. Existing fixtures now set
Kind explicitly since an empty kind is rejected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Postlog
Postlog merged commit 2ce45d0 into main Aug 11, 2026
8 checks passed
@Postlog
Postlog deleted the feat/hysteria2-render branch August 11, 2026 20:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants