P9(3): per-inbound forwarding module (amo-style form, dynamic egress compile)

Design as decided 2026-10-21 (replaces the raw-JSON egress editor idea):
the forwarding form lives on the inbound, and the entry agent's egress
entries are compiled from it on every config pull — never snapshotted.

- model: Inbound.Forwarding (JSON text) via AutoMigrate; panel-side
  metadata only, never reaches xray itself
- service/forwarding.go: ForwardingSpec (mode direct/forward; landing
  managed|manual), save-time validation (managed target must exist and
  be a SOCKS-family landing — the fork's SK5 inbound is 'mixed'; no
  self-forward; manual endpoint/protocol sanity), live resolution
  (managed: concrete listen IP wins, else hosting node's address;
  credentials from the target's first account), and the compiler that
  appends one socks/http outbound (tag fwd-<inboundId>) plus a prepended
  inboundTag rule per forwarding inbound
- buildXrayConfig agent path: compile runs after applyAgentEgress, so
  entries ride on the live template (or stored egress override) and
  template edits keep flowing; unresolvable entries skip with a warning
  instead of failing the whole config
- UpdateInbound's field-merge now carries Forwarding (its absence was
  caught by the smoke: adds worked, updates silently kept the old spec)
- GET /panel/api/inbounds/forwardTargets: managed-landing candidates
  (id/remark/tag/listen/port/node name) — credentials never exposed
- frontend: new Forwarding tab in the inbound form (mode, landing type,
  managed picker, manual endpoint with link import incl. base64 form),
  adapter round-trips the spec as a JSON string like settings; en/zh
  strings; generated API types refreshed

Acceptance: unit tests for parse/validate/resolve/compile incl. the
dynamic-recompile proof (template edit + spec edit both reflected), and
an end-to-end smoke (real binary, two agent nodes, all inbound writes
in browser form-urlencoded format): 22/22 PASS — see P9-REPORT.md.
This commit is contained in:
Muse
2026-10-21 17:40:42 +00:00
parent ceca96571d
commit f6d652019d
16 changed files with 874 additions and 4 deletions
+55
View File
@@ -67,6 +67,7 @@ func (a *InboundController) broadcastInboundsUpdate(userId int) {
func (a *InboundController) initRouter(g *gin.RouterGroup) {
g.GET("/list", a.getInbounds)
g.GET("/list/slim", a.getInboundsSlim)
g.GET("/forwardTargets", a.getForwardTargets)
g.GET("/options", a.getInboundOptions)
g.GET("/allLinks", a.getAllInboundLinks)
g.GET("/get/:id", a.getInbound)
@@ -97,6 +98,60 @@ func (a *InboundController) getInbounds(c *gin.Context) {
jsonObj(c, inbounds, nil)
}
// forwardTarget is one selectable managed landing for the inbound
// forwarding form. Credentials are deliberately absent: the landing's
// address/port/account are resolved server-side when the entry agent's
// config is generated.
type forwardTarget struct {
Id int `json:"id"`
Remark string `json:"remark"`
Tag string `json:"tag"`
Protocol string `json:"protocol"`
Listen string `json:"listen"`
Port int `json:"port"`
Enable bool `json:"enable"`
NodeId *int `json:"nodeId"`
NodeName string `json:"nodeName"`
}
// getForwardTargets lists the SOCKS-family (mixed) inbounds that can be
// picked as a managed forwarding landing.
func (a *InboundController) getForwardTargets(c *gin.Context) {
user := session.GetLoginUser(c)
inbounds, err := a.inboundService.GetInbounds(user.Id)
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
return
}
nodeService := service.NodeService{}
nodes, _ := nodeService.GetAll()
nodeNames := make(map[int]string, len(nodes))
for _, n := range nodes {
nodeNames[n.Id] = n.Name
}
targets := make([]forwardTarget, 0)
for _, inbound := range inbounds {
if inbound.Protocol != model.Mixed && inbound.Protocol != model.Protocol("socks") {
continue
}
t := forwardTarget{
Id: inbound.Id,
Remark: inbound.Remark,
Tag: inbound.Tag,
Protocol: string(inbound.Protocol),
Listen: inbound.Listen,
Port: inbound.Port,
Enable: inbound.Enable,
NodeId: inbound.NodeID,
}
if inbound.NodeID != nil {
t.NodeName = nodeNames[*inbound.NodeID]
}
targets = append(targets, t)
}
jsonObj(c, targets, nil)
}
// getInboundsSlim is the list-page variant that strips full client
// payloads from settings.clients[]. Detail-view flows still use /get/:id.
func (a *InboundController) getInboundsSlim(c *gin.Context) {