Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
34876fe
hvui: surface transport latency on the routing/transports pages
0pcom May 2, 2026
22538c1
hvui: inline add-transport form + drop transport-list pagination
0pcom May 2, 2026
f60abe5
hvui: drop confirmation modals on reversible toggles
0pcom May 2, 2026
f6bdf83
hvui: drop more modals, enrich routes view, live-tail logs
0pcom May 3, 2026
a9e198a
visor+hvui: diff-based streaming for runtime logs
0pcom May 3, 2026
a05e2e2
visor+hvui: Resource Monitor (host + process) with sparkline graphs
0pcom May 3, 2026
06a25f6
visor+hvui: Network tab — server-aggregated SD/TPD/UT view (cli-sd in…
0pcom May 3, 2026
410108f
hvui: visor-detail right-bar cleanup + Resources tab + 5min network c…
0pcom May 3, 2026
c57fd40
hvui: visor page — info as default tab, drop right-bar split, identit…
0pcom May 3, 2026
a8ae852
visor+hvui: skychat tab + password gate + localhost-only default
0pcom May 3, 2026
0a4e4e9
hvui: skychat password panel inline in the chat tab
0pcom May 3, 2026
d910c71
hvui: visor page restructure — Transports tab, sectional moves, no-pa…
0pcom May 3, 2026
29756ac
hvui: full-pk display, global input contrast, DMSG as per-visor tab
0pcom May 3, 2026
39ccad0
hvui: tighten home top-bar tab spacing
0pcom May 3, 2026
87b6715
hvui: replace newer Material Icons that fall back to literal text
0pcom May 3, 2026
1ba3092
hvui: Deployment tab + RSN remote stats; multi-visor Resources tab
0pcom May 3, 2026
565b571
hvui: Transports home tab + grouped local/network top-bar
0pcom May 3, 2026
cf1dfcf
tpd+visor+hvui: CXO publisher for /metrics aggregate
0pcom May 3, 2026
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
138 changes: 138 additions & 0 deletions cmd/apps/skychat/commands/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// Package commands cmd/apps/skychat/commands/auth.go
//
// HTTP basic-auth gate for skychat. Off by default; on when a
// password file is configured and non-empty.
//
// File format mirrors the hypervisor's user-store hashing scheme
// (salt + SHA256, see pkg/visor/usermanager/user.go) so the same
// password-set flow works the same way for both surfaces. The
// file holds a single line of "<hex-salt>:<hex-hash>". The
// password-file path is managed via the hypervisor UI; the visor
// writes / removes it on SetSkychatPassword / ClearSkychatPassword.
//
// The hypervisor reverse-proxy at /api/visors/<pk>/skychat/* runs
// in-process on the visor and bypasses this gate via a startup-
// random "internal token" in the X-Skychat-Internal-Token header,
// so authenticated hvui sessions don't have to re-authenticate to
// skychat. The standalone :8001 surface stays password-gated.
package commands

import (
"encoding/hex"
"net/http"
"os"
"strings"
"sync"

"github.com/skycoin/skywire/pkg/cipher"
)

var (
authMu sync.RWMutex
authPasswordSalt []byte
authPasswordHash cipher.SHA256
authPasswordSet bool
authInternalToken string
)

// loadSkychatPassword reads "<hex-salt>:<hex-hash>" from path.
// Empty file or missing path → no auth. Errors other than ENOENT
// are surfaced; the caller should log and continue with auth
// disabled rather than blocking startup on a transient FS hiccup.
func loadSkychatPassword(path string) error {
authMu.Lock()
defer authMu.Unlock()
authPasswordSet = false
if path == "" {
return nil
}
data, err := os.ReadFile(path) //nolint:gosec
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
line := strings.TrimSpace(string(data))
if line == "" {
return nil
}
parts := strings.SplitN(line, ":", 2)
if len(parts) != 2 {
return nil // malformed — treat as no auth, log-handled by caller
}
salt, err := hex.DecodeString(parts[0])
if err != nil {
return err
}
hashBytes, err := hex.DecodeString(parts[1])
if err != nil {
return err
}
if len(hashBytes) != len(authPasswordHash) {
return nil
}
copy(authPasswordHash[:], hashBytes)
authPasswordSalt = salt
authPasswordSet = true
return nil
}

// setSkychatInternalToken records the per-startup token the
// hypervisor proxy uses to bypass the password gate. Empty disables
// the bypass.
func setSkychatInternalToken(t string) {
authMu.Lock()
defer authMu.Unlock()
authInternalToken = strings.TrimSpace(t)
}

// requireAuth wraps a handler with HTTP basic auth gating. If no
// password is configured, the wrapped handler runs unchanged. If a
// password is configured, the request must either present basic
// auth that bcrypt-verifies against the stored hash, or carry a
// matching X-Skychat-Internal-Token header (from the visor's
// in-process proxy).
func requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authMu.RLock()
set := authPasswordSet
salt := authPasswordSalt
hash := authPasswordHash
token := authInternalToken
authMu.RUnlock()

if !set {
next.ServeHTTP(w, r)
return
}

if token != "" && r.Header.Get("X-Skychat-Internal-Token") == token {
next.ServeHTTP(w, r)
return
}

_, password, ok := r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="skychat"`)
http.Error(w, "skychat: authentication required", http.StatusUnauthorized)
return
}
got := cipher.SumSHA256(append([]byte(password), salt...))
if got != hash {
w.Header().Set("WWW-Authenticate", `Basic realm="skychat"`)
http.Error(w, "skychat: invalid credentials", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r)
})
}

// requireAuthFunc is the http.HandlerFunc-shaped equivalent for
// HandleFunc-style registrations.
func requireAuthFunc(h http.HandlerFunc) http.HandlerFunc {
wrapped := requireAuth(h)
return func(w http.ResponseWriter, r *http.Request) {
wrapped.ServeHTTP(w, r)
}
}
8 changes: 4 additions & 4 deletions cmd/apps/skychat/commands/pairing.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,10 +231,10 @@ func registerPairHTTPHandlers(ctx context.Context) {
if !pairEnable {
return
}
http.HandleFunc("/pair", pairRootHandler(ctx))
http.HandleFunc("/pair/invites", pairInvitesListHandler())
http.HandleFunc("/pair/invites/", pairInvitesItemHandler(ctx))
http.HandleFunc("/pair/", pairItemHandler(ctx))
http.HandleFunc("/pair", requireAuthFunc(pairRootHandler(ctx)))
http.HandleFunc("/pair/invites", requireAuthFunc(pairInvitesListHandler()))
http.HandleFunc("/pair/invites/", requireAuthFunc(pairInvitesItemHandler(ctx)))
http.HandleFunc("/pair/", requireAuthFunc(pairItemHandler(ctx)))
}

// pairInvitesListHandler serves GET /pair/invites — current pending
Expand Down
31 changes: 25 additions & 6 deletions cmd/apps/skychat/commands/skychat.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ var (
useSkynet bool
useDmsg bool

// Optional HTTP password gate. When --password-file points at a
// file containing a bcrypt hash, every HTTP endpoint requires
// matching basic auth (or the hypervisor's internal-proxy
// bypass token, see auth.go). Empty file or missing flag →
// no auth, current behavior.
passwordFile string
internalToken string

// Persistence (Phase 1) — all off by default.
persistEnabled bool
persistDBPath string
Expand Down Expand Up @@ -125,10 +133,12 @@ func (h *sseHub) broadcast(msg string) {

func init() {
launcher.RegisterApp("skychat", RunSkychat)
RootCmd.Flags().StringVar(&addr, "addr", ":8001", "address to bind, put an * before the port if you want to be able to access outside localhost")
RootCmd.Flags().StringVar(&addr, "addr", ":8001", "address to bind (default: localhost-only); use \"*:PORT\" to bind on all interfaces")
RootCmd.Flags().Uint16Var(&appPort, "port", 0, "routing port for communication between app and visor")
RootCmd.Flags().BoolVar(&useSkynet, "skynet", true, "listen on skynet network")
RootCmd.Flags().BoolVar(&useDmsg, "dmsg", true, "listen on dmsg network")
RootCmd.Flags().StringVar(&passwordFile, "password-file", "", "path to a file containing a bcrypt hash; when set, gates HTTP endpoints with basic auth")
RootCmd.Flags().StringVar(&internalToken, "internal-token", "", "shared secret used by the hypervisor's reverse proxy to bypass the password gate; managed automatically by the visor")

// Persistence flags (Phase 1). All default off; when --persist is set,
// the others fall back to conservative defaults.
Expand Down Expand Up @@ -188,6 +198,8 @@ func RunSkychat(ctx context.Context, args []string) error {
fs.Uint16Var(&appPort, "port", 0, "routing port")
fs.BoolVar(&useSkynet, "skynet", true, "listen on skynet")
fs.BoolVar(&useDmsg, "dmsg", true, "listen on dmsg")
fs.StringVar(&passwordFile, "password-file", "", "path to bcrypt hash for HTTP basic auth")
fs.StringVar(&internalToken, "internal-token", "", "hypervisor proxy bypass token")
fs.BoolVar(&persistEnabled, "persist", false, "persist chat history to BoltDB")
fs.StringVar(&persistDBPath, "persist-db", "", "path to BoltDB file")
fs.IntVar(&persistMaxMsgSize, "persist-max-size", 4096, "max message size bytes")
Expand Down Expand Up @@ -268,11 +280,18 @@ func RunSkychat(ctx context.Context, args []string) error {
startPairPoller(ctx)
defer stopPairPoller()

http.Handle("/", http.FileServer(getFileSystem()))
http.HandleFunc("/message", messageHandler(ctx))
http.HandleFunc("/sse", sseHandler)
http.HandleFunc("/history", historyHandler)
http.HandleFunc("/history/peers", historyPeersHandler)
// Wire optional password protection. If passwordFile is empty or
// the file is missing, requireAuth* are no-ops.
if err := loadSkychatPassword(passwordFile); err != nil {
appLog("password file load: %v — continuing without auth", err)
}
setSkychatInternalToken(internalToken)

http.Handle("/", requireAuth(http.FileServer(getFileSystem())))
http.HandleFunc("/message", requireAuthFunc(messageHandler(ctx)))
http.HandleFunc("/sse", requireAuthFunc(sseHandler))
http.HandleFunc("/history", requireAuthFunc(historyHandler))
http.HandleFunc("/history/peers", requireAuthFunc(historyPeersHandler))
registerPairHTTPHandlers(ctx)

url := ""
Expand Down
13 changes: 12 additions & 1 deletion cmd/svc/transport-discovery/commands/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -359,8 +359,19 @@ Example:
defer agg.Close() //nolint:errcheck
logger.WithField("feed_pk", agg.FeedPK()).Info("CXO aggregator running: accepting inbound visor stats feeds")
}

// CXO metrics publisher: outbound feed mirroring the
// /metrics aggregate. Visors subscribe to TPD's PK on
// skyenv.DmsgTPDMetricsCXOPort and read the JSON-encoded
// []TransportMetric from "metrics/days/<n>" instead of
// HTTP-polling the same query.
if pub, perr := api.StartMetricsCXOPublisher(ctx, tpdAPI, h.DmsgClient, sk, logger); perr != nil {
logger.WithError(perr).Error("Failed to start CXO metrics publisher, continuing without it")
} else {
defer pub.Close() //nolint:errcheck
}
} else if enableCXO {
logger.Warn("CXO requested but dmsg is not enabled (--mode=http); aggregator disabled")
logger.Warn("CXO requested but dmsg is not enabled (--mode=http); aggregator/publisher disabled")
}

// Wire DHT entry mirroring: every transport registration is
Expand Down
17 changes: 15 additions & 2 deletions pkg/skyenv/skyenv.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ const (
// collide with DmsgHypervisorPort.
DmsgCXOPort uint16 = 50

// DmsgTPDMetricsCXOPort is the DMSG port the TPD's CXO metrics-
// aggregate publisher listens on (and visors dial when they want
// to subscribe to the network-wide transport metrics feed).
// Distinct from DmsgCXOPort because TPD already runs its
// CXO aggregator there for inbound visor stats publishers; the
// metrics publisher is a separate feed in the opposite direction.
DmsgTPDMetricsCXOPort uint16 = 51

// DmsgDHTPort Listening port for the Kademlia DHT protocol.
DmsgDHTPort uint16 = 100

Expand Down Expand Up @@ -84,8 +92,13 @@ const (
// SkychatPort is the dmsg port used by skychat
SkychatPort uint16 = 1

// SkychatAddr is the non-dmsg port used to access the skychat app on localhost
SkychatAddr = ":8001"
// SkychatAddr is the non-dmsg address skychat binds for its HTTP
// UI. Localhost-only by default since skychat is unauthenticated
// out of the box; the operator can opt into wider exposure with
// "*:8001" (the docker integration configs do this for inter-
// container reachability), and optional password protection is
// available via the hypervisor's Skychat password setting.
SkychatAddr = "127.0.0.1:8001"

// SkysocksName is the name of the skysocks app
SkysocksName = "skysocks"
Expand Down
Loading
Loading