Skip to content

Commit ca57a19

Browse files
authored
v2.7.1 Add key management (#755)
* add key routes * better style * better style * final pass on keys gui and fix system logging
1 parent 5f1d134 commit ca57a19

9 files changed

Lines changed: 2203 additions & 62 deletions

File tree

goseg/handler/keys.go

Lines changed: 815 additions & 0 deletions
Large diffs are not rendered by default.

goseg/logger/logger.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"fmt"
88
"groundseg/dockerclient"
9+
"io"
910
"os"
1011
"path"
1112
"strings"
@@ -294,11 +295,19 @@ func getDockerLogs(name string) ([]byte, error) {
294295
}
295296

296297
func RetrieveSysLogHistory() ([]byte, error) {
298+
logs, _, _, err := RetrieveSysLogHistoryWithOffset()
299+
return logs, err
300+
}
301+
302+
func RetrieveSysLogHistoryWithOffset() ([]byte, string, int64, error) {
297303
filePath := SysLogfile()
298304
// Open the file
299305
file, err := os.Open(filePath)
300306
if err != nil {
301-
return []byte{}, fmt.Errorf("Error opening file: %v", err)
307+
if os.IsNotExist(err) {
308+
return []byte(`{"type":"system","history":true,"log":[]}`), filePath, 0, nil
309+
}
310+
return []byte{}, filePath, 0, fmt.Errorf("Error opening file: %v", err)
302311
}
303312
defer file.Close()
304313

@@ -313,13 +322,16 @@ func RetrieveSysLogHistory() ([]byte, error) {
313322

314323
// Check for scanner errors
315324
if err := scanner.Err(); err != nil {
316-
return []byte{}, fmt.Errorf("Error reading file: %w", err)
325+
return []byte{}, filePath, 0, fmt.Errorf("Error reading file: %w", err)
326+
}
327+
offset, err := file.Seek(0, io.SeekCurrent)
328+
if err != nil {
329+
return []byte{}, filePath, 0, fmt.Errorf("Error reading file offset: %w", err)
317330
}
318331

319332
// Join the lines slice into a single string resembling a JavaScript array
320333
jsArray := fmt.Sprintf("[%s]", strings.Join(lines, ", "))
321-
fmt.Println(jsArray)
322334

323335
// Print the JavaScript array string
324-
return fmt.Appendf(nil, `{"type":"system","history":true,"log":%s}`, jsArray), nil
336+
return fmt.Appendf(nil, `{"type":"system","history":true,"log":%s}`, jsArray), filePath, offset, nil
325337
}

goseg/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"groundseg/config"
2222
"groundseg/docker"
2323
"groundseg/exporter"
24+
"groundseg/handler"
2425
"groundseg/importer"
2526
"groundseg/leak"
2627
"groundseg/rectify"
@@ -157,6 +158,12 @@ func startServer() { // *http.Server {
157158
w.HandleFunc("/shell", ws.ShellHandler)
158159
w.HandleFunc("/export/{container}", exporter.ExportHandler)
159160
w.HandleFunc("/import/{uploadSession}/{patp}", importer.HTTPUploadHandler)
161+
w.HandleFunc("/keys/point", handler.KeysPointHandler)
162+
w.HandleFunc("/keys/keyfile", handler.KeysKeyfileHandler)
163+
w.HandleFunc("/keys/code", handler.KeysCodeHandler)
164+
w.HandleFunc("/keys/operation", handler.KeysOperationHandler)
165+
w.HandleFunc("/keys/wallet/prepare", handler.KeysPrepareWalletHandler)
166+
w.HandleFunc("/keys/wallet/submit", handler.KeysSubmitWalletHandler)
160167
wsServer := &http.Server{
161168
Addr: ":3000",
162169
Handler: w,

goseg/ws/logs.go

Lines changed: 99 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@ import (
99
"groundseg/dockerclient"
1010
"groundseg/logger"
1111
"groundseg/structs"
12+
"io"
1213
"net/http"
14+
"os"
1315
"strings"
16+
"time"
1417

1518
"github.com/docker/docker/api/types/container"
1619
"github.com/gorilla/websocket"
@@ -136,7 +139,7 @@ func LogsStreamHandler(w http.ResponseWriter, r *http.Request) {
136139
flusher.Flush()
137140

138141
if payload.Type == "system" {
139-
logHistory, err := logger.RetrieveSysLogHistory()
142+
logHistory, logPath, logOffset, err := logger.RetrieveSysLogHistoryWithOffset()
140143
if err != nil {
141144
zap.L().Error(fmt.Sprintf("failed to retrieve log history: %v", err))
142145
return
@@ -145,30 +148,110 @@ func LogsStreamHandler(w http.ResponseWriter, r *http.Request) {
145148
return
146149
}
147150
flusher.Flush()
151+
streamSystemLogs(w, flusher, r, logPath, logOffset)
152+
return
153+
}
148154

149-
logs, cancel := logger.SubscribeSysLogStream()
150-
defer cancel()
151-
for {
152-
select {
153-
case <-r.Context().Done():
154-
return
155-
case log, ok := <-logs:
156-
if !ok {
157-
return
155+
if _, err := docker.FindContainer(payload.Type); err != nil {
156+
zap.L().Error(fmt.Sprintf("log stream retrieval failed: %v", err))
157+
return
158+
}
159+
streamDockerLogs(w, flusher, r, payload.Type)
160+
}
161+
162+
func streamSystemLogs(w http.ResponseWriter, flusher http.Flusher, r *http.Request, filePath string, offset int64) {
163+
if filePath == "" {
164+
filePath = logger.SysLogfile()
165+
}
166+
file, err := os.Open(filePath)
167+
if err != nil {
168+
if os.IsNotExist(err) {
169+
waitForSystemLogFile(w, flusher, r, filePath, offset)
170+
return
171+
}
172+
zap.L().Error(fmt.Sprintf("failed to open system log file %s: %v", filePath, err))
173+
return
174+
}
175+
defer func() {
176+
if file != nil {
177+
file.Close()
178+
}
179+
}()
180+
181+
if info, err := file.Stat(); err == nil && offset > info.Size() {
182+
offset = 0
183+
}
184+
if _, err := file.Seek(offset, io.SeekStart); err != nil {
185+
zap.L().Error(fmt.Sprintf("failed to seek system log file %s: %v", filePath, err))
186+
return
187+
}
188+
189+
reader := bufio.NewReader(file)
190+
ticker := time.NewTicker(500 * time.Millisecond)
191+
defer ticker.Stop()
192+
193+
for {
194+
select {
195+
case <-r.Context().Done():
196+
return
197+
case <-ticker.C:
198+
for {
199+
line, err := reader.ReadString('\n')
200+
if line != "" {
201+
if err := writeSystemLogLine(w, flusher, line); err != nil {
202+
return
203+
}
158204
}
159-
if err := writeLogStreamEvent(w, log); err != nil {
205+
if err == nil {
206+
continue
207+
}
208+
if err != io.EOF {
209+
zap.L().Error(fmt.Sprintf("error reading system log file %s: %v", filePath, err))
160210
return
161211
}
162-
flusher.Flush()
212+
nextPath := logger.SysLogfile()
213+
if nextPath != filePath {
214+
if nextFile, openErr := os.Open(nextPath); openErr == nil {
215+
file.Close()
216+
file = nextFile
217+
filePath = nextPath
218+
reader = bufio.NewReader(file)
219+
}
220+
}
221+
break
163222
}
164223
}
165224
}
225+
}
166226

167-
if _, err := docker.FindContainer(payload.Type); err != nil {
168-
zap.L().Error(fmt.Sprintf("log stream retrieval failed: %v", err))
169-
return
227+
func waitForSystemLogFile(w http.ResponseWriter, flusher http.Flusher, r *http.Request, filePath string, offset int64) {
228+
ticker := time.NewTicker(500 * time.Millisecond)
229+
defer ticker.Stop()
230+
for {
231+
select {
232+
case <-r.Context().Done():
233+
return
234+
case <-ticker.C:
235+
if _, err := os.Stat(filePath); err == nil {
236+
streamSystemLogs(w, flusher, r, filePath, offset)
237+
return
238+
}
239+
filePath = logger.SysLogfile()
240+
}
170241
}
171-
streamDockerLogs(w, flusher, r, payload.Type)
242+
}
243+
244+
func writeSystemLogLine(w http.ResponseWriter, flusher http.Flusher, line string) error {
245+
line = strings.TrimRight(line, "\r\n")
246+
if strings.TrimSpace(line) == "" {
247+
return nil
248+
}
249+
logJSON := fmt.Appendf(nil, `{"type":"system","history":false,"log":%s}`, line)
250+
if err := writeLogStreamEvent(w, logJSON); err != nil {
251+
return err
252+
}
253+
flusher.Flush()
254+
return nil
172255
}
173256

174257
func streamDockerLogs(w http.ResponseWriter, flusher http.Flusher, r *http.Request, containerName string) {

ui/src/lib/Nav.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
class:highlight={$page.route.id != (pfx+"/apps")}
5656
on:click={()=>goto(pfx+"/apps")}
5757
class="btn option"
58-
>APPS
58+
>KEYS
5959
</div>
6060
<div
6161
class:highlight={$page.route.id != (pfx+"/profile")}

ui/src/lib/stores/keys.js

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { get, writable } from 'svelte/store'
2+
import { loadSession } from './gs-crypto'
3+
import { wsPort } from './websocket'
4+
5+
const PENDING_KEY = 'groundseg:keys:pending'
6+
const MIN_POLL_INTERVAL = 60
7+
const MAX_POLL_INTERVAL = 300
8+
const TERMINAL_STATUSES = new Set(['complete', 'confirmed', 'failed'])
9+
10+
export const keyPending = writable([])
11+
12+
const apiBase = () => {
13+
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:'
14+
return `${protocol}//${window.location.hostname}:${get(wsPort)}`
15+
}
16+
17+
const withToken = async payload => {
18+
const token = await loadSession()
19+
if (!token?.id || !token?.token) {
20+
throw new Error('GroundSeg login required')
21+
}
22+
return { ...payload, token }
23+
}
24+
25+
export const keysRequest = async (path, payload = {}) => {
26+
const response = await fetch(`${apiBase()}${path}`, {
27+
method: 'POST',
28+
headers: { 'Content-Type': 'application/json' },
29+
body: JSON.stringify(await withToken(payload))
30+
})
31+
let body = {}
32+
try {
33+
body = await response.json()
34+
} catch (error) {
35+
throw new Error(`Keys request failed: ${response.status}`)
36+
}
37+
if (!response.ok || body.ok === false) {
38+
throw new Error(body.error || `Keys request failed: ${response.status}`)
39+
}
40+
return body
41+
}
42+
43+
const readPending = () => {
44+
if (typeof localStorage === 'undefined') return []
45+
try {
46+
const parsed = JSON.parse(localStorage.getItem(PENDING_KEY) || '[]')
47+
return Array.isArray(parsed) ? parsed : []
48+
} catch (error) {
49+
return []
50+
}
51+
}
52+
53+
const writePending = items => {
54+
keyPending.set(items)
55+
if (typeof localStorage !== 'undefined') {
56+
localStorage.setItem(PENDING_KEY, JSON.stringify(items))
57+
}
58+
}
59+
60+
export const loadKeyPending = () => {
61+
writePending(readPending())
62+
}
63+
64+
export const addKeyPending = tx => {
65+
if (!tx) return
66+
const id = tx.hash || tx.signature || `${tx.ship}-${tx.operation}-${tx.submittedAt}`
67+
const next = [
68+
tx,
69+
...get(keyPending).filter(item => (item.hash || item.signature || `${item.ship}-${item.operation}-${item.submittedAt}`) !== id)
70+
]
71+
writePending(next.slice(0, 12))
72+
}
73+
74+
export const removeKeyPending = tx => {
75+
const id = tx.hash || tx.signature || `${tx.ship}-${tx.operation}-${tx.submittedAt}`
76+
writePending(get(keyPending).filter(item => (item.hash || item.signature || `${item.ship}-${item.operation}-${item.submittedAt}`) !== id))
77+
}
78+
79+
export const updateKeyPending = updated => {
80+
const id = updated.hash || updated.signature || `${updated.ship}-${updated.operation}-${updated.submittedAt}`
81+
writePending(get(keyPending).map(item => {
82+
const itemId = item.hash || item.signature || `${item.ship}-${item.operation}-${item.submittedAt}`
83+
return itemId === id ? updated : item
84+
}))
85+
}
86+
87+
export const getPoint = (ship, roller = '') => keysRequest('/keys/point', { ship, roller })
88+
89+
export const checkPending = tx => keysRequest('/keys/point', {
90+
ship: tx.ship,
91+
hash: tx.hash,
92+
roller: tx.roller
93+
})
94+
95+
export const generateKeyfile = payload => keysRequest('/keys/keyfile', payload)
96+
97+
export const generateCode = payload => keysRequest('/keys/code', payload)
98+
99+
export const submitKeyOperation = payload => keysRequest('/keys/operation', payload)
100+
101+
export const prepareWalletOperation = payload => keysRequest('/keys/wallet/prepare', payload)
102+
103+
export const submitWalletOperation = payload => keysRequest('/keys/wallet/submit', payload)
104+
105+
const containsPendingTx = (pending, tx) => {
106+
if (!Array.isArray(pending)) return false
107+
return pending.some(item => {
108+
const sig = item?.rawTx?.sig || item?.rawTx?.Sig
109+
const hash = item?.hash || item?.Hash
110+
return (tx.signature && sig === tx.signature) || (tx.hash && hash === tx.hash)
111+
})
112+
}
113+
114+
const nextPollFromBatch = (batch, currentInterval = MIN_POLL_INTERVAL) => {
115+
const batchWait = Number(batch?.timeUntilNext)
116+
if (Number.isFinite(batchWait) && batchWait > 0) {
117+
return Math.max(MIN_POLL_INTERVAL, Math.min(MAX_POLL_INTERVAL, batchWait + 15))
118+
}
119+
return Math.max(MIN_POLL_INTERVAL, Math.min(MAX_POLL_INTERVAL, Math.ceil(currentInterval * 1.5)))
120+
}
121+
122+
export const pollDueKeyPending = async (force = false) => {
123+
const now = Date.now()
124+
const items = get(keyPending)
125+
for (const tx of items) {
126+
if (!force && TERMINAL_STATUSES.has(tx.status)) continue
127+
if (!force && tx.nextPollAt && tx.nextPollAt > now) continue
128+
try {
129+
const result = await checkPending(tx)
130+
const status = result.status || ''
131+
const stillPending = containsPendingTx(result.pending, tx) || Boolean(result.pendingTx)
132+
if (status === 'confirmed' || status === 'failed' || (!stillPending && status !== 'pending' && status !== 'sending')) {
133+
const terminalStatus = status && status !== 'unknown' ? status : 'complete'
134+
updateKeyPending({ ...tx, status: terminalStatus, nextPollAt: 0 })
135+
continue
136+
}
137+
const pollInterval = nextPollFromBatch(result.batch, tx.pollInterval || MIN_POLL_INTERVAL)
138+
updateKeyPending({
139+
...tx,
140+
status: status || 'pending',
141+
pollInterval,
142+
nextPollAt: Date.now() + pollInterval * 1000
143+
})
144+
} catch (error) {
145+
const pollInterval = Math.max(MIN_POLL_INTERVAL, Math.min(MAX_POLL_INTERVAL, (tx.pollInterval || MIN_POLL_INTERVAL) * 2))
146+
updateKeyPending({
147+
...tx,
148+
status: 'checking',
149+
pollInterval,
150+
nextPollAt: Date.now() + pollInterval * 1000,
151+
lastError: error.message
152+
})
153+
}
154+
}
155+
}
156+
157+
export const downloadText = (filename, text) => {
158+
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
159+
const url = window.URL.createObjectURL(blob)
160+
const a = document.createElement('a')
161+
a.style.display = 'none'
162+
a.href = url
163+
a.download = filename
164+
document.body.appendChild(a)
165+
a.click()
166+
a.remove()
167+
window.URL.revokeObjectURL(url)
168+
}

0 commit comments

Comments
 (0)