You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
523 lines
17 KiB
523 lines
17 KiB
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;
|
|
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 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')
|
|
},
|
|
// per device specific (until adjustable per user)
|
|
'highresscreen' : { cmd: 'xrandr --output DP-4 --mode 3840x2160'},
|
|
'lowresscreen' : { cmd: 'xrandr --output DP-4 --mode 1920x1080'},
|
|
|
|
//'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 routes = app._router.stack.map( r => r.route?.path ).filter( r => typeof(r) == 'string' )
|
|
let formattedRoutes = routes.map( r => `<li><a href='${r}'>${r}</a></li>` ).join('\n')
|
|
res.send( formattedRoutes )
|
|
})
|
|
|
|
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){
|
|
let resultFromExecution = execSync(utilsCmd[cmdName].cmd, 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 = 'https://192.168.4.1/offline.html'
|
|
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
|
|
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('/resolution/high', (req, res) => { res.json( execConfiguredCommand('highresscreen') ) })
|
|
app.get('/resolution/low', (req, res) => { res.json( execConfiguredCommand('lowresscreen') ) })
|
|
|
|
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} )
|
|
}
|
|
})
|
|
|
|
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+"://localhost:" + 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
|
|
|
|
|