#!/usr/bin/node // Simple Vitastor etcd / antietcd / TLS configurator // Copyright (c) Vitaliy Filippov, 2019+ // License: MIT const child_process = require('child_process'); const fs = require('fs'); const os = require('os'); const path = require('path'); const readline = require('readline'); run().catch(e => { console.error(e); process.exit(1); }); const help_text = `Initialize a Vitastor cluster (etcd, vitastor.conf and TLS certificates) (c) Vitaliy Filippov, 2026+ (MIT) USAGE: 1) Create a minimal vitastor.conf with etcd_address, osd_network and (optionally) use_perms. Non-encrypted: {"etcd_address":["http://10.0.0.10:2379","http://10.0.0.11:2379","http://10.0.0.12:2379"],"osd_network":"10.0.0.0/24"} Encrypted: {"etcd_address":["https://10.0.0.10:2379","https://10.0.0.11:2379","https://10.0.0.12:2379"],"use_perms":true,"osd_network":"10.0.0.0/24"} (Note https:// etcd URLs!) 2) Run: ${process.argv[1]} [./vitastor.conf] [--antietcd-only] You can run it on etcd/monitor nodes or on an external node. It configures etcd, generates TLS certificates (on the first or external node), copies them to other etcd/monitor nodes, and updates vitastor.conf with TLS options. 3) If you have OSD-only nodes, run: ${process.argv[1]} --copy-to-osd-node NODE_NAME ./vitastor.conf It copies vitastor.conf and required TLS certificates to that node. OPTIONS: --antietcd-only disable etcd (proxy or direct mode), use only antietcd --gen-certs force certificate generation even if it's not the first node --no-certs disable certificate generation --copy yes|no|ask copy vitastor.conf and TLS certificates for monitor&etcd to monitor nodes using scp (default is ask) --copy-to-osd-node NODE[,NODE2,...] copy vitastor.conf and TLS certificates for OSDs to NODES using scp `; async function run() { let config_path = '/etc/vitastor/vitastor.conf'; let config_dir = '/etc/vitastor/'; let gen_certs = 'auto'; let antietcd_only = false; let copy = 'ask'; let copy_to_osd = null; for (let i = 2; i < process.argv.length; i++) { const arg = process.argv[i]; if (arg == '-h' || arg == '--help') { console.log(help_text); process.exit(0); } else if (arg == '--gen-certs') { gen_certs = true; } else if (arg == '--no-certs') { gen_certs = false; } else if (arg == '--antietcd-only') { antietcd_only = true; } else if (arg == '--copy-to-osd-node' && i < process.argv.length-1) { i++; copy_to_osd = process.argv[i].split(/,/); } else if (arg == '--copy' && i < process.argv.length-1) { i++; copy = process.argv[i]; if (copy !== 'ask' && copy !== 'yes' && copy !== 'no') { console.error('--copy should be "ask", "yes" or "no"'); process.exit(1); } } else if (arg[0] == '-') { console.error('Unknown option: '+arg[0]); process.exit(1); } else { config_path = arg; config_dir = path.dirname(arg); } } if (!fs.existsSync(config_path)) { console.log(config_path+' is missing'); process.exit(1); } const config = JSON.parse(fs.readFileSync(config_path, { encoding: 'utf-8' })); if (!config.etcd_address) { console.log("etcd_address is missing in "+config_path); process.exit(1); } const etcds = (config.etcd_address instanceof Array ? config.etcd_address : (''+config.etcd_address).split(/,/)) .map(s => /^(https?):\/\/(\[[^\]]+\]|[^\[\]\:\/]+)(?::(\d+))?/.exec(s.toLowerCase())) .filter(s => s) .map(s => ({ scheme: s[1], addr: s[2].indexOf(':') && s[2][0] != '[' ? '['+s[2]+']' : s[2], ip: s[2][0] == '[' ? s[2].substr(1, s[2].length-2) : s[2], port: s[3], })); const tls = etcds.filter(e => e.scheme === 'https').length > 0; const use_perms = tls && config.use_perms; const num = select_local_etcd(etcds); if (copy_to_osd) { copy_to_osd_nodes(copy_to_osd, config_dir, use_perms, antietcd_only); process.exit(0); } if (tls) { const etcd_ca = config_dir+'/'+path.basename(config.etcd_ca); if (gen_certs === true) { console.log('Certificate generation is requested explicitly, generating'); } else if (gen_certs === false) { console.log('Certificate generation is disabled explicitly, skipping'); } else if (num < 0) { gen_certs = true; console.log('No matching IPs in etcd_address from '+config_path+', only generating certificates'); } else if (config.etcd_ca && fs.existsSync(etcd_ca)) { gen_certs = false; console.log(etcd_ca+' already exists, assuming certificates are already generated'); } else if (num === 0) { gen_certs = true; console.log('This is monitor node 1, generating certificates'); } else { console.log('This is monitor node '+(num+1)+', '+etcd_ca+' does not exist, please copy certificates to this node'); process.exit(1); } await write_auth_config(config, config_path, etcds, use_perms, antietcd_only); if (gen_certs) { if (copy === 'ask') copy = await ask_copy('Copy certificates and vitastor.conf to other nodes after generation?'); copy = (copy === 'y' || copy === 'yes'); await make_certs(config_dir, copy, etcds, use_perms, antietcd_only); } } if (num < 0) { console.log('No matching IPs in etcd_address from '+config_path); process.exit(tls && gen_certs ? 0 : 1); } await configure_etcd(etcds, num, tls, use_perms); await enable_mon(); process.exit(0); } async function ask_copy(question) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: '> ', }); let copy; while (copy != 'y' && copy != 'n' && copy != 'yes' && copy != 'no') { if (copy) console.log('Please type "yes" or "no"'); copy = await new Promise(ok => rl.question(question, ok)); } return copy; } async function copy_to_osd_nodes(to, dir, use_perms, antietcd_only) { const osd_to_copy = [ 'vitastor.conf' ]; if (!antietcd_only && !use_perms) osd_to_copy.push('etcd_ca.crt'); else osd_to_copy.push('antietcd_ca.crt'); if (use_perms) osd_to_copy.push('osd.crt', 'osd.key', 'client_ca.crt'); console.warn('Copying configuration to OSD nodes '+to.join(', ')); for (const node of to) await system("scp "+dir+osd_to_copy.join(" "+dir)+" root@"+node+":/etc/vitastor/"); } async function make_certs(dir, copy, etcds, use_perms, antietcd_only) { console.log(`----- Generating certificates in ${dir} ----- `); const to_copy = [ 'vitastor.conf' ]; const osd_to_copy = [ 'vitastor.conf' ]; if (!antietcd_only) { await make_ca("/O=Vitastor etcd CA", dir+"etcd_ca"); await make_signed("/CN=Vitastor etcd", dir+"etcd", dir+"etcd_ca", etcds.map(e => "IP:"+e.ip).join(',')); to_copy.push('etcd_ca.crt', 'etcd.crt', 'etcd.key'); if (!use_perms) osd_to_copy.push('etcd_ca.crt'); } if (use_perms || antietcd_only) { await make_ca("/O=Vitastor Antietcd CA", dir+"antietcd_ca"); await make_signed("/CN=Vitastor Antietcd", dir+"antietcd", dir+"antietcd_ca", etcds.map(e => "IP:"+e.ip).join(',')); to_copy.push('antietcd_ca.crt', 'antietcd.crt', 'antietcd.key'); osd_to_copy.push('antietcd_ca.crt'); } if (use_perms) { await make_ca("/CN=Vitastor OSD", dir+"osd"); await make_ca("/O=Vitastor Client CA", dir+"client_ca"); await make_signed("/CN=admin", dir+"admin", dir+"client_ca"); to_copy.push('osd.crt', 'osd.key', 'client_ca.crt'); osd_to_copy.push('osd.crt', 'osd.key', 'client_ca.crt'); } console.log(`----- Certificates generated, commands to copy them: - Monitor+OSD node: cd ${dir} && scp ${to_copy.join(' ')} root@NODE:/etc/vitastor/ - Monitor node: cd ${dir} && scp ${to_copy.filter(f => f != 'osd.key').join(' ')} root@NODE:/etc/vitastor/ - OSD node: cd ${dir} && scp ${osd_to_copy.join(' ')} root@NODE:/etc/vitastor/ ----- `); if (copy) { for (const node of etcds) { await system("scp "+dir+to_copy.join(" "+dir)+" root@"+node.ip+":/etc/vitastor/"); } } else { console.warn('Certificates generated in '+dir+', please copy them to other nodes'); } } async function write_auth_config(config, config_path, etcds, use_perms, antietcd_only) { const auth = {}; if (use_perms) { auth["use_antietcd"] = true; if (!antietcd_only) { auth["etcd_proxy"] = { urls: etcds.map(e => e.ip+':2381'), cert: "/etc/vitastor/antietcd.crt", key: "/etc/vitastor/antietcd.key", ca: "/etc/vitastor/etcd_ca.crt", }; } auth["antietcd_cert"] = "/etc/vitastor/antietcd.crt"; auth["antietcd_key"] = "/etc/vitastor/antietcd.key"; auth["etcd_ca"] = "/etc/vitastor/antietcd_ca.crt"; auth["osd_cert"] = "/etc/vitastor/osd.crt"; auth["osd_pkey"] = "/etc/vitastor/osd.key"; auth["osd_ca"] = "/etc/vitastor/osd.crt"; auth["client_ca"] = "/etc/vitastor/client_ca.crt"; auth["cert"] = "/etc/vitastor/admin.crt"; auth["pkey"] = "/etc/vitastor/admin.key"; } else { if (antietcd_only) { auth["use_antietcd"] = true; auth["antietcd_cert"] = "/etc/vitastor/antietcd.crt"; auth["antietcd_key"] = "/etc/vitastor/antietcd.key"; auth["etcd_ca"] = "/etc/vitastor/antietcd_ca.crt"; } else { auth["etcd_ca"] = "/etc/vitastor/etcd.crt"; } } for (const k in auth) { if ((k in config) && JSON.stringify(auth[k]) != JSON.stringify(config[k])) { // Auth options already overridden with non-default console.log(k+" is already overridden in "+config_path+", skipping config update"); return; } } for (const k in auth) { config[k] = auth[k]; } console.log(`----- Updating ${config_path} ----- `); fs.writeFileSync(config_path, JSON.stringify(config, 0, 4)); } async function configure_etcd(etcds, num, tls, use_perms) { const in_docker = fs.existsSync("/etc/vitastor/etcd.conf") && fs.existsSync("/etc/vitastor/docker.conf"); if (!in_docker && fs.existsSync("/etc/systemd/system/vitastor-etcd.service")) { console.log("/etc/systemd/system/vitastor-etcd.service already exists"); process.exit(1); } if (!in_docker && fs.existsSync("/etc/systemd/system/etcd.service")) { console.log("/etc/systemd/system/etcd.service already exists"); process.exit(1); } const etcd_url = etcds[num].scheme + '://' + etcds[num].addr; const options = { name: 'etcd'+etcds[num].ip.replace(/[^0-9a-z_]/ig, '_'), advertise_client_urls: etcd_url+':'+(use_perms ? 2381 : 2379), listen_client_urls: etcd_url+':'+(use_perms ? 2381 : 2379), initial_advertise_peer_urls: etcd_url+':2380', listen_peer_urls: etcd_url+':2380', initial_cluster_token: 'vitastor-etcd-1', initial_cluster_state: 'new', initial_cluster: etcds.map(e => `etcd${e.ip.replace(/[^0-9a-z_]/ig, '_')}=${e.scheme}://${e.addr}:2380`).join(','), snapshot_count: 10000, max_txn_ops: 100000, max_request_bytes: 104857600, auto_compaction_retention: 10, auto_compaction_mode: 'revision', }; if (tls) { options['cert_file'] = '/etc/vitastor/etcd.crt'; options['key_file'] = '/etc/vitastor/etcd.key'; if (use_perms) { options['client_cert_auth'] = '1'; options['trusted_ca_file'] = '/etc/vitastor/antietcd.crt'; } options['peer_cert_file'] = '/etc/vitastor/etcd.crt'; options['peer_key_file'] = '/etc/vitastor/etcd.key'; if (use_perms) { options['peer_client_cert_auth'] = '1'; options['peer_trusted_ca_file'] = '/etc/vitastor/etcd.crt'; } } let etcd_conf = fs.existsSync("/etc/vitastor/etcd.conf") ? fs.readFileSync("/etc/vitastor/etcd.conf", { encoding: 'utf-8' }) : ""; for (const k in options) { etcd_conf = replace_env(etcd_conf, 'ETCD_'+k.toUpperCase().replace(/-/, '_'), options[k]); } fs.writeFileSync("/etc/vitastor/etcd.conf", etcd_conf); if (in_docker) { console.log('etcd for Vitastor configured. Run `systemctl enable --now vitastor-etcd` to start etcd'); process.exit(0); } await system(`mkdir -p /var/lib/etcd/vitastor`); fs.writeFileSync( "/etc/systemd/system/vitastor-etcd.service", `[Unit] Description=etcd for vitastor After=network-online.target local-fs.target time-sync.target Wants=network-online.target local-fs.target time-sync.target [Service] Restart=always Environment=GOGC=50 EnvironmentFile=/etc/vitastor/etcd.conf ExecStart=etcd --data-dir /var/lib/etcd/vitastor WorkingDirectory=/var/lib/etcd/vitastor ExecStartPre=+chown -R etcd /var/lib/etcd/vitastor User=etcd PrivateTmp=false TasksMax=infinity Restart=always StartLimitInterval=0 RestartSec=10 [Install] WantedBy=multi-user.target `); await system(`useradd etcd`); await system(`systemctl daemon-reload`); // Disable distribution etcd unit and enable our one await system(`systemctl disable --now etcd`); await system(`systemctl enable --now vitastor-etcd`); } async function enable_mon() { await system(`systemctl enable --now vitastor-mon`); } function replace_env(text, key, value) { let found = false; text = text.replace(new RegExp('^'+key+'\\s*=.*', 'm'), () => { found = true; return key+'='+value; }); return found ? text : text.replace(/\s*$/, '\n')+key+'='+value+'\n'; } function select_local_etcd(etcds) { const ifaces = os.networkInterfaces(); const local = {}; for (const ifname in ifaces) { for (const iface of ifaces[ifname]) { const addr = iface.address; if (iface.family == 'IPv6') local[addr.toLowerCase()] = local['['+addr.toLowerCase()+']'] = true; else local[addr] = true; } } for (let i = 0; i < etcds.length; i++) { if (local[etcds[i].addr]) return i; } return -1; } async function system(cmd) { console.log('Running '+cmd); const cp = child_process.spawn(cmd, { shell: true, stdio: [ 0, 1, 2 ] }); let finish_cb; cp.on('exit', () => finish_cb && finish_cb()); if (cp.exitCode == null) await new Promise(ok => finish_cb = ok); return cp.exitCode; } async function make_ca(subj, filename) { if (await system("openssl req -days 3650 -x509 -subj '"+subj+"' -addext basicConstraints=critical,CA:TRUE,pathlen:1"+ " -new -newkey rsa:4096 -nodes -keyout "+filename+".key -out "+filename+".crt")) process.exit(1); } async function make_signed(subj, f, ca, san) { if (await system(`openssl req -subj '${subj}' ${san ? "-addext 'subjectAltName="+san+"'" : ""} -nodes -new -keyout ${f}.key -out ${f}.csr`)) process.exit(1); if (await system(`openssl x509 -req -days 3650 -CA ${ca}.crt -CAkey ${ca}.key -CAcreateserial -in ${f}.csr -out ${f}.crt`)) process.exit(1); fs.unlinkSync(f+".csr"); }