2020-07-10 11:27:55 +00:00
|
|
|
/**
|
|
|
|
* @license
|
|
|
|
* Copyright 2016-2020 Balena Ltd.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
import { flags } from '@oclif/command';
|
|
|
|
import Command from '../../command';
|
|
|
|
import * as cf from '../../utils/common-flags';
|
|
|
|
import { stripIndent } from '../../utils/lazy';
|
|
|
|
|
|
|
|
interface FlagsDef {
|
|
|
|
help: void;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface ArgsDef {
|
|
|
|
target: string;
|
|
|
|
}
|
|
|
|
|
|
|
|
export default class LocalConfigureCmd extends Command {
|
|
|
|
public static description = stripIndent`
|
|
|
|
(Re)configure a balenaOS drive or image.
|
|
|
|
|
|
|
|
Configure or reconfigure a balenaOS drive or image.
|
|
|
|
`;
|
|
|
|
|
|
|
|
public static examples = [
|
|
|
|
'$ balena local configure /dev/sdc',
|
|
|
|
'$ balena local configure path/to/image.img',
|
|
|
|
];
|
|
|
|
|
|
|
|
public static args = [
|
|
|
|
{
|
|
|
|
name: 'target',
|
|
|
|
description: 'path of drive or image to configure',
|
|
|
|
required: true,
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
public static usage = 'local configure <target>';
|
|
|
|
|
|
|
|
public static flags: flags.Input<FlagsDef> = {
|
|
|
|
help: cf.help,
|
|
|
|
};
|
|
|
|
|
|
|
|
public static root = true;
|
|
|
|
|
|
|
|
public async run() {
|
|
|
|
const { args: params } = this.parse<FlagsDef, ArgsDef>(LocalConfigureCmd);
|
|
|
|
|
2020-07-29 17:27:20 +00:00
|
|
|
const { promisify } = await import('util');
|
2020-07-10 11:27:55 +00:00
|
|
|
const path = await import('path');
|
|
|
|
const umount = await import('umount');
|
2020-07-29 17:27:20 +00:00
|
|
|
const umountAsync = promisify(umount.umount);
|
|
|
|
const isMountedAsync = promisify(umount.isMounted);
|
2020-07-10 11:27:55 +00:00
|
|
|
const reconfix = await import('reconfix');
|
2020-07-29 17:27:20 +00:00
|
|
|
const denymount = promisify(await import('denymount'));
|
2020-07-10 11:27:55 +00:00
|
|
|
const Logger = await import('../../utils/logger');
|
|
|
|
|
|
|
|
const logger = Logger.getLogger();
|
|
|
|
|
|
|
|
const configurationSchema = await this.prepareConnectionFile(params.target);
|
|
|
|
|
|
|
|
if (await isMountedAsync(params.target)) {
|
|
|
|
await umountAsync(params.target);
|
|
|
|
}
|
|
|
|
|
|
|
|
const dmOpts: any = {};
|
|
|
|
if (process.pkg) {
|
|
|
|
// when running in a standalone pkg install, the 'denymount'
|
|
|
|
// executable is placed on the same folder as process.execPath
|
|
|
|
dmOpts.executablePath = path.join(
|
|
|
|
path.dirname(process.execPath),
|
|
|
|
'denymount',
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
const dmHandler = (cb: () => void) =>
|
|
|
|
reconfix
|
|
|
|
.readConfiguration(configurationSchema, params.target)
|
|
|
|
.tap((config: any) => {
|
|
|
|
logger.logDebug('Current config:');
|
|
|
|
logger.logDebug(JSON.stringify(config));
|
|
|
|
})
|
|
|
|
.then((config: any) => this.getConfiguration(config))
|
|
|
|
.tap((config: any) => {
|
|
|
|
logger.logDebug('New config:');
|
|
|
|
logger.logDebug(JSON.stringify(config));
|
|
|
|
})
|
|
|
|
.then(async (answers: any) => {
|
|
|
|
if (!answers.hostname) {
|
|
|
|
await this.removeHostname(configurationSchema);
|
|
|
|
}
|
|
|
|
return reconfix.writeConfiguration(
|
|
|
|
configurationSchema,
|
|
|
|
answers,
|
|
|
|
params.target,
|
|
|
|
);
|
|
|
|
})
|
|
|
|
.asCallback(cb);
|
|
|
|
|
|
|
|
await denymount(params.target, dmHandler, dmOpts);
|
|
|
|
|
|
|
|
console.log('Done!');
|
|
|
|
}
|
|
|
|
|
|
|
|
readonly BOOT_PARTITION = 1;
|
|
|
|
readonly CONNECTIONS_FOLDER = '/system-connections';
|
|
|
|
|
|
|
|
getConfigurationSchema(connectionFileName?: string) {
|
|
|
|
if (connectionFileName == null) {
|
|
|
|
connectionFileName = 'resin-wifi';
|
|
|
|
}
|
|
|
|
return {
|
|
|
|
mapper: [
|
|
|
|
{
|
|
|
|
template: {
|
|
|
|
persistentLogging: '{{persistentLogging}}',
|
|
|
|
},
|
|
|
|
domain: [['config_json', 'persistentLogging']],
|
|
|
|
},
|
|
|
|
{
|
|
|
|
template: {
|
|
|
|
hostname: '{{hostname}}',
|
|
|
|
},
|
|
|
|
domain: [['config_json', 'hostname']],
|
|
|
|
},
|
|
|
|
{
|
|
|
|
template: {
|
|
|
|
wifi: {
|
|
|
|
ssid: '{{networkSsid}}',
|
|
|
|
},
|
|
|
|
'wifi-security': {
|
|
|
|
psk: '{{networkKey}}',
|
|
|
|
},
|
|
|
|
},
|
|
|
|
domain: [
|
|
|
|
['system_connections', connectionFileName, 'wifi'],
|
|
|
|
['system_connections', connectionFileName, 'wifi-security'],
|
|
|
|
],
|
|
|
|
},
|
|
|
|
],
|
|
|
|
files: {
|
|
|
|
system_connections: {
|
|
|
|
fileset: true,
|
|
|
|
type: 'ini',
|
|
|
|
location: {
|
|
|
|
path: this.CONNECTIONS_FOLDER.slice(1),
|
|
|
|
// Reconfix still uses the older resin-image-fs, so still needs an
|
|
|
|
// object-based partition definition.
|
|
|
|
partition: this.BOOT_PARTITION,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
config_json: {
|
|
|
|
type: 'json',
|
|
|
|
location: {
|
|
|
|
path: 'config.json',
|
|
|
|
partition: this.BOOT_PARTITION,
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
inquirerOptions = (data: any) => [
|
|
|
|
{
|
|
|
|
message: 'Network SSID',
|
|
|
|
type: 'input',
|
|
|
|
name: 'networkSsid',
|
|
|
|
default: data.networkSsid,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
message: 'Network Key',
|
|
|
|
type: 'input',
|
|
|
|
name: 'networkKey',
|
|
|
|
default: data.networkKey,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
message: 'Do you want to set advanced settings?',
|
|
|
|
type: 'confirm',
|
|
|
|
name: 'advancedSettings',
|
|
|
|
default: false,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
message: 'Device Hostname',
|
|
|
|
type: 'input',
|
|
|
|
name: 'hostname',
|
|
|
|
default: data.hostname,
|
|
|
|
when(answers: any) {
|
|
|
|
return answers.advancedSettings;
|
|
|
|
},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
message: 'Do you want to enable persistent logging?',
|
|
|
|
type: 'confirm',
|
|
|
|
name: 'persistentLogging',
|
|
|
|
default: data.persistentLogging,
|
|
|
|
when(answers: any) {
|
|
|
|
return answers.advancedSettings;
|
|
|
|
},
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
getConfiguration = async (data: any) => {
|
|
|
|
const _ = await import('lodash');
|
|
|
|
const inquirer = await import('inquirer');
|
|
|
|
|
|
|
|
// `persistentLogging` can be `undefined`, so we want
|
|
|
|
// to make sure that case defaults to `false`
|
|
|
|
data = _.assign(data, {
|
|
|
|
persistentLogging: data.persistentLogging || false,
|
|
|
|
});
|
|
|
|
|
|
|
|
return inquirer
|
|
|
|
.prompt(this.inquirerOptions(data))
|
|
|
|
.then((answers: any) => _.merge(data, answers));
|
|
|
|
};
|
|
|
|
|
|
|
|
// Taken from https://goo.gl/kr1kCt
|
|
|
|
readonly CONNECTION_FILE = stripIndent`
|
|
|
|
[connection]
|
|
|
|
id=resin-wifi
|
|
|
|
type=wifi
|
|
|
|
|
|
|
|
[wifi]
|
|
|
|
hidden=true
|
|
|
|
mode=infrastructure
|
|
|
|
ssid=My_Wifi_Ssid
|
|
|
|
|
|
|
|
[wifi-security]
|
|
|
|
auth-alg=open
|
|
|
|
key-mgmt=wpa-psk
|
|
|
|
psk=super_secret_wifi_password
|
|
|
|
|
|
|
|
[ipv4]
|
|
|
|
method=auto
|
|
|
|
|
|
|
|
[ipv6]
|
|
|
|
addr-gen-mode=stable-privacy
|
|
|
|
method=auto\
|
|
|
|
`;
|
|
|
|
|
|
|
|
/*
|
|
|
|
* if the `resin-wifi` file exists (previously configured image or downloaded from the UI) it's used and reconfigured
|
|
|
|
* if the `resin-sample.ignore` exists it's copied to `resin-wifi`
|
|
|
|
* if the `resin-sample` exists it's reconfigured (legacy mode, will be removed eventually)
|
|
|
|
* otherwise, the new file is created
|
|
|
|
*/
|
|
|
|
async prepareConnectionFile(target: string) {
|
|
|
|
const _ = await import('lodash');
|
|
|
|
const imagefs = await import('resin-image-fs');
|
|
|
|
|
|
|
|
return imagefs
|
|
|
|
.listDirectory({
|
|
|
|
image: target,
|
|
|
|
partition: this.BOOT_PARTITION,
|
|
|
|
path: this.CONNECTIONS_FOLDER,
|
|
|
|
})
|
|
|
|
.then((files: string[]) => {
|
|
|
|
// The required file already exists
|
|
|
|
if (_.includes(files, 'resin-wifi')) {
|
|
|
|
return null;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Fresh image, new mode, accoding to https://github.com/balena-os/meta-balena/pull/770/files
|
|
|
|
if (_.includes(files, 'resin-sample.ignore')) {
|
|
|
|
return imagefs
|
|
|
|
.copy(
|
|
|
|
{
|
|
|
|
image: target,
|
|
|
|
partition: this.BOOT_PARTITION,
|
|
|
|
path: `${this.CONNECTIONS_FOLDER}/resin-sample.ignore`,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
image: target,
|
|
|
|
partition: this.BOOT_PARTITION,
|
|
|
|
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
|
|
|
|
},
|
|
|
|
)
|
|
|
|
.thenReturn(null);
|
|
|
|
}
|
|
|
|
|
|
|
|
// Legacy mode, to be removed later
|
|
|
|
// We return the file name override from this branch
|
|
|
|
// When it is removed the following cleanup should be done:
|
|
|
|
// * delete all the null returns from this method
|
|
|
|
// * turn `getConfigurationSchema` back into the constant, with the connection filename always being `resin-wifi`
|
|
|
|
// * drop the final `then` from this method
|
|
|
|
// * adapt the code in the main listener to not receive the config from this method, and use that constant instead
|
|
|
|
if (_.includes(files, 'resin-sample')) {
|
|
|
|
return 'resin-sample';
|
|
|
|
}
|
|
|
|
|
|
|
|
// In case there's no file at all (shouldn't happen normally, but the file might have been removed)
|
|
|
|
return imagefs
|
|
|
|
.writeFile(
|
|
|
|
{
|
|
|
|
image: target,
|
|
|
|
partition: this.BOOT_PARTITION,
|
|
|
|
path: `${this.CONNECTIONS_FOLDER}/resin-wifi`,
|
|
|
|
},
|
|
|
|
this.CONNECTION_FILE,
|
|
|
|
)
|
|
|
|
.thenReturn(null);
|
|
|
|
})
|
|
|
|
.then((connectionFileName) =>
|
|
|
|
this.getConfigurationSchema(connectionFileName || undefined),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
async removeHostname(schema: any) {
|
|
|
|
const _ = await import('lodash');
|
|
|
|
schema.mapper = _.reject(schema.mapper, (mapper: any) =>
|
|
|
|
_.isEqual(Object.keys(mapper.template), ['hostname']),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|