diff --git a/.github/workflows/dev-build.yml b/.github/workflows/dev-build.yml new file mode 100644 index 0000000..55e3fed --- /dev/null +++ b/.github/workflows/dev-build.yml @@ -0,0 +1,193 @@ +name: Dev Build + +on: + workflow_dispatch: + inputs: + version_suffix: + description: 'Version suffix (leave empty for auto: dev-YYYYMMDD-sha8)' + required: false + type: string + +concurrency: + group: dev-build-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-binaries: + name: Build Binaries + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + # Auto-generate: dev-YYYYMMDD-sha8 + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + # Already has dev- prefix + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Build WebUI + working-directory: ./webui + run: | + npm ci + npm run build + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version: '1.25.x' + + - name: Build Go Binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 0 + run: | + VERSION=${{ steps.version.outputs.version }} + GIT_COMMIT=${GITHUB_SHA::8} + BUILD_TIME=$(date -u +'%Y-%m-%dT%H:%M:%SZ') + + OUTPUT_NAME=resin-${GOOS}-${GOARCH} + if [ "$GOOS" = "windows" ]; then + OUTPUT_NAME="${OUTPUT_NAME}.exe" + fi + echo "OUTPUT_NAME=${OUTPUT_NAME}" >> $GITHUB_ENV + + mkdir -p build + + go build -trimpath -tags "with_quic with_wireguard with_grpc with_utls with_embedded_tor with_naive_outbound" \ + -ldflags="-s -w \ + -X github.com/Resinat/Resin/internal/buildinfo.Version=${VERSION} \ + -X github.com/Resinat/Resin/internal/buildinfo.GitCommit=${GIT_COMMIT} \ + -X github.com/Resinat/Resin/internal/buildinfo.BuildTime=${BUILD_TIME}" \ + -o build/${OUTPUT_NAME} ./cmd/resin + + cd build + + SIMPLE_NAME="resin" + if [ "$GOOS" = "windows" ]; then + SIMPLE_NAME="resin.exe" + fi + cp ${OUTPUT_NAME} ${SIMPLE_NAME} + + if [ "$GOOS" = "windows" ]; then + zip resin-${GOOS}-${GOARCH}.zip ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.zip" + else + tar -czvf resin-${GOOS}-${GOARCH}.tar.gz ${SIMPLE_NAME} + PACKAGE_NAME="resin-${GOOS}-${GOARCH}.tar.gz" + fi + + rm ${SIMPLE_NAME} + echo "PACKAGE_NAME=${PACKAGE_NAME}" >> $GITHUB_ENV + + - name: Upload Release Package Artifact + uses: actions/upload-artifact@v4 + with: + name: dev-release-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.PACKAGE_NAME }} + retention-days: 7 + + - name: Upload Linux bin for Docker + if: matrix.goos == 'linux' + uses: actions/upload-artifact@v4 + with: + name: dev-binary-${{ matrix.goos }}-${{ matrix.goarch }} + path: build/${{ env.OUTPUT_NAME }} + retention-days: 1 + + docker: + name: Build & Push Docker Image + runs-on: ubuntu-latest + needs: build-binaries + permissions: + contents: read + packages: write + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Compute Version + id: version + run: | + SUFFIX="${{ inputs.version_suffix }}" + if [ -z "$SUFFIX" ]; then + VERSION="dev-$(date -u +'%Y%m%d')-${GITHUB_SHA::8}" + elif [[ "$SUFFIX" == dev-* ]]; then + VERSION="$SUFFIX" + else + VERSION="dev-$SUFFIX" + fi + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Download Linux amd64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-amd64 + path: release-bin/linux/amd64/ + + - name: Download Linux arm64 binary + uses: actions/download-artifact@v4 + with: + name: dev-binary-linux-arm64 + path: release-bin/linux/arm64/ + + - name: Give binaries execute permission + run: chmod +x release-bin/linux/amd64/resin-linux-amd64 release-bin/linux/arm64/resin-linux-arm64 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Lowercase repository name + run: echo "REPO_LC=${GITHUB_REPOSITORY,,}" >> $GITHUB_ENV + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + file: ./.github/Dockerfile.release + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ env.REPO_LC }}:${{ steps.version.outputs.version }} + ghcr.io/${{ env.REPO_LC }}:dev-latest diff --git a/.gitignore b/.gitignore index 62dc1ff..8d5fe5a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,12 @@ /resin sing-box-reference .agents +.ace-tool/ +.claude/ +.cursor/ +.trellis/ +AGENTS.md +data/ +start.sh .devcontainer -start-instance.sh \ No newline at end of file +start-instance.sh diff --git a/internal/api/handler_data.go b/internal/api/handler_data.go new file mode 100644 index 0000000..4de0f2a --- /dev/null +++ b/internal/api/handler_data.go @@ -0,0 +1,42 @@ +package api + +import ( + "net/http" + "time" + + "github.com/Resinat/Resin/internal/service" +) + +// HandleExportData returns a handler for GET /api/v1/data/export. +func HandleExportData(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + payload, err := cp.ExportData() + if err != nil { + writeServiceError(w, err) + return + } + + filename := "resin-export-" + time.Now().UTC().Format("20060102-150405") + ".json" + w.Header().Set("Content-Disposition", "attachment; filename=\""+filename+"\"") + WriteJSON(w, http.StatusOK, payload) + } +} + +// HandleImportData returns a handler for POST /api/v1/data/import. +func HandleImportData(cp *service.ControlPlaneService) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var payload service.ExportPayload + if err := DecodeBody(r, &payload); err != nil { + writeDecodeBodyError(w, err) + return + } + + strategy := r.URL.Query().Get("strategy") + result, err := cp.ImportData(payload, strategy) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, result) + } +} diff --git a/internal/api/handler_lease.go b/internal/api/handler_lease.go index 15757a9..161e4ca 100644 --- a/internal/api/handler_lease.go +++ b/internal/api/handler_lease.go @@ -19,6 +19,12 @@ func validateAccountPath(r *http.Request) (string, error) { func leaseSortKey(sortBy string, l service.LeaseResponse) string { switch sortBy { + case "node_tag": + return l.NodeTag + case "egress_ip": + return l.EgressIP + case "created_at": + return l.CreatedAt case "expiry": return l.Expiry case "last_accessed": @@ -92,7 +98,7 @@ func HandleListLeases(cp *service.ControlPlaneService) http.HandlerFunc { leases = filtered } - sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "expiry", "last_accessed"}, "expiry", "asc") + sorting, ok := parseSortingOrWriteInvalid(w, r, []string{"account", "node_tag", "egress_ip", "created_at", "expiry", "last_accessed"}, "expiry", "asc") if !ok { return } @@ -164,6 +170,35 @@ func HandleDeleteAllLeases(cp *service.ControlPlaneService) http.HandlerFunc { } } +// HandleBindLease returns a handler for PUT /api/v1/platforms/{id}/leases/{account}. +func HandleBindLease(cp *service.ControlPlaneService) http.HandlerFunc { + type bindRequest struct { + NodeHash string `json:"node_hash"` + } + return func(w http.ResponseWriter, r *http.Request) { + platformID, ok := requireUUIDPathParam(w, r, "id", "platform_id") + if !ok { + return + } + account, err := validateAccountPath(r) + if err != nil { + writeServiceError(w, err) + return + } + var req bindRequest + if err := DecodeBody(r, &req); err != nil { + writeDecodeBodyError(w, err) + return + } + lease, err := cp.BindLease(platformID, account, req.NodeHash) + if err != nil { + writeServiceError(w, err) + return + } + WriteJSON(w, http.StatusOK, lease) + } +} + // HandleIPLoad returns a handler for GET /api/v1/platforms/{id}/ip-load. func HandleIPLoad(cp *service.ControlPlaneService) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/server.go b/internal/api/server.go index fa07024..54fab0a 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -89,6 +89,7 @@ func NewServerWithAddress( authed.Handle("GET /api/v1/platforms/{id}/leases", HandleListLeases(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases", HandleDeleteAllLeases(cp)) authed.Handle("GET /api/v1/platforms/{id}/leases/{account}", HandleGetLease(cp)) + authed.Handle("PUT /api/v1/platforms/{id}/leases/{account}", HandleBindLease(cp)) authed.Handle("DELETE /api/v1/platforms/{id}/leases/{account}", HandleDeleteLease(cp)) authed.Handle("GET /api/v1/platforms/{id}/ip-load", HandleIPLoad(cp)) @@ -119,6 +120,10 @@ func NewServerWithAddress( authed.Handle("GET /api/v1/geoip/lookup", HandleGeoIPLookup(cp)) authed.Handle("POST /api/v1/geoip/lookup", HandleGeoIPLookupPost(cp)) authed.Handle("POST /api/v1/geoip/actions/update-now", HandleGeoIPUpdate(cp)) + + // Data export / import. + authed.Handle("GET /api/v1/data/export", HandleExportData(cp)) + authed.Handle("POST /api/v1/data/import", HandleImportData(cp)) } // Request log endpoints (always registered if repo is available). diff --git a/internal/routing/router.go b/internal/routing/router.go index 76cb70f..5f00188 100644 --- a/internal/routing/router.go +++ b/internal/routing/router.go @@ -261,6 +261,16 @@ func (r *Router) tryLeaseHit( newLease := current newLease.LastAccessedNs = nowNs + + // Auto-renew: extend lease when within 1 minute of expiry. + if newLease.ExpiryNs-nowNs < int64(time.Minute) { + ttl := plat.StickyTTLNs + if ttl <= 0 { + ttl = int64(24 * time.Hour) + } + newLease.ExpiryNs = nowNs + ttl + } + r.emitLeaseEvent(LeaseEvent{ Type: LeaseTouch, PlatformID: plat.ID, @@ -297,6 +307,16 @@ func (r *Router) tryLeaseSameIPRotation( newLease := current newLease.NodeHash = bestHash newLease.LastAccessedNs = nowNs + + // Auto-renew: extend lease when within 1 minute of expiry. + if newLease.ExpiryNs-nowNs < int64(time.Minute) { + ttl := plat.StickyTTLNs + if ttl <= 0 { + ttl = int64(24 * time.Hour) + } + newLease.ExpiryNs = nowNs + ttl + } + r.emitLeaseEvent(LeaseEvent{ Type: LeaseReplace, PlatformID: plat.ID, diff --git a/internal/service/control_plane_data.go b/internal/service/control_plane_data.go new file mode 100644 index 0000000..dc29529 --- /dev/null +++ b/internal/service/control_plane_data.go @@ -0,0 +1,360 @@ +package service + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/Resinat/Resin/internal/platform" +) + +// ------------------------------------------------------------------ +// Export / Import data types +// ------------------------------------------------------------------ + +const exportVersion = 1 + +// ExportPlatformEntry is the portable representation of a platform. +type ExportPlatformEntry struct { + Name string `json:"name"` + StickyTTL string `json:"sticky_ttl"` + RegexFilters []string `json:"regex_filters"` + RegionFilters []string `json:"region_filters"` + AllocationPolicy string `json:"allocation_policy"` + ReverseProxyMissAction string `json:"reverse_proxy_miss_action"` + ReverseProxyEmptyAccountBehavior string `json:"reverse_proxy_empty_account_behavior"` + ReverseProxyFixedAccountHeader string `json:"reverse_proxy_fixed_account_header"` +} + +// ExportSubscriptionEntry is the portable representation of a subscription. +type ExportSubscriptionEntry struct { + Name string `json:"name"` + SourceType string `json:"source_type"` + URL string `json:"url"` + Content string `json:"content"` + UpdateInterval string `json:"update_interval"` + Enabled bool `json:"enabled"` + Ephemeral bool `json:"ephemeral"` + EphemeralNodeEvictDelay string `json:"ephemeral_node_evict_delay"` +} + +// ExportPayload is the top-level JSON structure for data export/import. +type ExportPayload struct { + Version int `json:"version"` + ExportedAt string `json:"exported_at"` + Platforms []ExportPlatformEntry `json:"platforms"` + Subscriptions []ExportSubscriptionEntry `json:"subscriptions"` +} + +// ImportResult summarises what happened during an import. +type ImportResult struct { + PlatformsCreated int `json:"platforms_created"` + PlatformsSkipped int `json:"platforms_skipped"` + PlatformsOverwritten int `json:"platforms_overwritten"` + SubscriptionsCreated int `json:"subscriptions_created"` + SubscriptionsSkipped int `json:"subscriptions_skipped"` + SubscriptionsOverwritten int `json:"subscriptions_overwritten"` + Errors []string `json:"errors"` +} + +// ------------------------------------------------------------------ +// Export +// ------------------------------------------------------------------ + +// ExportData builds an ExportPayload containing all user-created platforms +// and all subscriptions. +func (s *ControlPlaneService) ExportData() (*ExportPayload, error) { + // --- platforms (exclude Default) --- + platforms, err := s.Engine.ListPlatforms() + if err != nil { + return nil, internal("list platforms for export", err) + } + + exportPlatforms := make([]ExportPlatformEntry, 0, len(platforms)) + for _, p := range platforms { + if p.ID == platform.DefaultPlatformID { + continue + } + resp := platformToResponse(p) + exportPlatforms = append(exportPlatforms, ExportPlatformEntry{ + Name: resp.Name, + StickyTTL: resp.StickyTTL, + RegexFilters: resp.RegexFilters, + RegionFilters: resp.RegionFilters, + AllocationPolicy: resp.AllocationPolicy, + ReverseProxyMissAction: resp.ReverseProxyMissAction, + ReverseProxyEmptyAccountBehavior: resp.ReverseProxyEmptyAccountBehavior, + ReverseProxyFixedAccountHeader: resp.ReverseProxyFixedAccountHeader, + }) + } + + // --- subscriptions --- + subs, err := s.ListSubscriptions(nil) + if err != nil { + return nil, internal("list subscriptions for export", err) + } + + exportSubs := make([]ExportSubscriptionEntry, 0, len(subs)) + for _, sub := range subs { + exportSubs = append(exportSubs, ExportSubscriptionEntry{ + Name: sub.Name, + SourceType: sub.SourceType, + URL: sub.URL, + Content: sub.Content, + UpdateInterval: sub.UpdateInterval, + Enabled: sub.Enabled, + Ephemeral: sub.Ephemeral, + EphemeralNodeEvictDelay: sub.EphemeralNodeEvictDelay, + }) + } + + return &ExportPayload{ + Version: exportVersion, + ExportedAt: time.Now().UTC().Format(time.RFC3339), + Platforms: exportPlatforms, + Subscriptions: exportSubs, + }, nil +} + +// ------------------------------------------------------------------ +// Import +// ------------------------------------------------------------------ + +// ImportData imports platforms and subscriptions from the given payload. +// strategy must be "skip" (default) or "overwrite". +func (s *ControlPlaneService) ImportData(payload ExportPayload, strategy string) (*ImportResult, error) { + if strategy == "" { + strategy = "skip" + } + if strategy != "skip" && strategy != "overwrite" { + return nil, invalidArg("strategy must be 'skip' or 'overwrite'") + } + + result := &ImportResult{Errors: []string{}} + + // ----- import platforms ----- + s.importPlatforms(payload.Platforms, strategy, result) + + // ----- import subscriptions ----- + s.importSubscriptions(payload.Subscriptions, strategy, result) + + return result, nil +} + +func (s *ControlPlaneService) importPlatforms(entries []ExportPlatformEntry, strategy string, result *ImportResult) { + // Build existing name→id lookup. + existing, err := s.Engine.ListPlatforms() + if err != nil { + result.Errors = append(result.Errors, "failed to list existing platforms: "+err.Error()) + return + } + nameToID := make(map[string]string, len(existing)) + for _, p := range existing { + nameToID[p.Name] = p.ID + } + + // Detect duplicates inside the import payload itself. + seen := make(map[string]bool, len(entries)) + + for i, entry := range entries { + name := strings.TrimSpace(entry.Name) + if name == "" { + result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: name is empty, skipped", i)) + continue + } + if seen[name] { + result.Errors = append(result.Errors, fmt.Sprintf("platforms[%d]: duplicate name %q in import payload, skipped", i, name)) + continue + } + seen[name] = true + + existingID, exists := nameToID[name] + if exists && strategy == "skip" { + result.PlatformsSkipped++ + continue + } + + if exists && strategy == "overwrite" { + // Overwrite: build a patch JSON and call UpdatePlatform. + patch := buildPlatformPatch(entry) + patchJSON, _ := json.Marshal(patch) + if _, err := s.UpdatePlatform(existingID, patchJSON); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: overwrite failed: %v", name, err)) + continue + } + result.PlatformsOverwritten++ + continue + } + + // Create new platform. + req := buildCreatePlatformRequest(entry) + if _, err := s.CreatePlatform(req); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("platform %q: create failed: %v", name, err)) + continue + } + result.PlatformsCreated++ + } +} + +func (s *ControlPlaneService) importSubscriptions(entries []ExportSubscriptionEntry, strategy string, result *ImportResult) { + // Build existing name→id and url→id lookup tables. + existingSubs, err := s.ListSubscriptions(nil) + if err != nil { + result.Errors = append(result.Errors, "failed to list existing subscriptions: "+err.Error()) + return + } + nameToID := make(map[string]string, len(existingSubs)) + urlToID := make(map[string]string, len(existingSubs)) + for _, sub := range existingSubs { + nameToID[sub.Name] = sub.ID + if sub.URL != "" { + urlToID[sub.URL] = sub.ID + } + } + + // Detect duplicates inside the import payload itself. + seenName := make(map[string]bool, len(entries)) + seenURL := make(map[string]bool, len(entries)) + + for i, entry := range entries { + name := strings.TrimSpace(entry.Name) + if name == "" { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: name is empty, skipped", i)) + continue + } + + // Internal dedup by name. + if seenName[name] { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate name %q in import payload, skipped", i, name)) + continue + } + seenName[name] = true + + // Internal dedup by URL for remote subs. + url := strings.TrimSpace(entry.URL) + if entry.SourceType == "remote" && url != "" { + if seenURL[url] { + result.Errors = append(result.Errors, fmt.Sprintf("subscriptions[%d]: duplicate url %q in import payload, skipped", i, url)) + continue + } + seenURL[url] = true + } + + // Match against existing: first by name, then by URL. + existingID := "" + if id, ok := nameToID[name]; ok { + existingID = id + } else if entry.SourceType == "remote" && url != "" { + if id, ok := urlToID[url]; ok { + existingID = id + } + } + + if existingID != "" && strategy == "skip" { + result.SubscriptionsSkipped++ + continue + } + + if existingID != "" && strategy == "overwrite" { + patch := buildSubscriptionPatch(entry) + patchJSON, _ := json.Marshal(patch) + if _, err := s.UpdateSubscription(existingID, patchJSON); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: overwrite failed: %v", name, err)) + continue + } + result.SubscriptionsOverwritten++ + continue + } + + // Create new subscription. + req := buildCreateSubscriptionRequest(entry) + if _, err := s.CreateSubscription(req); err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("subscription %q: create failed: %v", name, err)) + continue + } + result.SubscriptionsCreated++ + } +} + +// ------------------------------------------------------------------ +// helpers: build request structs from export entries +// ------------------------------------------------------------------ + +func buildCreatePlatformRequest(e ExportPlatformEntry) CreatePlatformRequest { + name := strings.TrimSpace(e.Name) + return CreatePlatformRequest{ + Name: &name, + StickyTTL: strPtr(e.StickyTTL), + RegexFilters: e.RegexFilters, + RegionFilters: e.RegionFilters, + AllocationPolicy: strPtr(e.AllocationPolicy), + ReverseProxyMissAction: strPtr(e.ReverseProxyMissAction), + ReverseProxyEmptyAccountBehavior: strPtr(e.ReverseProxyEmptyAccountBehavior), + ReverseProxyFixedAccountHeader: strPtr(e.ReverseProxyFixedAccountHeader), + } +} + +func buildPlatformPatch(e ExportPlatformEntry) map[string]any { + regexFilters := e.RegexFilters + if regexFilters == nil { + regexFilters = []string{} + } + regionFilters := e.RegionFilters + if regionFilters == nil { + regionFilters = []string{} + } + patch := map[string]any{ + "sticky_ttl": e.StickyTTL, + "regex_filters": regexFilters, + "region_filters": regionFilters, + "allocation_policy": e.AllocationPolicy, + "reverse_proxy_miss_action": e.ReverseProxyMissAction, + "reverse_proxy_empty_account_behavior": e.ReverseProxyEmptyAccountBehavior, + "reverse_proxy_fixed_account_header": e.ReverseProxyFixedAccountHeader, + } + return patch +} + +func buildCreateSubscriptionRequest(e ExportSubscriptionEntry) CreateSubscriptionRequest { + name := strings.TrimSpace(e.Name) + sourceType := e.SourceType + url := strings.TrimSpace(e.URL) + content := e.Content + enabled := e.Enabled + ephemeral := e.Ephemeral + return CreateSubscriptionRequest{ + Name: &name, + SourceType: &sourceType, + URL: &url, + Content: &content, + UpdateInterval: strPtr(e.UpdateInterval), + Enabled: &enabled, + Ephemeral: &ephemeral, + EphemeralNodeEvictDelay: strPtr(e.EphemeralNodeEvictDelay), + } +} + +func buildSubscriptionPatch(e ExportSubscriptionEntry) map[string]any { + patch := map[string]any{ + "name": strings.TrimSpace(e.Name), + "update_interval": e.UpdateInterval, + "enabled": e.Enabled, + "ephemeral": e.Ephemeral, + "ephemeral_node_evict_delay": e.EphemeralNodeEvictDelay, + } + if e.SourceType == "remote" { + patch["url"] = strings.TrimSpace(e.URL) + } + if e.SourceType == "local" { + patch["content"] = e.Content + } + return patch +} + +func strPtr(s string) *string { + if s == "" { + return nil + } + return &s +} diff --git a/internal/service/control_plane_leases.go b/internal/service/control_plane_leases.go index 1702e2b..6f9f6e5 100644 --- a/internal/service/control_plane_leases.go +++ b/internal/service/control_plane_leases.go @@ -20,6 +20,7 @@ type LeaseResponse struct { NodeHash string `json:"node_hash"` NodeTag string `json:"node_tag"` EgressIP string `json:"egress_ip"` + CreatedAt string `json:"created_at"` Expiry string `json:"expiry"` LastAccessed string `json:"last_accessed"` } @@ -31,6 +32,7 @@ func leaseToResponse(lease model.Lease, nodeTag string) LeaseResponse { NodeHash: lease.NodeHash, NodeTag: nodeTag, EgressIP: lease.EgressIP, + CreatedAt: time.Unix(0, lease.CreatedAtNs).UTC().Format(time.RFC3339Nano), Expiry: time.Unix(0, lease.ExpiryNs).UTC().Format(time.RFC3339Nano), LastAccessed: time.Unix(0, lease.LastAccessedNs).UTC().Format(time.RFC3339Nano), } @@ -63,6 +65,7 @@ func (s *ControlPlaneService) ListLeases(platformID string) ([]LeaseResponse, er Account: account, NodeHash: lease.NodeHash.Hex(), EgressIP: lease.EgressIP.String(), + CreatedAtNs: lease.CreatedAtNs, ExpiryNs: lease.ExpiryNs, LastAccessedNs: lease.LastAccessedNs, }, s.resolveLeaseNodeTag(lease.NodeHash))) @@ -148,6 +151,60 @@ func (s *ControlPlaneService) DeleteAllLeases(platformID string) error { return nil } +// BindLease binds (or rebinds) an account to a specific node on the given platform. +// The node must be routable on the platform. +func (s *ControlPlaneService) BindLease(platformID, account, nodeHashHex string) (*LeaseResponse, error) { + account = strings.TrimSpace(account) + if account == "" { + return nil, invalidArg("account: must be non-empty") + } + nodeHashHex = strings.TrimSpace(nodeHashHex) + h, err := node.ParseHex(nodeHashHex) + if err != nil { + return nil, invalidArg("node_hash: invalid format") + } + + plat, ok := s.Pool.GetPlatform(platformID) + if !ok { + return nil, notFound("platform not found") + } + + if !plat.View().Contains(h) { + return nil, notFound("node is not routable on this platform") + } + + entry, ok := s.Pool.GetEntry(h) + if !ok { + return nil, notFound("node not found") + } + egressIP := entry.GetEgressIP() + if !egressIP.IsValid() { + return nil, invalidArg("node has no egress IP") + } + + nowNs := time.Now().UnixNano() + ttlNs := plat.StickyTTLNs + if ttlNs <= 0 { + ttlNs = int64(24 * time.Hour) // default 24h + } + + ml := model.Lease{ + PlatformID: platformID, + Account: account, + NodeHash: h.Hex(), + EgressIP: egressIP.String(), + CreatedAtNs: nowNs, + ExpiryNs: nowNs + ttlNs, + LastAccessedNs: nowNs, + } + if err := s.Router.UpsertLease(ml); err != nil { + return nil, internal("bind lease", err) + } + + resp := leaseToResponse(ml, s.resolveLeaseNodeTag(h)) + return &resp, nil +} + // IPLoadEntry is the API response for IP load stats. type IPLoadEntry struct { EgressIP string `json:"egress_ip"` diff --git a/webui/index.html b/webui/index.html index 939be23..5edaa66 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7,6 +7,15 @@ Resin · Sticky Proxy Pool +
diff --git a/webui/package-lock.json b/webui/package-lock.json index 88505cf..96b1015 100644 --- a/webui/package-lock.json +++ b/webui/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1666,7 +1665,6 @@ "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1677,7 +1675,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1743,7 +1740,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -2008,7 +2004,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2117,7 +2112,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2491,7 +2485,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2911,7 +2904,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -3307,7 +3299,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3369,7 +3360,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3379,7 +3369,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3392,7 +3381,6 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -3443,7 +3431,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -3538,8 +3525,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -3747,7 +3733,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3865,7 +3850,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -3995,7 +3979,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/webui/src/components/AppShell.tsx b/webui/src/components/AppShell.tsx index 6e49f9c..0d8fe42 100644 --- a/webui/src/components/AppShell.tsx +++ b/webui/src/components/AppShell.tsx @@ -20,6 +20,7 @@ import { useAuthStore } from "../features/auth/auth-store"; import { getEnvConfig } from "../features/systemConfig/api"; import { useI18n } from "../i18n"; import { LanguageSwitcher } from "./LanguageSwitcher"; +import { ThemeToggle } from "./ThemeToggle"; type NavItem = { label: string; @@ -151,6 +152,7 @@ export function AppShell() { ) : (