More work on tentacles.
This commit is contained in:
1 parent
573d15d7b4
commit
4cf3335017
29 files changed
+1194
-66
No files matched your search
@@ -295,6 +295,9 @@ func Run(ctx context.Context, options Options) error {
|
||||
}
|
||||
tentacles.output = &tentacleOutput{speech: speechMessages, braille: brailleOutput, gate: focusGate, live: live}
|
||||
focusGate.tentacleOutput = tentacles.output
|
||||
focusGate.remotePolicy = tentacle.NewRemotePolicy()
|
||||
focusGate.sync_remote_policy()
|
||||
tentacles.remotePolicy = focusGate.remotePolicy
|
||||
services = append(services, lifecycle.Service{Name: "tentacles", Start: tentacles.Start, Stop: tentacles.Stop})
|
||||
}
|
||||
preferences := newPreferencesWorker(logger, savedSettings, settingsUpdater)
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"sync/atomic"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
)
|
||||
|
||||
type commandGrabController interface {
|
||||
@@ -30,6 +31,7 @@ type commandGrabController interface {
|
||||
|
||||
type focusGate struct {
|
||||
tentacleOutput *tentacleOutput
|
||||
remotePolicy *tentacle.RemotePolicy
|
||||
|
||||
ocr *ocrSession
|
||||
mutex sync.Mutex
|
||||
@@ -64,12 +66,21 @@ func (gate *focusGate) SetSuspended(suspended bool) bool {
|
||||
return false
|
||||
}
|
||||
gate.retireTentacleOutput()
|
||||
gate.sync_remote_policy()
|
||||
if gate.commands != nil {
|
||||
gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load())
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Called during startup or with gate.mutex held. Cancellation never calls back
|
||||
// into the gate or runtime locks. Lock/unlock and handoff cannot restore consent.
|
||||
func (gate *focusGate) sync_remote_policy() {
|
||||
if gate.remotePolicy != nil {
|
||||
gate.remotePolicy.SetAvailable(!gate.secure.Load() && !gate.suspended.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func (gate *focusGate) InternalUIActive() bool {
|
||||
return gate != nil && gate.internalUI != nil && gate.internalUI.InternalUIActive()
|
||||
}
|
||||
|
||||
@@ -42,10 +42,11 @@ type internalUINotice struct {
|
||||
value interaction.Presentation
|
||||
}
|
||||
type internalUIRequest struct {
|
||||
ctx context.Context
|
||||
session *interaction.Session
|
||||
result chan internalUIResult
|
||||
ready chan error
|
||||
localOnly bool
|
||||
ctx context.Context
|
||||
session *interaction.Session
|
||||
result chan internalUIResult
|
||||
ready chan error
|
||||
}
|
||||
|
||||
// internalUI owns one modal session on its worker. The bounded event channel
|
||||
@@ -70,11 +71,21 @@ type internalUI struct {
|
||||
}
|
||||
|
||||
func (ui *internalUI) Open(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
|
||||
return ui.open_form(ctx, form, false)
|
||||
}
|
||||
|
||||
// OpenLocal is core-only. Unknown input backends cannot silently fall back to
|
||||
// ordinary forms when authorizing remote access or handling its secrets/trust.
|
||||
func (ui *internalUI) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
|
||||
return ui.open_form(ctx, form, true)
|
||||
}
|
||||
|
||||
func (ui *internalUI) open_form(ctx context.Context, form interaction.Form, localOnly bool) (<-chan internalUIResult, error) {
|
||||
session, err := interaction.NewSession(form)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1)}
|
||||
request := internalUIRequest{ctx: ctx, session: session, result: make(chan internalUIResult, 1), ready: make(chan error, 1), localOnly: localOnly}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
@@ -202,7 +213,17 @@ func (ui *internalUI) run(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
var err error
|
||||
sessionID, err = ui.keys.BeginInternalUI(request.ctx)
|
||||
if request.localOnly {
|
||||
if keys, ok := ui.keys.(interface {
|
||||
BeginLocalUI(context.Context) (uint64, error)
|
||||
}); ok {
|
||||
sessionID, err = keys.BeginLocalUI(request.ctx)
|
||||
} else {
|
||||
err = errors.New("local approval input unavailable")
|
||||
}
|
||||
} else {
|
||||
sessionID, err = ui.keys.BeginInternalUI(request.ctx)
|
||||
}
|
||||
if err != nil {
|
||||
request.ready <- err
|
||||
close(request.result)
|
||||
|
||||
@@ -140,6 +140,7 @@ func (gate *focusGate) SetLocked(active bool, adapter Adapter, listener InputLis
|
||||
return
|
||||
}
|
||||
gate.retireTentacleOutput()
|
||||
gate.sync_remote_policy()
|
||||
for _, target := range []any{adapter, listener, commands} {
|
||||
if controller, ok := target.(secureInputController); ok {
|
||||
controller.SetSecureInputActive(active)
|
||||
|
||||
@@ -32,8 +32,15 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
||||
return
|
||||
}
|
||||
defer func() { <-runtime.interactionSlots }()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
commandContext := ctx
|
||||
parent := ctx
|
||||
if len(command.session.RemoteGrants()) != 0 && runtime.remotePolicy != nil {
|
||||
parent = runtime.remotePolicy.Context()
|
||||
}
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
defer cancel()
|
||||
stopCommand := context.AfterFunc(commandContext, cancel)
|
||||
defer stopCommand()
|
||||
stop := context.AfterFunc(command.session.OutputContext(), cancel)
|
||||
defer stop()
|
||||
var temporaryFiles []string
|
||||
@@ -95,7 +102,7 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
||||
return
|
||||
}
|
||||
formCtx, closeForm := context.WithCancel(ctx)
|
||||
replies, openErr := runtime.forms.Open(formCtx, *value.Form)
|
||||
replies, openErr := runtime.open_tentacle_form(formCtx, command, *value.Form)
|
||||
if openErr != nil {
|
||||
closeForm()
|
||||
runtime.callInteraction(ctx, command, value.Next, tentaclewire.Submission{Cancelled: true})
|
||||
@@ -156,6 +163,17 @@ func (runtime *tentacleRuntime) interact(ctx context.Context, command tentacleCo
|
||||
}
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) open_tentacle_form(ctx context.Context, command tentacleCommand, form interaction.Form) (<-chan internalUIResult, error) {
|
||||
if len(command.session.RemoteGrants()) != 0 {
|
||||
local, ok := runtime.forms.(localTentacleForms)
|
||||
if !ok {
|
||||
return nil, errors.New("local approval input unavailable")
|
||||
}
|
||||
return local.OpenLocal(ctx, form)
|
||||
}
|
||||
return runtime.forms.Open(ctx, form)
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) validContinuation(command tentacleCommand, id string) bool {
|
||||
if id == "" {
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/interaction"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
type localTentacleForms interface {
|
||||
OpenLocal(context.Context, interaction.Form) (<-chan internalUIResult, error)
|
||||
}
|
||||
|
||||
// authorize_remote is a core entry point, not a registered Tentacle command or
|
||||
// child RPC. A future connection command must resolve the installed session and
|
||||
// role before calling it. A synthetic shortcut may request a dialog but cannot
|
||||
// select permissions or approve it through the local-only capture path.
|
||||
func (runtime *tentacleRuntime) authorize_remote(ctx context.Context, id, role string, grants []string) (tentaclewire.SessionEvent, error) {
|
||||
if runtime == nil || runtime.remotePolicy == nil || !tentaclewire.ValidRoleGrants(role, grants) {
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||
}
|
||||
forms, ok := runtime.forms.(localTentacleForms)
|
||||
if !ok {
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
||||
}
|
||||
grants = slices.Clone(grants)
|
||||
runtime.mutex.Lock()
|
||||
var target *tentacle.Session
|
||||
if !runtime.disabled {
|
||||
for _, session := range runtime.sessions {
|
||||
if session.TentacleID() == id {
|
||||
target = session
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.mutex.Unlock()
|
||||
if target == nil {
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrUnavailable
|
||||
}
|
||||
installed := target.RemoteGrants()
|
||||
for _, grant := range grants {
|
||||
if !slices.Contains(installed, grant) {
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||
}
|
||||
}
|
||||
// Reserve the interaction before creating a ticket, so a second request
|
||||
// rejected as busy cannot invalidate the dialog already on screen.
|
||||
select {
|
||||
case runtime.interactionSlots <- struct{}{}:
|
||||
default:
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrBusy
|
||||
}
|
||||
defer func() { <-runtime.interactionSlots }()
|
||||
approval, err := target.BeginRemoteApproval(role, grants)
|
||||
if err != nil {
|
||||
return tentaclewire.SessionEvent{}, err
|
||||
}
|
||||
defer approval.Cancel()
|
||||
ctx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
stopPolicy := context.AfterFunc(approval.Context(), cancel)
|
||||
defer stopPolicy()
|
||||
stopSession := context.AfterFunc(target.OutputContext(), cancel)
|
||||
defer stopSession()
|
||||
form := remote_consent_form(id, role, grants)
|
||||
replies, err := forms.OpenLocal(ctx, form)
|
||||
if err != nil {
|
||||
return tentaclewire.SessionEvent{}, err
|
||||
}
|
||||
select {
|
||||
case reply, open := <-replies:
|
||||
if !open || ctx.Err() != nil || reply.Cancelled || reply.Err != nil || reply.Activated != "approve" {
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||
}
|
||||
var selected []string
|
||||
for _, grant := range grants {
|
||||
if reply.Values[grant].Checked {
|
||||
selected = append(selected, grant)
|
||||
}
|
||||
}
|
||||
if len(selected) == 0 {
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||
}
|
||||
return target.AuthorizeRemoteWithApproval(approval, role, selected)
|
||||
case <-ctx.Done():
|
||||
return tentaclewire.SessionEvent{}, tentacle.ErrPermission
|
||||
}
|
||||
}
|
||||
|
||||
func remote_consent_form(id, role string, grants []string) interaction.Form {
|
||||
roleText := "control another computer"
|
||||
if role == "controlled" {
|
||||
roleText = "allow a helper to access this computer"
|
||||
}
|
||||
form := interaction.Form{Title: "Remote access for " + id + ": " + roleText +
|
||||
". The relay can read the channel key and session contents. A peer ID does not verify a person's identity. Select access for this session using a local keyboard.",
|
||||
Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}}
|
||||
labels := map[string]string{
|
||||
"output-observe": "Share local reader output with the remote controller",
|
||||
"remote-present": "Present output from the controlled computer",
|
||||
"keyboard-capture": "Forward local keyboard input to the controlled computer",
|
||||
"keyboard-inject": "Allow the remote controller to type on this computer",
|
||||
"clipboard-exchange": "Allow receiving clipboard text; send only on explicit local request",
|
||||
"braille-stream": "Exchange remote braille cells and supported display gestures",
|
||||
}
|
||||
for _, grant := range grants {
|
||||
form.Elements = append(form.Elements, interaction.Element{ID: grant, Label: labels[grant], Kind: interaction.CheckBox})
|
||||
}
|
||||
form.Elements = append(form.Elements, interaction.Element{ID: "approve", Label: "Approve selected access for this session", Kind: interaction.Button})
|
||||
return form
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/interaction"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentacle"
|
||||
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
|
||||
)
|
||||
|
||||
type consentFormRequest struct {
|
||||
ctx context.Context
|
||||
form interaction.Form
|
||||
reply chan internalUIResult
|
||||
}
|
||||
type consentTestForms struct{ requests chan consentFormRequest }
|
||||
|
||||
func (forms *consentTestForms) Open(context.Context, interaction.Form) (<-chan internalUIResult, error) {
|
||||
return nil, errors.New("ordinary form used for consent")
|
||||
}
|
||||
func (forms *consentTestForms) Notice(context.Context, tentaclewire.Presentation) {}
|
||||
func (forms *consentTestForms) OpenLocal(ctx context.Context, form interaction.Form) (<-chan internalUIResult, error) {
|
||||
reply := make(chan internalUIResult, 1)
|
||||
forms.requests <- consentFormRequest{ctx, form, reply}
|
||||
return reply, nil
|
||||
}
|
||||
|
||||
func consent_runtime_fixture(t *testing.T) (*tentacleRuntime, *tentacle.Session, *consentTestForms, *focusGate) {
|
||||
t.Helper()
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manager, err := tentacle.NewManager(tentacle.DefaultLimits(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := manager.Shutdown(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
policy := tentacle.NewRemotePolicy()
|
||||
if err := manager.UseRemotePolicy(policy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := manager.Start(t.Context(), tentacle.Spec{ID: "consent-probe", Program: []string{executable, "-test.run=^TestRemoteManagementPeer$", "--", "remote-management-peer"}, SessionEvents: true, RemoteGrants: []string{"output-observe", "clipboard-exchange"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-session.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("peer not ready")
|
||||
}
|
||||
if err := session.Activate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forms := &consentTestForms{requests: make(chan consentFormRequest, 1)}
|
||||
runtime := &tentacleRuntime{remotePolicy: policy, forms: forms, sessions: []*tentacle.Session{session}, interactionSlots: make(chan struct{}, 1)}
|
||||
return runtime, session, forms, &focusGate{remotePolicy: policy}
|
||||
}
|
||||
|
||||
func TestRemoteConsentFormDefaultsAndRevocation(t *testing.T) {
|
||||
for _, action := range []string{"approve", "cancel", "empty", "lock", "handoff", "revoke", "disable"} {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
runtime, session, forms, gate := consent_runtime_fixture(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := runtime.authorize_remote(ctx, "consent-probe", "controlled", []string{"output-observe", "clipboard-exchange"})
|
||||
result <- err
|
||||
}()
|
||||
var request consentFormRequest
|
||||
select {
|
||||
case request = <-forms.requests:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("no consent form")
|
||||
}
|
||||
if _, err := interaction.NewSession(request.form); err != nil {
|
||||
t.Fatal("invalid consent form", err)
|
||||
}
|
||||
if request.form.Elements[0].ID != "cancel" {
|
||||
t.Fatal("approval was default action")
|
||||
}
|
||||
for _, element := range request.form.Elements {
|
||||
if element.Checked {
|
||||
t.Fatal("permission preselected")
|
||||
}
|
||||
}
|
||||
reply := internalUIResult{Activated: "approve", Values: map[string]interaction.Value{"output-observe": {Checked: true}}}
|
||||
switch action {
|
||||
case "cancel":
|
||||
reply.Activated = "cancel"
|
||||
case "empty":
|
||||
reply.Values = nil
|
||||
case "lock":
|
||||
gate.SetLocked(true, nil, nil, nil, nil, &brailleWorker{})
|
||||
gate.SetLocked(false, nil, nil, nil, nil, &brailleWorker{})
|
||||
case "handoff":
|
||||
gate.SetSuspended(true)
|
||||
gate.SetSuspended(false)
|
||||
case "revoke":
|
||||
runtime.revokeManagedRemote("consent-probe")
|
||||
case "disable":
|
||||
session.Disable()
|
||||
}
|
||||
request.reply <- reply
|
||||
select {
|
||||
case err := <-result:
|
||||
if action == "approve" && err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if action != "approve" && err == nil {
|
||||
t.Fatal("stale/denied consent issued action")
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("consent worker did not finish")
|
||||
}
|
||||
if action != "approve" {
|
||||
return
|
||||
}
|
||||
pending, err := session.Invoke(ctx, "open", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var lease tentaclewire.RemoteLease
|
||||
select {
|
||||
case opened := <-pending:
|
||||
if opened.Err != nil || json.Unmarshal(opened.Value, &lease) != nil {
|
||||
t.Fatal("approved action failed", opened.Err)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
t.Fatal("open timed out")
|
||||
}
|
||||
delivery, err := session.RemoteContext(lease, "output-observe")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := session.RemoteContext(lease, "clipboard-exchange"); err == nil {
|
||||
t.Fatal("unchecked permission granted")
|
||||
}
|
||||
gate.SetSuspended(true)
|
||||
if delivery.Err() == nil {
|
||||
t.Fatal("handoff did not synchronously cancel delivery")
|
||||
}
|
||||
gate.SetSuspended(false)
|
||||
if _, err := session.RemoteContext(lease, "output-observe"); err == nil {
|
||||
t.Fatal("handoff resume revived access")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteGrantedTentacleUsesLocalForms(t *testing.T) {
|
||||
runtime, session, forms, _ := consent_runtime_fixture(t)
|
||||
form := interaction.Form{Title: "Certificate review", Elements: []interaction.Element{{ID: "cancel", Label: "Cancel", Kind: interaction.Button}}}
|
||||
if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, form); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case <-forms.requests:
|
||||
default:
|
||||
t.Fatal("remote form used ordinary input")
|
||||
}
|
||||
}
|
||||
|
||||
type ordinaryOnlyForms struct{ tentacleForms }
|
||||
|
||||
func TestRemoteConsentCannotFallBackToOrdinaryForms(t *testing.T) {
|
||||
runtime, session, _, _ := consent_runtime_fixture(t)
|
||||
runtime.forms = ordinaryOnlyForms{runtime.forms}
|
||||
if _, err := runtime.authorize_remote(t.Context(), "consent-probe", "controlled", []string{"output-observe"}); err == nil {
|
||||
t.Fatal("consent fell back to ordinary input")
|
||||
}
|
||||
if _, err := runtime.open_tentacle_form(t.Context(), tentacleCommand{session: session}, remote_consent_form("consent-probe", "controlled", []string{"output-observe"})); err == nil {
|
||||
t.Fatal("sensitive form fell back to ordinary input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemotePolicyAttachedToRuntimeManagers(t *testing.T) {
|
||||
runtime := &tentacleRuntime{remotePolicy: tentacle.NewRemotePolicy(), newManager: func() (*tentacle.Manager, error) { return tentacle.NewManager(tentacle.DefaultLimits(), nil) }}
|
||||
manager, err := runtime.makeManager()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := manager.UseRemotePolicy(tentacle.NewRemotePolicy()); err == nil {
|
||||
t.Fatal("reader manager omitted policy")
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type tentacleCommand struct {
|
||||
// native publication; mutex protects dispatch snapshots and immediate disable.
|
||||
// Only explicitly granted presentation commands reach the core output owner.
|
||||
type tentacleRuntime struct {
|
||||
remotePolicy *tentacle.RemotePolicy
|
||||
readerSettings *preferencesWorker
|
||||
extensionChanges chan struct{}
|
||||
forms tentacleForms
|
||||
@@ -421,6 +422,14 @@ func (runtime *tentacleRuntime) report(message string) {
|
||||
|
||||
// makeManager runs on the lifecycle worker, never on input or GTK callbacks.
|
||||
func (runtime *tentacleRuntime) makeManager() (*tentacle.Manager, error) {
|
||||
manager, err := runtime.make_manager()
|
||||
if err == nil && runtime.remotePolicy != nil {
|
||||
err = manager.UseRemotePolicy(runtime.remotePolicy)
|
||||
}
|
||||
return manager, err
|
||||
}
|
||||
|
||||
func (runtime *tentacleRuntime) make_manager() (*tentacle.Manager, error) {
|
||||
if runtime.newManager != nil {
|
||||
return runtime.newManager()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user