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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ cd InfraCanvas && make all

Requires Go 1.21+ and Node.js 20+. Open the URL it prints. `--private` binds `127.0.0.1` only, `--no-tunnel` skips Cloudflare entirely; nothing leaves your machine.

- **Have Docker or a local Kubernetes context** (kind, minikube, Docker Desktop) on this machine? You're already looking at its live topology, nothing else to configure.
- **Have Docker or a local Kubernetes context** (kind, minikube, Docker Desktop) on this machine? InfraCanvas auto-discovers local kubeconfig contexts (`$KUBECONFIG` or `~/.kube/config`) and connects to the current context at startup. To skip local kubeconfig auto-discovery, run `infracanvas serve --discover-local-kubeconfig=false` (or set `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false`).
- **Want to point it at a real cluster?** Click **+** next to **Clusters** in the sidebar and drop a kubeconfig. The dashboard talks to that cluster's API server directly, the same way `kubectl` does; the file is read into memory by this same local process and never transmitted anywhere. A picker appears if it has multiple contexts. [Full details](#clusters-kubernetes-with-zero-install), or expand below if you don't have a kubeconfig handy.

<details>
Expand Down Expand Up @@ -163,6 +163,9 @@ curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/ins
# Read-only: viewers can look, not touch; public demos and dashboards on a TV
curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --read-only

# Disable local kubeconfig auto-discovery (keeps Docker auto-discovery on)
INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false infracanvas serve

# Agent-only: join this VM to an existing hub (see Multiple VMs below)
curl -fsSL https://github.com/bytestrix/InfraCanvas/releases/latest/download/install.sh | bash -s -- --join <hub-url> --token <join-token>

Expand Down
1 change: 1 addition & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Treat the URL+token like an SSH key for the box.
- **No persistent storage of graph state**: graphs are recomputed every refresh; the relay does not persist snapshots between restarts. The one exception is Clusters (see below), which by design persists a credential locally so the connection survives a restart.
- **Pair codes**: generated by the agent for legacy shared-relay deployments. In the default local serve-mode the browser auto-pairs from loopback and pair codes are unused.
- **Clusters (kubeconfig direct-connect)**: an uploaded kubeconfig is written to `<state dir>/clusters/<id>.kubeconfig` at `0600`, readable only by the user running `infracanvas serve`, and is **never transmitted anywhere**: it's read directly by the same process serving your dashboard. Deleting a cluster from the UI deletes this file. Anyone with shell access to the box as that user (or root) can read it, same as they already could read `~/.kube/config` or any other credential on the machine; this introduces no new privilege beyond what local shell access already grants.
- **Local kubeconfig auto-discovery**: by default, the host agent checks in-cluster config first, then local kubeconfig (`$KUBECONFIG` or `~/.kube/config`) and can auto-connect the current context. Disable this path with `--discover-local-kubeconfig=false` or `INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG=false`. This does not disable Docker discovery, and it does not affect explicit Clusters connections added from the UI.
- **Read-only mode.** `--read-only` blocks every mutating action, terminal/exec session, and (as of v0.18.1) cluster connect/disconnect, at the relay before it reaches the agent or any credential-executing code path. Viewing topology and logs still works. Use this for public demos.
- **Per-cluster read-only (v0.19.0+).** Independent of the global `--read-only` flag, each Clusters connection has its own read-only toggle (set it when connecting, or flip it later), so you can run some clusters view-only while others stay fully interactive on the same dashboard. Enforced at the same relay layer as global read-only mode.
- **Permission preview (v0.19.0+).** Before you commit to connecting a kubeconfig, `POST /api/clusters/preview` (surfaced in the Add Cluster dialog) runs a handful of `SelfSubjectAccessReview` checks against it (a standard Kubernetes API any authenticated credential can call about its own permissions) and shows what it can actually do (view, exec, restart, scale, read Secrets) before anything is persisted or connected.
Expand Down
29 changes: 19 additions & 10 deletions cmd/infracanvas/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,15 @@ import (
"syscall"
"time"

"github.com/spf13/cobra"
"infracanvas/pkg/agent"
"infracanvas/pkg/audit"
"infracanvas/pkg/clustermgr"
"infracanvas/pkg/runstate"
"infracanvas/pkg/server"
"infracanvas/pkg/tunnel"
"infracanvas/pkg/webui"

"github.com/spf13/cobra"
)

