const fs = require("fs");
const http = require("http");
const https = require("https");
const path = require("path");
const {execSync} = require('child_process');
const express = require("express"); // could be good to replace with code, no dep
// express does not seem architecture dependant thus copying node_modules sufficed for now
// (worked at least on RPi, Deck, Quest and x64 machines)
// Get port or default to 8082
const port = process.env.PORT || 8082; // assumes homogenity across peers
const protocol = process.env.PROTOCOL || 'https'
const subclass = process.env.SUBCLASS || '192.168.0.' // defaulting to IP used at home rather than RPi0. Could also change it there as it's not justified beside helping distinction.
// Object.values(require("os").networkInterfaces()).flat().filter(({ family, internal }) => family === "IPv4" && !internal).map(({ address }) => address)[0].split('.').slice(0,3).join('.')+'.'
const publicKeyPath = path.resolve(process.env.HOME,'.ssh','id_rsa_offlineoctopus.pub')
const publicKey = fs.readFileSync(publicKeyPath).toString().split(' ')[1]
const md5fromPub = fs.readFileSync( path.resolve(__dirname, ".keyfrommd5")).toString().replace('\n','')
process.title = 'offtopus' // offline-octopus seems too long
const filename = 'editor.html'
const fileSaveFullPath = path.join(__dirname,'examples', filename)
const minfilename = 'editor.html.minimal'
const minfileSaveFullPath = path.join(__dirname,'examples', minfilename)
const sshconfigpath = path.resolve(process.env.HOME,'.ssh','config')
const propath = path.resolve(process.env.HOME,'Prototypes')
const sshfsmounts = "sshfsmounts"
let workspaces = {}
let mountPoints = []
// note that stopping this process removes the mounts
// does not apply in a P2P fashion, must rely on local configuration here config.json
let localServices = [ ]
const configFilePath = path.resolve(__dirname, "config.json")
if (fs.existsSync( configFilePath ) ){
const configurationFromFile = JSON.parse( fs.readFileSync( configFilePath ).toString() )
localServices = configurationFromFile
}
// note that this is crucial to populate properly as this is what allow recombination
/* e.g on quest
spascanodesecure https localhost 7778 /engine/
requires CORS to fetch here
fetch('https://192.168.4.3:8082/available')
.then( r => r.json() )
.then( r => { if (r) addNewNote( 'available' ) } )
gitea http localhost 3000 /
pmwiki http localhost 4000 /pmwiki.php
sshd see quest in ssh config, specific user and port
*/
const kwinmin = `
echo "const clients = workspace.clientList();
for (var i = 0; i < clients.length; i++) {
print(clients[i].caption);
clients[i].minimized = true;
}" > /tmp/kwinscriptdemo
num=$(dbus-send --print-reply --dest=org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript string:"/tmp/kwinscriptdemo" | awk 'END {print $2}' )
dbus-send --print-reply --dest=org.kde.KWin /$num org.kde.kwin.Script.run`
const utilsCmd = { // security risk but for now not accepting user input so safer
//'update' : { desc: 'note that will lose the state, e.g foundpeers', cmd: 'killall '+process.title+' && ' },
// should first download the new version and proceed only if new
// e.g git clone deck@localhost:~/Prototypes/offline-octopus/
// should see /sshconfig
// tried git instaweb but unable without lighttpd/apache
// would probably be problematic with https anyway
// ideally all handles within node
'shutdown' : { cmd: 'shutdown -h now' }, // not available everywhere, e.g unrooted Quest
'listprototypes': { cmd: 'ls', context: {cwd: propath},
format: res => res.toString().split('\n')
},
'mousemove' : { cmd: 'xdotool mousemove ', optionsPattern: /\d+ \d+/},
'mouseup' : { cmd: 'xdotool mouseup 1'},
'mousedown' : { cmd: 'xdotool mousedown 1'},
// per device specific (until adjustable per user)
'minimizeall' : { cmd: kwinmin}, //KWin script
//'minimizeall' : { cmd: '/home/fabien/Prototypes/kwin-scripting/launch'}, //KWin script
'highresscreen' : { cmd: 'xrandr --output DP-4 --mode 3840x2160'},
'lowresscreen' : { cmd: 'xrandr --output DP-4 --mode 1920x1080'},
// 'anyresscreen' : { cmd: 'xrandr --output DP-4 --mode '}, // would require user input which is risky e.g here 'OxO; wget rootkit; bash rootkit;'
'availableRes' : { cmd: 'xrandr -q | grep 0x | grep -v primary | cut -f 4 -d " "',
format: res => res.toString().split('\n').filter( l => l!='' )
},
//'npmfind' : { desc: 'package manager finder', cmd: 'find . -wholename "*node_modules/acorn"' },
// security risk if relying on user provided name, e.g replacing acorn by user input
// example that could be generalized to other package managers e.g .deb or opkg
}
// could be interesting to consider also recent containers and ~/.bashrc for services
const instructions = `
/home/deck/.ssh/
trusted context, i.e on closed WiFi and over https with bearer authorization
/home/deck/.ssh/config
limit to known IP subclass e.g cat .ssh/config | grep 192.168.4. -C 3
see /sshconfig
could also re-add new entries rather than extend the format
/home/deck/server.locatedb
seems to be plain text with metadata
/home/deck/desktop.plocate.db
seems to be a specific format, binary or maybe compressed
both should be queriable via http with json output
ssh remarkable2 to get drawing preview
conversion should be done, if necessary, on device
not feasible right now without toltec to get opkg to get node
for typed text
cat 43*.rm | sed "s/[^a-zA-z0-9 ]//g" | sed "s/[OT,EC]//g"
util functions
modify WiFi parameters, including AP if available
shutdown/reboot
`
const auth_instructions = `generate md5 from pub offline-octopus then provide as bearer query param`
// Setup and configure Express http server.
const app = express();
app.use(express.static(path.resolve(__dirname, ".", "examples")));
// CORS
app.use(function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
next();
});
app.get('/', (req, res) => {
res.send( instructions )
// see issue 20
})
app.get('/routes', (req, res) => {
let formattedRoutes = routes.map( r => `${r}` ).join('\n')
res.send( formattedRoutes )
})
app.get('/routes/json', (req, res) => {
// minimalist for JXR but could also be OpenAPI, cf https://git.benetou.fr/utopiah/offline-octopus/issues/16
// used in /webxr as
// fetch('/routes/json').then(r=>r.json()).then(r=>r.filter(p=>p.match(/resolution/)).map(p=>addNewNote('jxr fetch("https://192.168.0.129:8082'+p+'")')))
res.send( routes )
})
app.get('/authtestviaheader', (req, res) => {
if (req.header('authorization') != 'Bearer '+md5fromPub){ res.sendStatus(401); return; }
res.sendStatus(200)
// fetch('/authtestviaheader', {headers: {'authorization': 'Bearer '+ bearer}}).then(r=>r.text()).then(t=>console.log(t))
// prevents from showing in browser history but also makes testing slightly harder
// consider next() for middleware instead of copy/pasting this line
})
app.get('/authtest', (req, res) => {
if (req.query?.bearer != md5fromPub){ res.send( auth_instructions); return; }
// relying on this line for each specific route
// this is NOT authentification proper, even less secure
// this is done ONLY to avoid mistakes on a secure LAN
res.json( {msg: "success"} )
})
app.get('/pwa', (req, res) => {
// for offline use on mobile or VR headset
// should try to sync back when devices connect back
// same when itself connects back to (Internet) own server e.g benetou.fr
// can be cascading or federatede or properly P2P
// responsive programming also, not "just" design
res.redirect('pwa/index.html')
// see also /editor
})
let resultFromLastGlobalCmd = {}
app.get('/allpeers/exec', (req, res) => {
if (req.query?.bearer != md5fromPub){ res.send( auth_instructions); return; }
if (!req.query.cmdName){
res.json(utilsCmd)
} else {
foundPeers.map( i => {
let url=protocol+'://'+subclass+i+':'+port
+'/exec?cmdName='+req.query.cmdName
+'&bearer='+req.query.bearer
let opt={rejectUnauthorized: false}
https.get(url, opt, res => {
// to simplify with fetch and promises
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
console.log('data', url, data)
data = JSON.parse(data);
resultFromLastGlobalCmd[url] = data
})
}).on('error', err => {
console.log(err.message);
}).end()
})
res.json( {msg: 'started'} ) // could redirect('/all/result') instead after a timeout
}
})
app.get('/allpeers/result', (req, res) => {
res.json( resultFromLastGlobalCmd )
})
app.get('/exec', (req, res) => {
if (req.query?.bearer != md5fromPub){ res.send( auth_instructions); return; }
if (!req.query.cmdName){
res.json(utilsCmd)
} else {
res.json( execConfiguredCommand(req.query.cmdName) )
}
})
function execConfiguredCommand(cmdName, options=''){
let resultFromExecution = execSync(utilsCmd[cmdName].cmd+options, utilsCmd[cmdName].context)
let formatter = utilsCmd[cmdName].format
if (formatter) resultFromExecution = formatter(resultFromExecution)
return resultFromExecution
}
// app.get('/interface/register', (req, res) => {
// consider uinput to access devices proper, e.g physical keyboard
// could be use for e.g reMarkable2, Quest2, etc with specifically accepted or prefered formats
// app.get('/interface/unregister', (req, res) => {
app.get('/services', (req, res) => {
// should be updated via register/unregister
res.json( localServices )
})
app.get('/services/register', (req, res) => {
// see localServices to load and save in configFilePath
res.json( {msg: 'not yet implemented'})
// example {name:'hmdlink', desc:'share URL between local devices', protocol:'http', port:8082, path: '/hmdlink', url:'http://192.168.4.3:8082/hmdlink'},
})
app.get('/services/unregister', (req, res) => {
res.json( {msg: 'not yet implemented'})
})
app.get('/updates', (req, res) => {
// see utilsCmd['update']
// could rely on a git reachable by all peers
// this might be feasible from this very https server as read-only
// surely feasible via ssh
// could killall offtopus first then pull then restart detached
res.json( {msg: 'not yet implemented'})
})
app.get('/recentfiles', (req, res) => {
// e.g lsof | grep home | grep vim | grep -v firefox
// or history | grep vi
// should be available after (ideally local) conversion if needed, e.g rm -> .svg on reMarkable
res.json( {msg: 'not yet implemented'})
})
let dynURL = '/'
app.get('/hmdlink/set', (req, res) => { // could be a PUT instead
// e.g http://192.168.4.3:8082/hmdlink/set?url=http://192.168.4.3:8082/114df5f8-3921-42f0-81e7-48731b563571.thumbnails/f07120ba-0ca1-429d-869f-c704a52b7aa3.png
// should return a warning if req.query.url is undefined
if (!req.query.url) {res.json( {error: 'use /hmdlink/set?url=YOURURL'}); return}
dynURL = req.query.url
res.redirect( dynURL )
})
app.get('/hmdlink', (req, res) => {
res.redirect( dynURL )
})
app.get('/webxr', (req, res) => {
res.redirect( '/spasca-offline/engine/index.html')
//res.redirect( '/local-metaverse-tooling/local-aframe-test.html' )
})
// user for /scan to populate foundPeers
app.get('/available', (req, res) => {
res.json( true )
})
app.get('/foundpeers', (req, res) => {
res.json( foundPeers )
})
let foundPeers = []
app.get('/scan', (req, res) => {
scanpeers()
res.json( {msg: 'started'} ) // could redirect('/foundpeers') too after a timeout
})
function scanpeers(){
foundPeers = []
for (let i=1;i<254;i++){ // async so blasting, gives very quick result for positives
let url=protocol+'://'+subclass+i+':'+port+'/available'
let opt={rejectUnauthorized: false}
https.get(url, opt, res => {
let data = '';
res.on('data', chunk => {
data += chunk;
});
res.on('end', () => {
data = JSON.parse(data);
foundPeers.push(i)
// could also register there and then
})
}).on('error', err => {
//console.log(err.message); usually ECONNREFUSED or EHOSTUNREACH
}).end()
}
}
app.get('/sshconfig', (req, res) => {
res.json( getSshConfig() )
})
app.get('/sshconfig/live', (req, res) => {
res.json( getSshConfig().filter( i => foundPeers.map( e => subclass+e ).filter( x => i.connectionString.match(x) ).length ) )
})
app.get('/unifiedworkspaces', (req, res) => {
res.json( workspaces )
})
function getUnifiedworkspaces(){
mountPoints.map( mp => {
let dir = path.resolve(__dirname, sshfsmounts, mp)
workspaces[mp] = fs.readdirSync( dir )
console.log('reading', mp, workspaces.mp)
})
return workspaces
}
function mountAll(){
// should scanpeers() first
if (foundPeers.length==0) return
getSshConfig().filter( i => foundPeers.map( e => subclass+e ).filter( x => i.connectionString.match(x) ).length )
.map( l => {
let cs = 'sshfs ' + l.name + ':'
if (l.custom)
cs+= l.custom
else
cs+='/home/'+l.user
let targetPath = path.resolve(__dirname, sshfsmounts, l.name)
if (!fs.existsSync(targetPath)){ fs.mkdirSync(targetPath, { recursive: true }); }
mountPoints.push(l.name)
return cs + ' ' + targetPath
} )
//.map( l => console.log(l))
.map( l => { try { execSync(l) } catch (err) { console.log(err) } } )
}
function umountAll(){
mpath = path.resolve(__dirname, sshfsmounts)
fs.readdirSync( mpath )
// could rely on mountPoints instead
.map( f => {
try {
execSync('umount ' + path.resolve(__dirname, sshfsmounts, f) )
// should update mountPoints instead
} catch (err) {
console.log(err)
}
})
mountPoints = []
}
function getSshConfig(){
let txt = fs.readFileSync(sshconfigpath).toString()
return txt.split('Host ')
.filter( m => m.match(subclass) )
.map( c => {
let all = c.replaceAll(' ','')
.split('\n')
let p = all
.filter(i=>i.match(/^[a-zA-Z]/));
let custom = all
.filter(i=>i.match(/^#Dir /))[0]
?.split(' ')[1]
// custom ~/.ssh/config parameter as comment
// user here for home directory in termux
let user = p.filter(pm=>pm.match('User '))[0].replace('User ','')
let hostname = p.filter(pm=>pm.match('HostName'))[0].replace('HostName ','')
let connectionString = user + '@' + hostname
let port = p.filter(pm=>pm.match('Port'))[0]?.replace('Port ','')
if (port) connectionString += ':' + port
return {name: p[0], user, connectionString, custom}
})
}
/*
e.g AFTER mounting
f.readdirSync('./sshfsmounts/').map( d=>f.readdirSync('./sshfsmounts/'+d) )
location : /home/deck/Prototypes/offline-octopus/sshfsmounts
sshfs remarkable2:/home/root/xochitl-data/ remarkable2/
works if available
sshfs fabien@192.168.4.1:/home/fabien/ rpi0/
still prompts for password, need manual login
ls rpi0/
ls remarkable2/
must be done after passwordless login, i.e after ssh-copy-id
made a dedicated key : /home/deck/.ssh/id_rsa_offlineoctopus
easier to revoke if need be
*/
app.get('/mouse/up', (req, res) => { res.json( execConfiguredCommand('mouseup') ) })
app.get('/mouse/down', (req, res) => { res.json( execConfiguredCommand('mousedown') ) })
app.get('/mouse/move', (req, res) => {
if( !req.query.options?.match(utilsCmd['mousemove']) ) { {res.json( {error: 'use /mouse/move?options=100 2000'}); return}}
res.json( execConfiguredCommand('mousemove',req.query.options) ) }
)
app.get('/minimizeall', (req, res) => { res.json( execConfiguredCommand('minimizeall') ) })
app.get('/resolution/high', (req, res) => { res.json( execConfiguredCommand('highresscreen') ) })
app.get('/resolution/low', (req, res) => { res.json( execConfiguredCommand('lowresscreen') ) })
app.get('/resolution/list', (req, res) => { res.json( execConfiguredCommand('availableRes') ) })
app.get('/localprototypes', (req, res) => {
// examples to disentangle own work for cloned existing repositories :
// find Prototypes/ -iwholename */.git/config | xargs grep git.benetou.fr
// find ~/Prototypes/ -depth -maxdepth 4 -wholename "*/.git/config" | xargs grep -l git.benetou.fr | sed "s|.*Prototypes/\(.*\)/.git/config|\1|"
res.json( execConfiguredCommand('listprototypes') )
})
app.get('/editor/recover', (req, res) => {
// could move the previous file with time stamp
fs.copyFileSync(minfileSaveFullPath, fileSaveFullPath )
res.json( {msg: 'copied'} )
})
/*
* example of finding past local solution, here shiki for syntax highlighting
*
(deck@steamdeck serverhome)$ find . -iname shiki
./fabien/web/future_of_text_demo/content/shiki
(deck@steamdeck serverhome)$ ls ./fabien/web/future_of_text_demo/content/shiki/dist/
onig.wasm
see syntax-highlighting branch in SpaSca git repository
in /home/deck/serverhome/fabien/web/future_of_text_demo/engine/
*/
app.get('/editor/read', (req, res) => {
content = fs.readFileSync(fileSaveFullPath ).toString()
res.json( {msg: content} )
})
app.get('/editor/save', (req, res) => {
let content = req.query.content // does not escape, loses newlines
if (!content){
res.json( {msg: 'missing content'} )
} else {
console.log('writting', content)
fs.writeFileSync(fileSaveFullPath, content)
res.json( {msg: 'written to '+fileSaveFullPath} )
}
})
let routes = app._router.stack.map( r => r.route?.path ).filter( r => typeof(r) == 'string' )
// it's a form of caching but so far routes are not dynamically generated so sufficient solution
const privateKey = fs.readFileSync("naf-key.pem", "utf8");
const certificate = fs.readFileSync("naf.pem", "utf8");
const credentials = { key: privateKey, cert: certificate };
const webServer = https.createServer(credentials, app);
// Start Express http server
// const webServer = http.createServer(app);
// Listen on port
webServer.listen(port, () => {
console.log("listening on "+protocol+"://0.0.0.0:" + port)
getCommand()
});
// REPL testing
const readline = require("readline");
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
completer: completer
});
// https://nodejs.org/api/readline.html#use-of-the-completer-function
function completer(line) {
const completions = help().split('\n');
const hits = completions.filter((c) => c.startsWith(line));
// Show all completions if none found
return [hits.length ? hits : completions, line];
}
let command = ''
function getCommand(){
rl.question(process.title+" REPL: ", function(command) {
if (command == "close") {
rl.close();
} else {
try {
console.log(command, eval(command) )
} catch (err) {
console.log(err)
}
getCommand()
// somehow switch to node REPL proper after?!
}
});
}
function help(){
return `
help()
execConfiguredCommand(cmdName)
getSshConfig()
mountAll()
umountAll()
scanpeers()
getUnifiedworkspaces()
foundPeers
port
protocol
subclass
publicKeyPath
publicKey
md5fromPub
process.title
filename
fileSaveFullPath
minfilename
minfileSaveFullPath
sshconfigpath
propath
localServices
configFilePath
utilsCmd
instructions
auth_instructions
process.title
sshfsmounts
mountPoints
workspaces
`
}
rl.on("close", function() {
console.log("\ndone");
umountAll()
process.exit(0);
});
// Demo Day target :
// show files from ~/Prototypes as cubes from ssh mounted on a virtual workspace
// sshfs might not even be needed, see allpeers/exec instead
// wouldn't easily get content back though, just meta data