feat(instance): public profile endpoint GET /api/instance (hub-app M1)
This commit is contained in:
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user