Load server identity from install dir, not shell CWD.
Release rolling / release (push) Successful in 9m49s

Resolve PEARDOCK_HOME / binary path /opt/peardock for .env so manual
sudo -u peardock runs no longer hit EACCES on a relative home-dir .env.
This commit is contained in:
Raven Scott
2026-07-16 06:01:20 -04:00
parent fda8695fbe
commit 6d6c30f204
7 changed files with 252 additions and 9 deletions
+1
View File
@@ -30,6 +30,7 @@ Group=docker
SupplementaryGroups=docker
Environment=NODE_ENV=production
Environment=PEARDOCK_HOME=/opt/peardock
EnvironmentFile=-/opt/peardock/.env
# Installer also writes PEARDOCK_ENGINE / DOCKER_HOST into .env and may set:
# Environment=PEARDOCK_ENGINE=podman
+30
View File
@@ -36,6 +36,22 @@ sudo systemctl status peardock
sudo journalctl -u peardock -f
```
The server always loads identity from **`$PEARDOCK_HOME/.env`** (default install: `/opt/peardock/.env`), not from your shells current directory. Manual runs:
```bash
# Preferred: match systemd WorkingDirectory
cd /opt/peardock && sudo -u peardock ./peardock-server
# or:
sudo -u peardock env PEARDOCK_HOME=/opt/peardock /opt/peardock/peardock-server
```
If you see `EACCES … open ".env"`, either CWD was wrong (fixed in current builds via exec-path / `/opt/peardock` detection) or ownership is wrong:
```bash
sudo chown peardock:peardock /opt/peardock /opt/peardock/.env
sudo chmod 600 /opt/peardock/.env
```
### Debian / Ubuntu notes
| Engine | Packages / enable | Installer behavior |
@@ -52,6 +68,20 @@ sudo journalctl -u peardock -f
sudo grep -E '^(PEARDOCK_ENGINE|DOCKER_HOST)=' /opt/peardock/.env
```
Identity is always read/written as **`/opt/peardock/.env`** (or `$PEARDOCK_HOME/.env`), not relative to your shell CWD. Manual run:
```bash
cd /opt/peardock && sudo -u peardock ./peardock-server
# or: sudo -u peardock env PEARDOCK_HOME=/opt/peardock /opt/peardock/peardock-server
```
`EACCES … open ".env"` usually means wrong ownership:
```bash
sudo chown -R peardock:peardock /opt/peardock
sudo chmod 600 /opt/peardock/.env
```
### Keys: check `/opt/peardock/.env`
On first successful start the server writes identity into the env file (mode 600):
+1
View File
@@ -918,6 +918,7 @@ SupplementaryGroups=${eng_grp}
NoNewPrivileges=true
PrivateTmp=true
Environment=NODE_ENV=production
Environment=PEARDOCK_HOME=${SERVER_DIR}
Environment=PEARDOCK_ENGINE=${eng_kind}
Environment=DOCKER_HOST=unix://${eng_sock}
Environment=CONTAINER_HOST=unix://${eng_sock}
+25 -8
View File
@@ -10,16 +10,22 @@ import b4a from 'b4a'
import crypto from 'hypercore-crypto'
import dotenv from 'dotenv'
import logger from '../utils/logger.js'
import { ensureServerDataDir, resolveEnvFile } from './paths.js'
dotenv.config()
const { dataDir, envFile: defaultEnvFile } = ensureServerDataDir()
// Load .env from install/data dir (not process CWD) so manual runs work.
dotenv.config({ path: defaultEnvFile })
const log = logger.child('keys')
log.debug('Server data directory', { dataDir, envFile: defaultEnvFile })
/**
* @param {string} [envPath]
* @returns {{ seed: Uint8Array, keyPair: { publicKey: Uint8Array, secretKey: Uint8Array }, publicKeyHex: string, seedHex: string }}
*/
export function loadOrCreateKeyPair(envPath = '.env') {
export function loadOrCreateKeyPair(envPath = resolveEnvFile()) {
const resolved = path.resolve(envPath)
let seedHex = process.env.SERVER_SEED || process.env.SERVER_KEY
// SERVER_KEY historically was a 32-byte topic seed; reuse as DHT seed if present
@@ -28,9 +34,18 @@ export function loadOrCreateKeyPair(envPath = '.env') {
seedHex = b4a.toString(seed, 'hex')
const publicKeyHex = b4a.toString(DHT.keyPair(seed).publicKey, 'hex')
const line = `\nSERVER_SEED=${seedHex}\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
fs.appendFileSync(envPath, line, { flag: 'a' })
try {
fs.mkdirSync(path.dirname(resolved), { recursive: true })
fs.appendFileSync(resolved, line, { flag: 'a', mode: 0o600 })
} catch (err) {
const hint =
err?.code === 'EACCES'
? ` Permission denied writing ${resolved}. Run with WorkingDirectory set to the install dir (systemd) or: sudo chown peardock:peardock ${path.dirname(resolved)} ${resolved}`
: ''
throw new Error(`Cannot write identity file ${resolved}: ${err.message}.${hint}`)
}
log.info('Generated new SERVER_SEED and SERVER_PUBLIC_KEY', {
path: path.resolve(envPath),
path: resolved,
})
}
@@ -45,21 +60,23 @@ export function loadOrCreateKeyPair(envPath = '.env') {
// Keep PUBLIC_KEY in env for operator convenience
if (process.env.SERVER_PUBLIC_KEY !== publicKeyHex) {
try {
let env = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
let env = fs.existsSync(resolved) ? fs.readFileSync(resolved, 'utf8') : ''
if (env.includes('SERVER_PUBLIC_KEY=')) {
env = env.replace(/SERVER_PUBLIC_KEY=.*/g, `SERVER_PUBLIC_KEY=${publicKeyHex}`)
} else {
env += `\nSERVER_PUBLIC_KEY=${publicKeyHex}\n`
}
if (!env.includes('SERVER_SEED=') && !process.env.SERVER_SEED) {
// migrate old SERVER_KEY-only files
if (!env.includes('SERVER_SEED=')) {
env += `SERVER_SEED=${seedHex}\n`
}
}
fs.writeFileSync(envPath, env)
fs.writeFileSync(resolved, env, { mode: 0o600 })
} catch (err) {
log.warn('Could not update .env with SERVER_PUBLIC_KEY', { error: err.message })
log.warn('Could not update .env with SERVER_PUBLIC_KEY', {
error: err.message,
path: resolved,
})
}
}
+122
View File
@@ -0,0 +1,122 @@
/**
* Resolve peardock server data directory and .env path.
*
* Manual runs often start from a home directory (`sudo -u peardock /opt/peardock/peardock-server`)
* while systemd sets WorkingDirectory=/opt/peardock. Relative `.env` must not depend on CWD.
*
* Priority:
* 1. PEARDOCK_HOME / PEARDOCK_SERVER_DIR
* 2. Directory of PEARDOCK_ENV_PATH
* 3. Directory of the peardock-server binary (process.execPath)
* 4. /opt/peardock when it looks like an install tree
* 5. Package root (dev checkout)
* 6. process.cwd()
*/
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
/**
* @param {string} dir
* @returns {boolean}
*/
function looksLikeInstallDir(dir) {
if (!dir) return false
try {
if (fs.existsSync(path.join(dir, '.env'))) return true
if (fs.existsSync(path.join(dir, 'peardock-server'))) return true
if (fs.existsSync(path.join(dir, 'package.json'))) {
// Prefer package root only when it is clearly peardock
try {
const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'))
if (pkg?.name === 'peardock') return true
} catch {
// ignore
}
}
} catch {
// ignore
}
return false
}
/**
* @returns {string} absolute data directory
*/
export function resolveServerDataDir() {
const fromEnv =
process.env.PEARDOCK_HOME ||
process.env.PEARDOCK_SERVER_DIR ||
process.env.PEARDOCK_DATA_DIR
if (fromEnv && String(fromEnv).trim()) {
return path.resolve(String(fromEnv).trim())
}
if (process.env.PEARDOCK_ENV_PATH) {
return path.dirname(path.resolve(process.env.PEARDOCK_ENV_PATH))
}
// Standalone binary: /opt/peardock/peardock-server
try {
const execPath = process.execPath || ''
const base = path.basename(execPath).toLowerCase()
if (base.includes('peardock-server') || base === 'peardock') {
const dir = path.dirname(path.resolve(execPath))
if (looksLikeInstallDir(dir) || dir === '/opt/peardock') return dir
// Bare often embeds; still prefer exec dir when binary name matches
if (dir && dir !== '/' && !dir.includes('node_modules')) return dir
}
} catch {
// ignore
}
if (looksLikeInstallDir('/opt/peardock')) {
return '/opt/peardock'
}
// Dev: server/core/paths.js → repo root
try {
const here = path.dirname(fileURLToPath(import.meta.url))
const root = path.resolve(here, '../..')
if (looksLikeInstallDir(root)) return root
} catch {
// Bare may not support import.meta.url the same way
}
return path.resolve(process.cwd())
}
/**
* Absolute path to the identity / env file.
* @returns {string}
*/
export function resolveEnvFile() {
if (process.env.PEARDOCK_ENV_PATH && String(process.env.PEARDOCK_ENV_PATH).trim()) {
return path.resolve(String(process.env.PEARDOCK_ENV_PATH).trim())
}
return path.join(resolveServerDataDir(), '.env')
}
/**
* Pin PEARDOCK_HOME and prefer chdir into the data dir so relative state files land correctly.
* Safe to call multiple times.
* @returns {{ dataDir: string, envFile: string }}
*/
export function ensureServerDataDir() {
const dataDir = resolveServerDataDir()
const envFile = resolveEnvFile()
if (!process.env.PEARDOCK_HOME) {
process.env.PEARDOCK_HOME = dataDir
}
try {
if (path.resolve(process.cwd()) !== dataDir) {
process.chdir(dataDir)
}
} catch {
// Non-fatal: still use absolute env path for keys
}
return { dataDir, envFile }
}
+10 -1
View File
@@ -7,8 +7,12 @@
import DHT from 'hyperdht'
import b4a from 'b4a'
import gracefulGoodbye from 'graceful-goodbye'
import { ensureServerDataDir } from './core/paths.js'
import { loadOrCreateKeyPair } from './core/keys.js'
import { initAuthKeys } from './core/auth-keys.js'
// Pin CWD / PEARDOCK_HOME before other side-effectful services use relative paths
const { dataDir: serverDataDir, envFile: serverEnvFile } = ensureServerDataDir()
import { peers } from './core/peer-registry.js'
import { PeerSession } from './rpc/session.js'
import { registerAllHandlers, cleanupSession } from './rpc/register.js'
@@ -45,8 +49,13 @@ let seedHex
try {
;({ keyPair, publicKeyHex, seedHex } = loadOrCreateKeyPair())
initAuthKeys({ seedHex, publicKeyHex })
log.info('Server identity ready', { dataDir: serverDataDir, envFile: serverEnvFile })
} catch (err) {
log.error('Failed to load server keypair', { error: err.message })
log.error('Failed to load server keypair', {
error: err.message,
dataDir: serverDataDir,
envFile: serverEnvFile,
})
process.exit(1)
}
+63
View File
@@ -0,0 +1,63 @@
import test from 'brittle'
import path from 'path'
import fs from 'fs'
import os from 'os'
import {
resolveServerDataDir,
resolveEnvFile,
ensureServerDataDir,
} from '../server/core/paths.js'
test('resolveServerDataDir respects PEARDOCK_HOME', (t) => {
const prev = process.env.PEARDOCK_HOME
process.env.PEARDOCK_HOME = '/opt/peardock'
t.is(resolveServerDataDir(), path.resolve('/opt/peardock'))
if (prev === undefined) delete process.env.PEARDOCK_HOME
else process.env.PEARDOCK_HOME = prev
})
test('resolveEnvFile joins data dir', (t) => {
const prevH = process.env.PEARDOCK_HOME
const prevE = process.env.PEARDOCK_ENV_PATH
delete process.env.PEARDOCK_ENV_PATH
process.env.PEARDOCK_HOME = '/opt/peardock'
t.is(resolveEnvFile(), path.join(path.resolve('/opt/peardock'), '.env'))
if (prevH === undefined) delete process.env.PEARDOCK_HOME
else process.env.PEARDOCK_HOME = prevH
if (prevE === undefined) delete process.env.PEARDOCK_ENV_PATH
else process.env.PEARDOCK_ENV_PATH = prevE
})
test('resolveEnvFile respects PEARDOCK_ENV_PATH', (t) => {
const prev = process.env.PEARDOCK_ENV_PATH
process.env.PEARDOCK_ENV_PATH = '/custom/identity.env'
t.is(resolveEnvFile(), path.resolve('/custom/identity.env'))
if (prev === undefined) delete process.env.PEARDOCK_ENV_PATH
else process.env.PEARDOCK_ENV_PATH = prev
})
test('ensureServerDataDir sets PEARDOCK_HOME', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-paths-'))
const prevH = process.env.PEARDOCK_HOME
const prevCwd = process.cwd()
try {
process.env.PEARDOCK_HOME = dir
const r = ensureServerDataDir()
t.is(r.dataDir, path.resolve(dir))
t.is(r.envFile, path.join(path.resolve(dir), '.env'))
t.is(process.env.PEARDOCK_HOME, path.resolve(dir))
} finally {
try {
process.chdir(prevCwd)
} catch {
// ignore
}
if (prevH === undefined) delete process.env.PEARDOCK_HOME
else process.env.PEARDOCK_HOME = prevH
try {
fs.rmSync(dir, { recursive: true, force: true })
} catch {
// ignore
}
}
})