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
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ PORTAL_URL=https://localhost:4017
# Listener ports
API_PORT=4017
SNI_PORT=443

# UDP transport (0 = disabled). Set count > 0 to enable QUIC tunnel + allocate UDP ports starting from 50000.
# e.g., UDP_PORT_COUNT=10 → ports 50000-50009. Also requires enabling UDP in the admin panel.
UDP_PORT_COUNT=0
Expand All @@ -27,6 +26,7 @@ AWS_HOSTED_ZONE_ID=

# Admin/auth configuration
ADMIN_SECRET_KEY=
LANDING_PAGE_ENABLED=false
# Enable when the relay is behind nginx/ingress/load balancers and should trust forwarded client IP headers.
# Optionally restrict which proxy source ranges may supply those headers; leave empty for default private/loopback proxy ranges.
TRUST_PROXY_HEADERS=false
Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
make && rm -rf /var/lib/apt/lists/*

COPY frontend ./frontend
COPY utils ./utils
COPY Makefile ./

RUN --mount=type=cache,target=/root/.npm \
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ For architecture decisions, see [docs/adr/README.md](docs/adr/README.md).

## Public Relay Registry

Portal's official public relay registry is:

`https://raw.githubusercontent.com/gosuda/portal/main/registry.json`

Portal tunnel clients can include this registry by default, and the relay UI also reads from the same path to show the official relay list.

If you operate a public Portal relay, open a Pull Request to add your relay URL to `registry.json`. Keeping the registry updated makes public relays easier for the community to discover.

## Contributing
Expand Down
6 changes: 3 additions & 3 deletions cmd/portal-tunnel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ portal list
Custom relay and metadata example:

```text
portal expose --name myapp \
portal expose localhost:8080 \
--name myapp \
--relays https://portal.example.com \
--description "Service description" \
--tags tag1,tag2 \
--thumbnail https://example.com/thumb.png \
--owner "Portal Operator" \
localhost:8080
--owner "Portal Operator"
```

## Commands
Expand Down
10 changes: 5 additions & 5 deletions cmd/portal-tunnel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -239,8 +239,8 @@ func printRootUsage(w io.Writer) {
},
[]string{
"portal expose 3000",
"portal expose --name my-app localhost:8080",
"portal expose --udp --udp-addr 127.0.0.1:5353 3000",
"portal expose localhost:8080 --name my-app",
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
"portal list",
},
)
Expand All @@ -253,9 +253,9 @@ func printExposeUsage(w io.Writer) {
},
[]string{
"portal expose 3000",
"portal expose --name my-app localhost:8080",
"portal expose --udp --udp-addr 127.0.0.1:5353 3000",
"portal expose --relays https://portal.example.com --default-relays=false 3000",
"portal expose localhost:8080 --name my-app",
"portal expose 3000 --udp --udp-addr 127.0.0.1:5353",
"portal expose 3000 --relays https://portal.example.com --default-relays=false",
},
)
}
Expand Down
84 changes: 56 additions & 28 deletions cmd/relay-server/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,26 +97,29 @@ func (a *adminAuth) cleanupExpiredSessionsLocked() {
}
}

func loadAdminState(path string, runtime *policy.Runtime) error {
func loadAdminState(path string, runtime *policy.Runtime) (persistedAdminState, error) {
root, name, err := openSettingsRoot(path)
if err != nil {
return err
return persistedAdminState{}, err
}
defer root.Close()

data, err := root.ReadFile(name)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
return persistedAdminState{}, nil
}
return err
return persistedAdminState{}, err
}

var payload persistedAdminState
if err := json.Unmarshal(data, &payload); err != nil {
return err
return persistedAdminState{}, err
}
return payload.apply(runtime)
if err := payload.apply(runtime); err != nil {
return persistedAdminState{}, err
}
return payload, nil
}

func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -196,13 +199,29 @@ func (f *Frontend) serveAdmin(w http.ResponseWriter, r *http.Request) {
return
}
utils.WriteAPIData(w, http.StatusOK, types.AdminSnapshotResponse{
ApprovalMode: string(runtime.Approver().Mode()),
Leases: f.adminLeaseSnapshots(),
ApprovalMode: string(runtime.Approver().Mode()),
LandingPageEnabled: f.isLandingPageEnabled(),
Leases: f.adminLeaseSnapshots(),
UDP: types.AdminUDPSettingsResponse{
Enabled: runtime.IsUDPEnabled(),
MaxLeases: runtime.UDPMaxLeases(),
},
})
case types.PathAdminLandingPage:
if r.Method != http.MethodPost {
methodNotAllowed()
return
}
var req types.AdminLandingPageSettingsRequest
if err := utils.DecodeJSONBody(w, r, &req, 1<<16); err != nil {
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "invalid request body")
return
}
f.setLandingPageEnabled(req.Enabled)
f.saveAdminState(runtime)
utils.WriteAPIData(w, http.StatusOK, types.AdminLandingPageSettingsResponse{
Enabled: f.isLandingPageEnabled(),
})
case types.PathAdminUDP:
if r.Method != http.MethodPost {
methodNotAllowed()
Expand Down Expand Up @@ -399,11 +418,11 @@ func (f *Frontend) saveAdminState(runtime *policy.Runtime) {
if f == nil {
return
}
saveAdminState(f.adminSettingsPath, runtime)
saveAdminState(f.adminSettingsPath, runtime, f.isLandingPageEnabled())
}

func saveAdminState(path string, runtime *policy.Runtime) {
payload := persistedStateFromRuntime(runtime)
func saveAdminState(path string, runtime *policy.Runtime, landingPageEnabled bool) {
payload := persistedStateFromRuntime(runtime, landingPageEnabled)
data, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return
Expand All @@ -418,30 +437,39 @@ func saveAdminState(path string, runtime *policy.Runtime) {
}

type persistedAdminState struct {
ApprovalMode string `json:"approval_mode"`
ApprovedLeases []string `json:"approved_leases,omitempty"`
DeniedLeases []string `json:"denied_leases,omitempty"`
BannedLeases []string `json:"banned_leases,omitempty"`
BannedIPs []string `json:"banned_ips,omitempty"`
LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
UDPEnabled *bool `json:"udp_enabled,omitempty"`
UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
ApprovalMode string `json:"approval_mode"`
ApprovedLeases []string `json:"approved_leases,omitempty"`
DeniedLeases []string `json:"denied_leases,omitempty"`
BannedLeases []string `json:"banned_leases,omitempty"`
BannedIPs []string `json:"banned_ips,omitempty"`
LeaseBPS map[string]int64 `json:"lease_bps,omitempty"`
UDPEnabled *bool `json:"udp_enabled,omitempty"`
UDPMaxLeases *int `json:"udp_max_leases,omitempty"`
LandingPageEnabled *bool `json:"landing_page_enabled,omitempty"`
}

func persistedStateFromRuntime(runtime *policy.Runtime) persistedAdminState {
func persistedStateFromRuntime(runtime *policy.Runtime, landingPageEnabled bool) persistedAdminState {
approver := runtime.Approver()
udpEnabled := runtime.IsUDPEnabled()
udpMaxLeases := runtime.UDPMaxLeases()
return persistedAdminState{
ApprovalMode: string(approver.Mode()),
ApprovedLeases: approver.ApprovedLeases(),
DeniedLeases: approver.DeniedLeases(),
BannedLeases: runtime.BannedLeases(),
BannedIPs: runtime.IPFilter().BannedIPs(),
LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
UDPEnabled: &udpEnabled,
UDPMaxLeases: &udpMaxLeases,
ApprovalMode: string(approver.Mode()),
ApprovedLeases: approver.ApprovedLeases(),
DeniedLeases: approver.DeniedLeases(),
BannedLeases: runtime.BannedLeases(),
BannedIPs: runtime.IPFilter().BannedIPs(),
LeaseBPS: runtime.BPSManager().LeaseBPSLimits(),
UDPEnabled: &udpEnabled,
UDPMaxLeases: &udpMaxLeases,
LandingPageEnabled: &landingPageEnabled,
}
}

func (s persistedAdminState) landingPageEnabled(defaultEnabled bool) bool {
if s.LandingPageEnabled == nil {
return defaultEnabled
}
return *s.LandingPageEnabled
}

func (s persistedAdminState) apply(runtime *policy.Runtime) error {
Expand Down
67 changes: 52 additions & 15 deletions cmd/relay-server/frontend.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import (
"mime"
"net/http"
"path"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"

"github.com/gosuda/portal/v2/portal"
"github.com/gosuda/portal/v2/types"
"github.com/gosuda/portal/v2/utils"
)

type readDirFileFS interface {
Expand All @@ -33,26 +36,30 @@ type Frontend struct {

cachedPortalHTML []byte
cachedPortalHTMLOnce sync.Once
landingPageEnabled atomic.Bool
}

func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string) (*Frontend, error) {
func NewFrontend(server *portal.Server, adminSecret string, adminSettingsPath string, defaultLandingPageEnabled bool) (*Frontend, error) {
if server == nil {
return nil, errors.New("frontend requires portal server")
}
runtime := server.PolicyRuntime()
if runtime == nil {
return nil, errors.New("frontend requires policy runtime")
}
if err := loadAdminState(adminSettingsPath, runtime); err != nil {
state, err := loadAdminState(adminSettingsPath, runtime)
if err != nil {
return nil, err
}

return &Frontend{
frontend := &Frontend{
distFS: embeddedDistFS,
server: server,
auth: newAdminAuth(adminSecret),
adminSettingsPath: strings.TrimSpace(adminSettingsPath),
}, nil
}
frontend.setLandingPageEnabled(state.landingPageEnabled(defaultLandingPageEnabled))
return frontend, nil
}

func (f *Frontend) Handler() *http.ServeMux {
Expand All @@ -78,6 +85,7 @@ func (f *Frontend) Handler() *http.ServeMux {

mux.HandleFunc(types.PathAdmin, f.serveAdmin)
mux.HandleFunc(types.PathAdminPrefix, f.serveAdmin)
mux.HandleFunc(types.PathTunnelStatus, f.serveTunnelStatus)
mux.HandleFunc(types.PathInstallShell, func(w http.ResponseWriter, r *http.Request) {
serveInstallScript(w, r, f.server.PortalURL(), false)
})
Expand Down Expand Up @@ -171,7 +179,7 @@ func (f *Frontend) servePortalHTMLWithSSR(w http.ResponseWriter) {

htmlContent := string(f.cachedPortalHTML)
htmlContent = f.injectServerData(htmlContent)
htmlContent = f.injectOGMetadata(htmlContent, "", "", "")
htmlContent = f.injectOGMetadata(htmlContent, "", "")

w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache, must-revalidate")
Expand All @@ -192,30 +200,60 @@ func (f *Frontend) injectServerData(htmlContent string) string {
return strings.Replace(htmlContent, "</head>", ssrScript+"\n</head>", 1)
}

func (f *Frontend) injectOGMetadata(htmlContent, title, description, imageURL string) string {
func (f *Frontend) serveTunnelStatus(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
utils.WriteAPIError(w, http.StatusMethodNotAllowed, types.APIErrorCodeMethodNotAllowed, "method not allowed")
return
}

hostname := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("hostname")))
if hostname == "" {
utils.WriteAPIError(w, http.StatusBadRequest, types.APIErrorCodeInvalidRequest, "hostname is required")
return
}

resp := types.TunnelStatusResponse{
Hostname: hostname,
}
if snapshot, ok := f.server.LeaseSnapshotByHostname(hostname); ok {
resp.Hostname = snapshot.Hostname
resp.Registered = true
resp.ServiceAlive = snapshot.Ready > 0
}
utils.WriteAPIData(w, http.StatusOK, resp)
}

func (f *Frontend) injectOGMetadata(htmlContent, title, description string) string {
if title == "" {
title = "Portal Proxy Gateway"
}
if description == "" {
description = "Transform your local services into web-accessible endpoints. Instant access from anywhere."
}
if imageURL == "" {
base := strings.TrimSuffix(f.server.PortalURL(), "/")
if !strings.HasPrefix(base, "http") {
base = "https://" + base
}
imageURL = base + "/portal.jpg"
}

replacer := strings.NewReplacer(
"[%OG_TITLE%]", html.EscapeString(title),
"[%OG_DESCRIPTION%]", html.EscapeString(description),
"[%OG_IMAGE_URL%]", html.EscapeString(imageURL),
"[%LANDING_PAGE_ENABLED%]", html.EscapeString(strconv.FormatBool(f.isLandingPageEnabled())),
"[%RELEASE_VERSION%]", html.EscapeString(types.ReleaseVersion),
)
return replacer.Replace(htmlContent)
}

func (f *Frontend) isLandingPageEnabled() bool {
if f == nil {
return false
}
return f.landingPageEnabled.Load()
}

func (f *Frontend) setLandingPageEnabled(enabled bool) {
if f == nil {
return
}
f.landingPageEnabled.Store(enabled)
}

func (f *Frontend) adminLeaseSnapshots() []types.Lease {
snapshots := f.server.LeaseSnapshots()
if len(snapshots) == 0 {
Expand Down Expand Up @@ -303,6 +341,5 @@ func frontendRootAssetPaths() []string {
"/apple-touch-icon.png",
"/web-app-manifest-192x192.png",
"/web-app-manifest-512x512.png",
"/portal.jpg",
}
}
Loading
Loading