const (
Expand All @@ -32,14 +33,15 @@ const (
)

var (
servePort int
servePrivate bool
serveNoTunnel bool
serveReadOnly bool
serveUIToken string
serveAgentToken string
serveScope []string
serveRefresh int
servePort int
servePrivate bool
serveNoTunnel bool
serveReadOnly bool
serveUIToken string
serveAgentToken string
serveScope []string
serveRefresh int
serveDiscoverLocalKubeconfig bool
)

var serveCmd = &cobra.Command{
Expand All @@ -65,7 +67,9 @@ and it appears in the sidebar's machine list.
Environment variables:
INFRACANVAS_UI_TOKEN Auth token (default: random per run)
INFRACANVAS_AGENT_TOKEN Join token other VMs use (default: random per run)
INFRACANVAS_SCOPE Discovery scopes (default: host,docker,kubernetes)`,
INFRACANVAS_SCOPE Discovery scopes (default: host,docker,kubernetes)
INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG
Override --discover-local-kubeconfig`,
RunE: runServe,
}

Expand All @@ -79,6 +83,7 @@ func init() {
serveCmd.Flags().StringVar(&serveAgentToken, "agent-token", "", "Override the join token other VMs use to connect")
serveCmd.Flags().StringSliceVar(&serveScope, "discover", []string{"host", "docker", "lxd", "kubernetes"}, "Discovery scopes")
serveCmd.Flags().IntVar(&serveRefresh, "refresh", 30, "Seconds between discovery refreshes")
serveCmd.Flags().BoolVar(&serveDiscoverLocalKubeconfig, "discover-local-kubeconfig", true, "Auto-discover local kubeconfig contexts when in-cluster config is unavailable")
}

func runServe(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -217,6 +222,10 @@ func runServe(cmd *cobra.Command, args []string) error {
cfg.RefreshSeconds = serveRefresh
cfg.EnableRedaction = true
cfg.QuietPairBanner = true
cfg.AutoDiscoverLocalKubeconfig = serveDiscoverLocalKubeconfig
if v, ok := parseBoolEnv("INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG"); ok {
cfg.AutoDiscoverLocalKubeconfig = v
}

for {
if ctx.Err() != nil {
Expand Down
39 changes: 30 additions & 9 deletions cmd/infracanvas/cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import (
"context"
"fmt"
"os"
"strconv"

"github.com/spf13/cobra"
"infracanvas/pkg/agent"
"infracanvas/pkg/discovery/lxd"

"github.com/spf13/cobra"
)

// containsStr reports whether s is present in xs.
Expand All @@ -21,11 +23,12 @@ func containsStr(xs []string, s string) bool {
}

var (
backendURL string
agentToken string
refreshSeconds int
tlsInsecure bool
noRedact bool
backendURL string
agentToken string
refreshSeconds int
tlsInsecure bool
noRedact bool
startDiscoverLocalKubeconfig bool
)

var startCmd = &cobra.Command{
Expand All @@ -43,9 +46,10 @@ Run this on the machine you want to observe:
Then open the canvas UI and enter the displayed pair code.

Environment variables:
INFRACANVAS_BACKEND Override --backend
INFRACANVAS_SCOPE Override --scope (comma-separated)
INFRACANVAS_TOKEN Override --token (shared secret)`,
INFRACANVAS_BACKEND Override --backend
INFRACANVAS_SCOPE Override --scope (comma-separated)
INFRACANVAS_TOKEN Override --token (shared secret)
INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG Override --discover-local-kubeconfig`,
RunE: runStart,
}

Expand All @@ -57,6 +61,7 @@ func init() {
startCmd.Flags().IntVar(&refreshSeconds, "refresh", 30, "Seconds between refresh cycles")
startCmd.Flags().BoolVar(&tlsInsecure, "tls-insecure", false, "Skip TLS certificate verification")
startCmd.Flags().BoolVar(&noRedact, "no-redact", false, "Disable sensitive data redaction")
startCmd.Flags().BoolVar(&startDiscoverLocalKubeconfig, "discover-local-kubeconfig", true, "Auto-discover local kubeconfig contexts when in-cluster config is unavailable")
}

func runStart(cmd *cobra.Command, args []string) error {
Expand All @@ -83,6 +88,10 @@ func runStart(cmd *cobra.Command, args []string) error {
if envScope := os.Getenv("INFRACANVAS_SCOPE"); envScope != "" {
cfg.Scope = splitComma(envScope)
}
cfg.AutoDiscoverLocalKubeconfig = startDiscoverLocalKubeconfig
if v, ok := parseBoolEnv("INFRACANVAS_DISCOVER_LOCAL_KUBECONFIG"); ok {
cfg.AutoDiscoverLocalKubeconfig = v
}

// Zero-config LXC/LXD/Incus: the install script pins a fixed scope
// (host,docker,kubernetes), so auto-append "lxd" when a local LXD/Incus
Expand Down Expand Up @@ -131,3 +140,15 @@ func splitOn(s string, sep rune) []string {
parts = append(parts, s[start:])
return parts
}

func parseBoolEnv(key string) (bool, bool) {
v := os.Getenv(key)
if v == "" {
return false, false
}
parsed, err := strconv.ParseBool(v)
if err != nil {
return false, false
}
return parsed, true
}
89 changes: 83 additions & 6 deletions frontend/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type { SessionInfo } from '@/types'

const LOCAL_KEY = 'local'
const SESSIONS_POLL_MS = 10_000
const LOCAL_KUBECONFIG_NOTICE_KEY = 'ic-local-kubeconfig-autodiscovery-notice-v1'

// The websocket/store key for a machine: the hub's own agent uses the
// 'local' alias, remote agents their session id.
Expand Down Expand Up @@ -644,6 +645,76 @@ function Sidebar({ vm, view, onViewChange, machines, clusters, activeKey, onSele
function MainContent({ vm, vmKey, view, onSwitchToCanvas }: { vm: any; vmKey: string; view: View; onSwitchToCanvas: () => void }) {
const isLoading = !vm || vm.status === 'connecting' || vm.status === 'paired'
const isError = vm?.status === 'error'
const localKubeconfigAuto = vm?.graph?.snapshot?.localKubeconfigAutoDiscovery
const [showLocalKubeconfigNotice, setShowLocalKubeconfigNotice] = useState(false)
const [connectedLocalContexts, setConnectedLocalContexts] = useState(0)

useEffect(() => {
const shouldShow = vmKey === LOCAL_KEY &&
!!localKubeconfigAuto?.enabled &&
!!localKubeconfigAuto?.ran &&
(localKubeconfigAuto?.discoveredContexts ?? 0) > 0 &&
(localKubeconfigAuto?.connectedContexts ?? 0) > 0

if (!shouldShow || typeof window === 'undefined') {
setShowLocalKubeconfigNotice(false)
return
}
if (window.localStorage.getItem(LOCAL_KUBECONFIG_NOTICE_KEY) === '1') {
setShowLocalKubeconfigNotice(false)
return
}

setConnectedLocalContexts(localKubeconfigAuto.connectedContexts)
setShowLocalKubeconfigNotice(true)
}, [
vmKey,
localKubeconfigAuto?.enabled,
localKubeconfigAuto?.ran,
localKubeconfigAuto?.discoveredContexts,
localKubeconfigAuto?.connectedContexts,
])

const dismissLocalKubeconfigNotice = () => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCAL_KUBECONFIG_NOTICE_KEY, '1')
}
setShowLocalKubeconfigNotice(false)
}

const notice = showLocalKubeconfigNotice ? (
<div style={{
margin:'14px 16px 0',
padding:'10px 12px',
borderRadius:10,
border:`1px solid ${T.line}`,
background:T.surface,
display:'flex',
alignItems:'center',
justifyContent:'space-between',
gap:12,
}}>
<div style={{ fontSize:12.5, color:T.ink2 }}>
Connected to {connectedLocalContexts} local Kubernetes context{connectedLocalContexts === 1 ? '' : 's'} from your kubeconfig.
</div>
<button
onClick={dismissLocalKubeconfigNotice}
style={{
border:`1px solid ${T.line2}`,
background:'transparent',
color:T.ink3,
borderRadius:7,
cursor:'pointer',
fontSize:11.5,
padding:'4px 8px',
fontFamily:MONO,
flexShrink:0,
}}
>
Dismiss
</button>
</div>
) : null

if (isError) {
return <ErrorContent message={vm.error ?? 'Connection failed'} />
Expand All @@ -657,6 +728,7 @@ function MainContent({ vm, vmKey, view, onSwitchToCanvas }: { vm: any; vmKey: st
if (isLoading) return <LoadingContent />
return (
<div style={{ flex:1, position:'relative', height:'100%', overflow:'hidden' }}>
{notice}
<InfraCanvas vm={vm} />
</div>
)
Expand All @@ -665,12 +737,17 @@ function MainContent({ vm, vmKey, view, onSwitchToCanvas }: { vm: any; vmKey: st
if (isLoading || !vm?.graph) return <LoadingContent />

return (
<AgentOverview
graph={vm.graph}
hostname={vm.hostname ?? null}
vmCode={vmKey}
onSwitchToCanvas={onSwitchToCanvas}
/>
<div style={{ flex:1, minHeight:0, display:'flex', flexDirection:'column' }}>
{notice}
<div style={{ flex:1, minHeight:0 }}>
<AgentOverview
graph={vm.graph}
hostname={vm.hostname ?? null}
vmCode={vmKey}
onSwitchToCanvas={onSwitchToCanvas}
/>
</div>
</div>
)
}

Expand Down
Loading
Loading