feat(instance): public profile endpoint GET /api/instance (hub-app M1)
This commit is contained in:
@@ -2,7 +2,7 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Dcntrlized Maestro API
|
||||
version: 1.0.0
|
||||
description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF).
|
||||
description: The API of the Maestro orchestration router. Maestro manages the lifecycle of Dcntrlized service containers and routes inter-service action calls to them. Every /api/* route except /api/auth/* and the deliberately public GET /api/instance (hub-app theme) requires an instance-owner session (cookies issued by the auth endpoints); in setup mode gated routes answer 428 ERR_SETUP_REQUIRED, otherwise 401 ERR_UNAUTHENTICATED. Mutating requests from browser origins are additionally checked against the request host (CSRF).
|
||||
|
||||
components:
|
||||
schemas:
|
||||
@@ -144,6 +144,90 @@ components:
|
||||
issued_at:
|
||||
type: string
|
||||
|
||||
InstanceIdentity:
|
||||
type: object
|
||||
description: The instance's public self-description (hub-app M1).
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: "home-node"
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
fingerprint:
|
||||
type: string
|
||||
description: Instance CA public-key SHA256 (lib/pki); omitted when no CA is provisioned.
|
||||
example: "sha256:…"
|
||||
|
||||
PublicService:
|
||||
type: object
|
||||
description: |
|
||||
Deliberately reduced view of a running service — no container id,
|
||||
docker name, private IP, or port mapping leaves the instance (hub-app D3).
|
||||
required: [service_name, status, image]
|
||||
properties:
|
||||
service_name:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
capabilities:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
has_auth:
|
||||
type: boolean
|
||||
image:
|
||||
type: string
|
||||
|
||||
TrustedInstance:
|
||||
type: object
|
||||
description: A remote instance this instance trusts (federation store row).
|
||||
required: [name, url, fingerprint, status]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
url:
|
||||
type: string
|
||||
fingerprint:
|
||||
type: string
|
||||
example: "sha256:…"
|
||||
status:
|
||||
type: string
|
||||
example: "authorized"
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
InstancePublicProfile:
|
||||
type: object
|
||||
description: |
|
||||
The unauthenticated GET /api/instance response (hub-app theme). With
|
||||
DCNTRLIZED_PUBLIC_PROFILE=false only `instance` is returned. Absent
|
||||
federation store → trusted_instances:null; present-but-empty → [].
|
||||
required: [instance]
|
||||
properties:
|
||||
instance:
|
||||
$ref: '#/components/schemas/InstanceIdentity'
|
||||
services:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
$ref: '#/components/schemas/PublicService'
|
||||
trusted_instances:
|
||||
type: array
|
||||
nullable: true
|
||||
items:
|
||||
$ref: '#/components/schemas/TrustedInstance'
|
||||
|
||||
RegistryContainer:
|
||||
type: object
|
||||
required: [name, image]
|
||||
@@ -506,6 +590,24 @@ paths:
|
||||
'502':
|
||||
description: Failed to connect to upstream registry
|
||||
|
||||
/api/instance:
|
||||
get:
|
||||
summary: Public instance profile (unauthenticated)
|
||||
tags: ["instance"]
|
||||
security: []
|
||||
description: >-
|
||||
The one read surface the owner gate deliberately allows through
|
||||
(hub-app theme M1/D2): what this instance is, which non-hidden services
|
||||
it runs (public fields only), and which remote instances it trusts.
|
||||
DCNTRLIZED_PUBLIC_PROFILE=false omits services + trusted_instances.
|
||||
responses:
|
||||
'200':
|
||||
description: The instance public profile
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InstancePublicProfile'
|
||||
|
||||
/api/auth/status:
|
||||
get:
|
||||
summary: Instance-owner gate state (public)
|
||||
|
||||
@@ -148,3 +148,39 @@ export interface SessionResponse {
|
||||
role: string
|
||||
access_ttl_s: number
|
||||
}
|
||||
|
||||
/** The instance's public self-description (hub-app M1). */
|
||||
export interface InstanceIdentity {
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
fingerprint?: string
|
||||
}
|
||||
|
||||
/** Deliberately reduced service view — no ids, IPs, or ports (hub-app D3). */
|
||||
export interface PublicService {
|
||||
service_name: string
|
||||
title?: string
|
||||
description?: string
|
||||
status: string
|
||||
version?: string
|
||||
capabilities?: string[]
|
||||
has_auth?: boolean
|
||||
image: string
|
||||
}
|
||||
|
||||
/** A remote instance this instance trusts (federation store row). */
|
||||
export interface TrustedInstance {
|
||||
name: string
|
||||
url: string
|
||||
fingerprint: string
|
||||
status: string
|
||||
scopes?: string[]
|
||||
}
|
||||
|
||||
/** GET /api/instance — the one unauthenticated read surface. */
|
||||
export interface InstancePublicProfile {
|
||||
instance: InstanceIdentity
|
||||
services?: PublicService[] | null
|
||||
trusted_instances?: TrustedInstance[] | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package instance serves the instance's public profile: the single
|
||||
// unauthenticated read surface the owner gate allows through (hub-app theme
|
||||
// M1/D2). It answers "what is this instance, what does it run, and who does
|
||||
// it trust?" for the untrusted-first hub client.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/env"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/instance"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
// InstanceIdentity is the public face of this instance. Fingerprint is the
|
||||
// instance CA's public-key hash — the same value a peer pins at first contact.
|
||||
type InstanceIdentity struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
}
|
||||
|
||||
// PublicService is the deliberately reduced view of a running service:
|
||||
// no container id, docker name, private IP, or port mapping ever leaves the
|
||||
// instance (hub-app D3).
|
||||
type PublicService struct {
|
||||
ServiceName string `json:"service_name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
HasAuth bool `json:"has_auth,omitempty"`
|
||||
Image string `json:"image"`
|
||||
}
|
||||
|
||||
// PublicProfile is the GET /api/instance wire shape. The section pointers
|
||||
// carry three distinct states: nil → field omitted (privacy switch off),
|
||||
// pointer-to-nil → JSON null (store absent), pointer-to-empty → JSON [].
|
||||
type PublicProfile struct {
|
||||
Instance InstanceIdentity `json:"instance"`
|
||||
Services *[]PublicService `json:"services,omitempty"`
|
||||
TrustedInstances *[]federation.RemoteInstance `json:"trusted_instances,omitempty"`
|
||||
}
|
||||
|
||||
// NewPublicProfileHandler serves GET /api/instance. getServices is the cache
|
||||
// accessor (cache.GetServices self-locks); dataRoot resolves identity, CA and
|
||||
// federation stores on demand.
|
||||
func NewPublicProfileHandler(getServices func() []types.Container, dataRoot string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
profile := PublicProfile{
|
||||
Instance: buildIdentity(dataRoot),
|
||||
}
|
||||
|
||||
if env.PublicProfile() {
|
||||
services := publicServices(getServices())
|
||||
trusted, err := federation.List(dataRoot)
|
||||
if err != nil {
|
||||
// A malformed store must not take the profile down;
|
||||
// report absence instead.
|
||||
trusted = nil
|
||||
}
|
||||
profile.Services = &services
|
||||
profile.TrustedInstances = &trusted
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(profile); err != nil {
|
||||
http.Error(w, "Error encoding instance profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func buildIdentity(dataRoot string) InstanceIdentity {
|
||||
id := instance.Load(dataRoot)
|
||||
return InstanceIdentity{
|
||||
Name: id.Name,
|
||||
Title: id.Title,
|
||||
Description: id.Description,
|
||||
Fingerprint: pki.CAFingerprint(dataRoot),
|
||||
}
|
||||
}
|
||||
|
||||
// publicServices filters hidden infrastructure (role-labeled containers such
|
||||
// as dcntrlized-db / dcntrlized-auth and anything flagged hidden) down to the
|
||||
// public field set.
|
||||
func publicServices(all []types.Container) []PublicService {
|
||||
public := make([]PublicService, 0, len(all))
|
||||
for _, c := range all {
|
||||
if c.Hidden || c.Role != "" {
|
||||
continue
|
||||
}
|
||||
name := c.ServiceName
|
||||
if name == "" {
|
||||
name = c.Name
|
||||
}
|
||||
public = append(public, PublicService{
|
||||
ServiceName: name,
|
||||
Title: c.Title,
|
||||
Description: c.Description,
|
||||
Status: c.Status,
|
||||
Version: c.Version,
|
||||
Capabilities: c.Capabilities,
|
||||
HasAuth: c.HasAuth,
|
||||
Image: c.Image,
|
||||
})
|
||||
}
|
||||
return public
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package instance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/federation"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||
)
|
||||
|
||||
func testServices() []types.Container {
|
||||
return []types.Container{
|
||||
{
|
||||
ID: "c1", Name: "keepnote-1", ServiceName: "keepnote", DockerName: "keepnote-1",
|
||||
Image: "registry.example/dcntrlized/keepnote:1.0", Version: "1.0",
|
||||
Status: "running", Title: "KeepNote", Description: "notes app",
|
||||
Capabilities: []string{"database"}, HasAuth: true,
|
||||
Network: types.Network{Ports: []types.PortMapping{{Host: 8081, Guest: 8080}}},
|
||||
},
|
||||
{ID: "c2", Name: "dcntrlized-db", Role: "db", Hidden: true, Status: "running"},
|
||||
{ID: "c3", Name: "dcntrlized-auth", Role: "auth", Hidden: true, Status: "running"},
|
||||
{ID: "c4", Name: "secret", Hidden: true, Status: "running"},
|
||||
}
|
||||
}
|
||||
|
||||
func serve(t *testing.T, dataRoot string, services []types.Container) map[string]any {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(NewPublicProfileHandler(func() []types.Container { return services }, dataRoot))
|
||||
t.Cleanup(srv.Close)
|
||||
resp, err := http.Get(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("GET failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func TestPublicProfileFiltersHiddenInfra(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
body := serve(t, dataRoot, testServices())
|
||||
|
||||
raw, _ := json.Marshal(body["services"])
|
||||
var services []map[string]any
|
||||
if err := json.Unmarshal(raw, &services); err != nil {
|
||||
t.Fatalf("services not an array: %v", err)
|
||||
}
|
||||
if len(services) != 1 {
|
||||
t.Fatalf("got %d services, want exactly the one public service (%s)", len(services), raw)
|
||||
}
|
||||
svc := services[0]
|
||||
if svc["service_name"] != "keepnote" || svc["status"] != "running" || svc["image"] == "" {
|
||||
t.Fatalf("unexpected public service shape: %v", svc)
|
||||
}
|
||||
|
||||
// No private fields may serialize.
|
||||
for _, forbidden := range []string{"id", "docker_name", "network", "ipAddress", "ports", "role", "hidden", "public_key"} {
|
||||
if _, ok := svc[forbidden]; ok {
|
||||
t.Errorf("public service leaked %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfileIdentityFallbackAndFile(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
body := serve(t, dataRoot, nil)
|
||||
instance := body["instance"].(map[string]any)
|
||||
if instance["name"] == "" {
|
||||
t.Fatal("identity name must fall back to hostname/env, got empty")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(dataRoot, "instance.json"),
|
||||
[]byte(`{"name":"home-node","title":"Home node","description":"main rig"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = serve(t, dataRoot, nil)
|
||||
instance = body["instance"].(map[string]any)
|
||||
if instance["name"] != "home-node" || instance["title"] != "Home node" {
|
||||
t.Fatalf("file identity not used: %v", instance)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfileTrustedInstancesNullVsEmpty(t *testing.T) {
|
||||
dataRoot := t.TempDir()
|
||||
|
||||
// Store absent → JSON null (field present with null value).
|
||||
body := serve(t, dataRoot, testServices())
|
||||
if v, ok := body["trusted_instances"]; !ok || v != nil {
|
||||
raw, _ := json.Marshal(body)
|
||||
t.Fatalf("absent federation store should yield trusted_instances:null, got %s", raw)
|
||||
}
|
||||
|
||||
// Present but empty → [].
|
||||
if err := os.MkdirAll(filepath.Join(dataRoot, "federation"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(federation.Path(dataRoot), []byte(`[]`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = serve(t, dataRoot, testServices())
|
||||
raw, _ := json.Marshal(body["trusted_instances"])
|
||||
if string(raw) != "[]" {
|
||||
t.Fatalf("empty store should marshal as [], got %s", raw)
|
||||
}
|
||||
|
||||
// Populated → rows pass through.
|
||||
populated := `{"instances":[{"name":"peer-b","url":"http://192.168.1.20:8080","fingerprint":"sha256:ab","status":"authorized","scopes":["read"]}]}`
|
||||
if err := os.WriteFile(federation.Path(dataRoot), []byte(populated), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body = serve(t, dataRoot, testServices())
|
||||
list := body["trusted_instances"].([]any)
|
||||
if len(list) != 1 || list[0].(map[string]any)["name"] != "peer-b" {
|
||||
t.Fatalf("populated store not surfaced: %v", list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProfilePrivacySwitchOmitsSections(t *testing.T) {
|
||||
t.Setenv("DCNTRLIZED_PUBLIC_PROFILE", "false")
|
||||
dataRoot := t.TempDir()
|
||||
body := serve(t, dataRoot, testServices())
|
||||
|
||||
for _, section := range []string{"services", "trusted_instances"} {
|
||||
if _, ok := body[section]; ok {
|
||||
t.Errorf("%q must be omitted when DCNTRLIZED_PUBLIC_PROFILE=false", section)
|
||||
}
|
||||
}
|
||||
if _, ok := body["instance"]; !ok {
|
||||
t.Error("instance identity must remain visible with the profile off")
|
||||
}
|
||||
}
|
||||
Vendored
+7
@@ -32,3 +32,10 @@ func IsDev() bool {
|
||||
func GiteaURL() string {
|
||||
return os.Getenv("DCNTRLIZED_GITEA_URL")
|
||||
}
|
||||
|
||||
// PublicProfile reports whether the unauthenticated instance profile
|
||||
// (GET /api/instance, hub-app theme M1) is served. Default on; set
|
||||
// DCNTRLIZED_PUBLIC_PROFILE=false to blank services + trusted_instances.
|
||||
func PublicProfile() bool {
|
||||
return os.Getenv("DCNTRLIZED_PUBLIC_PROFILE") != "false"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package federation is a defensive reader for the remote-instance trust
|
||||
// store that the multi-registry & instance federation theme will own. The
|
||||
// hub app's public profile (hub-app theme M1) surfaces authorized peers from
|
||||
// it when present; until that theme lands, the file simply does not exist and
|
||||
// every read returns nil.
|
||||
package federation
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// RemoteInstance mirrors the planned trust-store row: a peer this instance
|
||||
// trusts, addressed by URL and identified by its CA fingerprint.
|
||||
type RemoteInstance struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
Status string `json:"status"`
|
||||
Scopes []string `json:"scopes,omitempty"`
|
||||
}
|
||||
|
||||
// Path returns the trust-store location for the given data root.
|
||||
func Path(dataRoot string) string {
|
||||
return filepath.Join(dataRoot, "federation", "instances.json")
|
||||
}
|
||||
|
||||
// List reads the authorized remote instances. A missing store yields
|
||||
// (nil, nil): callers distinguish "absent" (nil → JSON null) from
|
||||
// "present but empty" (non-nil, len 0 → JSON []).
|
||||
func List(dataRoot string) ([]RemoteInstance, error) {
|
||||
data, err := os.ReadFile(Path(dataRoot))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Defensive parse: accept both a bare array and the wrapped shape the
|
||||
// federation theme is expected to persist.
|
||||
var direct []RemoteInstance
|
||||
if json.Unmarshal(data, &direct) == nil {
|
||||
return direct, nil
|
||||
}
|
||||
var wrapped struct {
|
||||
Instances []RemoteInstance `json:"instances"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrapped); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return wrapped.Instances, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (C) 2026 Dcntrlized
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published
|
||||
// by the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package instance holds the instance's public identity: a small, self-
|
||||
// contained file at the data root that answers "who am I" for the hub app's
|
||||
// public profile (hub-app theme M1) and, later, federation attestation.
|
||||
package instance
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Identity is the instance's user-visible self-description. Name falls back to
|
||||
// DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent;
|
||||
// Title and Description are optional and only ever come from the file.
|
||||
type Identity struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// Path returns the identity file location for the given data root.
|
||||
func Path(dataRoot string) string {
|
||||
return filepath.Join(dataRoot, "instance.json")
|
||||
}
|
||||
|
||||
// Load resolves the instance identity: instance.json when present, else
|
||||
// DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design —
|
||||
// no daemon state, so an edited file shows up on the next request.
|
||||
func Load(dataRoot string) Identity {
|
||||
id := defaultIdentity()
|
||||
|
||||
if data, err := os.ReadFile(Path(dataRoot)); err == nil {
|
||||
var fromFile Identity
|
||||
if json.Unmarshal(data, &fromFile) == nil && fromFile.Name != "" {
|
||||
if fromFile.Title != "" {
|
||||
id.Title = fromFile.Title
|
||||
}
|
||||
if fromFile.Description != "" {
|
||||
id.Description = fromFile.Description
|
||||
}
|
||||
id.Name = fromFile.Name
|
||||
}
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// Save atomically writes the identity file (tmp + rename), chmod 600 like the
|
||||
// rest of Maestro's file stores. The owner-gated edit endpoint that uses this
|
||||
// lands with the federation theme; the helper exists so both callers share one
|
||||
// write path.
|
||||
func Save(dataRoot string, id Identity) error {
|
||||
if id.Name == "" {
|
||||
return fmt.Errorf("instance name must not be empty")
|
||||
}
|
||||
data, err := json.MarshalIndent(id, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal instance identity: %w", err)
|
||||
}
|
||||
path := Path(dataRoot)
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, append(data, '\n'), 0o600); err != nil {
|
||||
return fmt.Errorf("write instance identity: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace instance identity: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultIdentity() Identity {
|
||||
name := os.Getenv("DCNTRLIZED_INSTANCE_NAME")
|
||||
if name == "" {
|
||||
if host, err := os.Hostname(); err == nil {
|
||||
name = host
|
||||
}
|
||||
}
|
||||
return Identity{Name: name}
|
||||
}
|
||||
@@ -117,3 +117,23 @@ func FingerprintDER(spki []byte) string {
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// CAFingerprint returns the instance CA's public-key fingerprint by reading
|
||||
// the certificate from disk on demand (hub-app M1's public profile surfaces
|
||||
// it as the instance identity). Empty string when no CA exists yet — dev-mode
|
||||
// static services never provision one.
|
||||
func CAFingerprint(dataRoot string) string {
|
||||
pemBytes, err := os.ReadFile(CertPath(dataRoot))
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
if block == nil {
|
||||
return ""
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return Fingerprint(cert)
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
|
||||
handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers"
|
||||
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
|
||||
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
|
||||
@@ -150,6 +151,12 @@ func main() {
|
||||
mux.HandleFunc("/api/auth/logout", authService.Logout)
|
||||
mux.Handle("/api/auth/change-password", gate(http.HandlerFunc(authService.ChangePassword)))
|
||||
|
||||
// Instance public profile (hub-app theme M1): the one additional
|
||||
// unauthenticated surface — deliberately allowlisted around the owner
|
||||
// gate (cross-theme contract, hub-app D2). Exact path only; sub-paths
|
||||
// like /api/instance/attestation (federation) are registered separately.
|
||||
mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot))
|
||||
|
||||
mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS)))
|
||||
mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache)))
|
||||
mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache)))
|
||||
|
||||
Reference in New Issue
Block a user