Skip to content
Open
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,8 @@
sing-box-reference
.agents
.devcontainer
start-instance.sh
start-instance.sh
data/
start.bat
resin.exe
.gitignore
9 changes: 9 additions & 0 deletions cmd/resin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ func newTopologyRuntime(
// No NotifyNodeDirty here — AddNodeFromSub already notifies all platforms.
probeMgr.TriggerImmediateEgressProbe(hash)
})
pool.SetOnNodeUpdated(func(hash node.Hash) {
if onNodeRemoved != nil {
onNodeRemoved(hash)
}
outboundMgr.EnsureNodeOutbound(hash)
probeMgr.TriggerImmediateEgressProbe(hash)
probeMgr.TriggerImmediateLatencyProbe(hash)
})
pool.SetOnNodeRemoved(func(hash node.Hash, entry *node.NodeEntry) {
markNodeRemovedDirty(engine, hash, entry)
outboundMgr.RemoveNodeOutbound(entry)
Expand Down Expand Up @@ -380,6 +388,7 @@ func bootstrapTopology(
sub.SetFetchConfig(ms.URL, ms.UpdateIntervalNs)
sub.SetSourceType(ms.SourceType)
sub.SetContent(ms.Content)
sub.SetUpstreamSubscriptionID(ms.UpstreamSubscriptionID)
sub.SetEphemeralNodeEvictDelayNs(ms.EphemeralNodeEvictDelayNs)
sub.CreatedAtNs = ms.CreatedAtNs
sub.UpdatedAtNs = ms.UpdatedAtNs
Expand Down
78 changes: 78 additions & 0 deletions internal/api/subscription_local_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,81 @@ func TestAPIContract_SubscriptionSourceTypeReadOnlyOnPatch(t *testing.T) {
}
assertErrorCode(t, rec, "INVALID_ARGUMENT")
}

func TestAPIContract_SubscriptionUpstreamCreateAndPatchValidation(t *testing.T) {
srv, _, _ := newControlPlaneTestServer(t)

createARec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{
"name": "sub-a",
"url": "https://example.com/a",
}, true)
if createARec.Code != http.StatusCreated {
t.Fatalf("create sub-a status: got %d, want %d, body=%s", createARec.Code, http.StatusCreated, createARec.Body.String())
}
createABody := decodeJSONMap(t, createARec)
subAID, _ := createABody["id"].(string)
if subAID == "" {
t.Fatalf("create sub-a missing id: body=%s", createARec.Body.String())
}

createBRec := doJSONRequest(t, srv, http.MethodPost, "/api/v1/subscriptions", map[string]any{
"name": "sub-b",
"url": "https://example.com/b",
"upstream_subscription_id": subAID,
}, true)
if createBRec.Code != http.StatusCreated {
t.Fatalf("create sub-b with upstream status: got %d, want %d, body=%s", createBRec.Code, http.StatusCreated, createBRec.Body.String())
}
createBBody := decodeJSONMap(t, createBRec)
subBID, _ := createBBody["id"].(string)
if subBID == "" {
t.Fatalf("create sub-b missing id: body=%s", createBRec.Body.String())
}
if got, _ := createBBody["upstream_subscription_id"].(string); got != subAID {
t.Fatalf("create sub-b upstream_subscription_id: got %q, want %q", got, subAID)
}

getBRec := doJSONRequest(t, srv, http.MethodGet, "/api/v1/subscriptions/"+subBID, nil, true)
if getBRec.Code != http.StatusOK {
t.Fatalf("get sub-b status: got %d, want %d, body=%s", getBRec.Code, http.StatusOK, getBRec.Body.String())
}
getBBody := decodeJSONMap(t, getBRec)
if got, _ := getBBody["upstream_subscription_id"].(string); got != subAID {
t.Fatalf("get sub-b upstream_subscription_id: got %q, want %q", got, subAID)
}

selfRec := doJSONRequest(t, srv, http.MethodPatch, "/api/v1/subscriptions/"+subBID, map[string]any{
"upstream_subscription_id": subBID,
}, true)
if selfRec.Code != http.StatusBadRequest {
t.Fatalf("patch self upstream status: got %d, want %d, body=%s", selfRec.Code, http.StatusBadRequest, selfRec.Body.String())
}
assertErrorCode(t, selfRec, "INVALID_ARGUMENT")

missingRec := doJSONRequest(t, srv, http.MethodPatch, "/api/v1/subscriptions/"+subBID, map[string]any{
"upstream_subscription_id": "11111111-1111-1111-1111-111111111111",
}, true)
if missingRec.Code != http.StatusBadRequest {
t.Fatalf("patch missing upstream status: got %d, want %d, body=%s", missingRec.Code, http.StatusBadRequest, missingRec.Body.String())
}
assertErrorCode(t, missingRec, "INVALID_ARGUMENT")

cycleRec := doJSONRequest(t, srv, http.MethodPatch, "/api/v1/subscriptions/"+subAID, map[string]any{
"upstream_subscription_id": subBID,
}, true)
if cycleRec.Code != http.StatusBadRequest {
t.Fatalf("patch cycle upstream status: got %d, want %d, body=%s", cycleRec.Code, http.StatusBadRequest, cycleRec.Body.String())
}
assertErrorCode(t, cycleRec, "INVALID_ARGUMENT")

clearRec := doJSONRequest(t, srv, http.MethodPatch, "/api/v1/subscriptions/"+subBID, map[string]any{
"upstream_subscription_id": "",
}, true)
if clearRec.Code != http.StatusOK {
t.Fatalf("patch clear upstream status: got %d, want %d, body=%s", clearRec.Code, http.StatusOK, clearRec.Body.String())
}
clearBody := decodeJSONMap(t, clearRec)
if got, _ := clearBody["upstream_subscription_id"].(string); got != "" {
t.Fatalf("clear upstream_subscription_id: got %q, want empty", got)
}
}
1 change: 1 addition & 0 deletions internal/model/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Subscription struct {
Enabled bool `json:"enabled"`
Ephemeral bool `json:"ephemeral"`
EphemeralNodeEvictDelayNs int64 `json:"ephemeral_node_evict_delay_ns"`
UpstreamSubscriptionID string `json:"upstream_subscription_id"`
CreatedAtNs int64 `json:"created_at_ns"`
UpdatedAtNs int64 `json:"updated_at_ns"`
}
Expand Down
26 changes: 16 additions & 10 deletions internal/outbound/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
stdlog "log"

"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/endpoint"
Expand Down Expand Up @@ -32,9 +33,10 @@ type OutboundBuilder interface {
// It holds a fully-wired context with DNS services so that domain-based
// outbound servers can be resolved.
type SingboxBuilder struct {
registry *sbOutbound.Registry
ctx context.Context
logFactory log.Factory
outboundManager *sbOutbound.Manager
ctx context.Context
logFactory log.Factory

dnsTransportManager *dns.TransportManager
dnsRouter *dns.Router
}
Expand Down Expand Up @@ -102,10 +104,8 @@ func NewSingboxBuilder() (*SingboxBuilder, error) {
return nil, fmt.Errorf("singbox builder: start DNS router: %w", err)
}

registry := service.FromContext[adapter.OutboundRegistry](ctx).(*sbOutbound.Registry)

return &SingboxBuilder{
registry: registry,
outboundManager: outboundMgr,
ctx: ctx,
logFactory: logFactory,
dnsTransportManager: dnsTransportMgr,
Expand All @@ -123,20 +123,26 @@ func (b *SingboxBuilder) Build(rawOptions json.RawMessage) (adapter.Outbound, er
if err := sJson.UnmarshalContext(b.ctx, rawOptions, &outboundConfig); err != nil {
return nil, fmt.Errorf("parse outbound options: %w", err)
}
fmt.Printf("[outbound] raw=%s\n", string(rawOptions))
stdlog.Printf("[outbound] raw=%s", string(rawOptions))

// 2. Create the outbound instance via the registry.
// 2. Create the outbound instance via manager.Create so detour dependencies
// can be resolved against a shared outbound manager registry.
logger := b.logFactory.NewLogger("outbound/" + outboundConfig.Type)
ob, err := b.registry.CreateOutbound(
if err := b.outboundManager.Create(
b.ctx,
nil, // router — not needed for simple dialing
logger,
outboundConfig.Tag,
outboundConfig.Type,
outboundConfig.Options,
)
if err != nil {
); err != nil {
return nil, fmt.Errorf("create outbound [%s]: %w", outboundConfig.Type, err)
}
ob, ok := b.outboundManager.Outbound(outboundConfig.Tag)
if !ok {
return nil, fmt.Errorf("create outbound [%s]: created outbound not found by tag %s", outboundConfig.Type, outboundConfig.Tag)
}

// 3. Run lifecycle start stages. On failure, close and return error.
for _, stage := range adapter.ListStartStages {
Expand Down
85 changes: 85 additions & 0 deletions internal/outbound/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package outbound

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"time"

"github.com/Resinat/Resin/internal/netutil"
Expand Down Expand Up @@ -45,6 +48,10 @@ func (m *OutboundManager) isLiveEntry(hash node.Hash, entry *node.NodeEntry) boo
// Uses CompareAndSwap(nil, &wrapped) to guarantee only one goroutine's build
// result is stored. Losers discard their result (stage 6 adds io.Closer release).
func (m *OutboundManager) EnsureNodeOutbound(hash node.Hash) {
m.ensureNodeOutbound(hash, map[node.Hash]struct{}{})
}

func (m *OutboundManager) ensureNodeOutbound(hash node.Hash, visiting map[node.Hash]struct{}) {
entry, ok := m.pool.GetEntry(hash)
if !ok {
return
Expand All @@ -53,12 +60,37 @@ func (m *OutboundManager) EnsureNodeOutbound(hash node.Hash) {
if entry.Outbound.Load() != nil {
return
}
if _, exists := visiting[hash]; exists {
entry.SetLastError("outbound build: detour cycle detected")
return
}
visiting[hash] = struct{}{}
defer delete(visiting, hash)

if detourTag, ok := parseOutboundDetourTag(entry.RawOptions); ok {
log.Printf("[outbound] ensure node=%s detour=%s", hash.Hex(), detourTag)
detourHash, found := m.findHashByOutboundTag(detourTag)
if !found {
log.Printf("[outbound] detour target missing node=%s detour=%s", hash.Hex(), detourTag)
entry.SetLastError("outbound build: detour target not found: " + detourTag)
return
}
m.ensureNodeOutbound(detourHash, visiting)
detourEntry, ok := m.pool.GetEntry(detourHash)
if !ok || detourEntry.Outbound.Load() == nil {
log.Printf("[outbound] detour target not ready node=%s detour=%s detour_hash=%s", hash.Hex(), detourTag, detourHash.Hex())
entry.SetLastError("outbound build: detour target not ready: " + detourTag)
return
}
log.Printf("[outbound] detour target ready node=%s detour=%s detour_hash=%s", hash.Hex(), detourTag, detourHash.Hex())
}

ob, err := m.builder.Build(entry.RawOptions)
if err != nil {
entry.SetLastError("outbound build: " + err.Error())
return
}
entry.SetLastError("")

// Build can race with node deletion/replacement. If this entry is no longer
// the pool's live value for the hash, discard the build result.
Expand Down Expand Up @@ -132,3 +164,56 @@ func (m *OutboundManager) FetchWithUserAgent(
UserAgent: userAgent,
})
}

func parseOutboundDetourTag(raw json.RawMessage) (string, bool) {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
return "", false
}
rawDetour, ok := outbound["detour"]
if !ok {
return "", false
}
detour, ok := rawDetour.(string)
if !ok || detour == "" {
return "", false
}
return detour, true
}

func parseOutboundTag(raw json.RawMessage) (string, error) {
var outbound map[string]any
if err := json.Unmarshal(raw, &outbound); err != nil {
return "", fmt.Errorf("parse outbound: %w", err)
}
rawTag, ok := outbound["tag"]
if !ok {
return "", errors.New("tag missing")
}
tag, ok := rawTag.(string)
if !ok || tag == "" {
return "", errors.New("tag invalid")
}
return tag, nil
}

func (m *OutboundManager) findHashByOutboundTag(tag string) (node.Hash, bool) {
found := node.Zero
matched := false
m.pool.RangeNodes(func(h node.Hash, entry *node.NodeEntry) bool {
if entry == nil {
return true
}
outboundTag, err := parseOutboundTag(entry.RawOptions)
if err != nil {
return true
}
if outboundTag == tag {
found = h
matched = true
return false
}
return true
})
return found, matched
}
2 changes: 2 additions & 0 deletions internal/probe/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,7 @@ func (m *ProbeManager) probeEgress(hash node.Hash, entry *node.NodeEntry) {
}

if entry.Outbound.Load() == nil {
log.Printf("[probe] skip egress for %s: outbound not ready", hash.Hex())
return
}

Expand Down Expand Up @@ -767,6 +768,7 @@ func (m *ProbeManager) probeLatency(hash node.Hash, entry *node.NodeEntry, testU
}

if entry.Outbound.Load() == nil {
log.Printf("[probe] skip latency for %s: outbound not ready", hash.Hex())
return
}

Expand Down
11 changes: 11 additions & 0 deletions internal/proxy/route_outbound.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package proxy

import (
"encoding/json"
"log"

"github.com/Resinat/Resin/internal/outbound"
"github.com/Resinat/Resin/internal/routing"
"github.com/sagernet/sing-box/adapter"
Expand Down Expand Up @@ -29,9 +32,17 @@ func resolveRoutedOutbound(
}
obPtr := entry.Outbound.Load()
if obPtr == nil {
log.Printf("[proxy] route node=%s outbound=nil", result.NodeHash.Hex())
return routedOutbound{}, ErrNoAvailableNodes
}

var raw map[string]any
if err := json.Unmarshal(entry.RawOptions, &raw); err == nil {
tag, _ := raw["tag"].(string)
detour, _ := raw["detour"].(string)
log.Printf("[proxy] route node=%s tag=%s detour=%s", result.NodeHash.Hex(), tag, detour)
}

return routedOutbound{
Route: result,
Outbound: *obPtr,
Expand Down
Loading