Merge branch 'master' into 2049-doq

This commit is contained in:
Andrey Meshkov 2020-09-08 17:03:40 +03:00
commit 120d5304c2
20 changed files with 786 additions and 124 deletions

View File

@ -1,6 +1,10 @@
#!/bin/bash #!/bin/bash
set -e; set -e;
git diff --cached --name-only | grep -q '.js$' && make lint-js; git diff --cached --name-only | grep -q '.js$' && found=1
if [ $found == 1 ]; then
make lint-js || exit 1
npm run test --prefix client || exit 1
fi
found=0 found=0
git diff --cached --name-only | grep -q '.go$' && found=1 git diff --cached --name-only | grep -q '.go$' && found=1

6
client/package-lock.json generated vendored
View File

@ -12377,9 +12377,9 @@
} }
}, },
"react-i18next": { "react-i18next": {
"version": "11.4.0", "version": "11.7.2",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.4.0.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-11.7.2.tgz",
"integrity": "sha512-lyOZSSQkif4H9HnHN3iEKVkryLI+WkdZSEw3VAZzinZLopfYRMHVY5YxCopdkXPLEHs6S5GjKYPh3+j0j336Fg==", "integrity": "sha512-Djj3K3hh5Tecla2CI9rLO3TZBYGMFrGilm0JY4cLofAQONCi5TK6nVmUPKoB59n1ZffgjfgJt6zlbE9aGF6Q0Q==",
"requires": { "requires": {
"@babel/runtime": "^7.3.1", "@babel/runtime": "^7.3.1",
"html-parse-stringify2": "2.0.1" "html-parse-stringify2": "2.0.1"

2
client/package.json vendored
View File

@ -28,7 +28,7 @@
"react": "^16.13.1", "react": "^16.13.1",
"react-click-outside": "^3.0.1", "react-click-outside": "^3.0.1",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-i18next": "^11.4.0", "react-i18next": "^11.7.2",
"react-modal": "^3.11.2", "react-modal": "^3.11.2",
"react-popper-tooltip": "^2.11.1", "react-popper-tooltip": "^2.11.1",
"react-redux": "^7.2.0", "react-redux": "^7.2.0",

View File

@ -366,7 +366,7 @@
"fix": "Fix", "fix": "Fix",
"dns_providers": "Here is a <0>list of known DNS providers</0> to choose from.", "dns_providers": "Here is a <0>list of known DNS providers</0> to choose from.",
"update_now": "Update now", "update_now": "Update now",
"update_failed": "Auto-update failed. Please <a href='https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update'>follow the steps</a> to update manually.", "update_failed": "Auto-update failed. Please <a>follow these steps</a> to update manually.",
"processing_update": "Please wait, AdGuard Home is being updated", "processing_update": "Please wait, AdGuard Home is being updated",
"clients_title": "Clients", "clients_title": "Clients",
"clients_desc": "Configure devices connected to AdGuard Home", "clients_desc": "Configure devices connected to AdGuard Home",

View File

@ -1,5 +1,7 @@
import { getIpMatchListStatus, sortIp } from '../helpers/helpers'; import {
import { IP_MATCH_LIST_STATUS } from '../helpers/constants'; countClientsStatistics, findAddressType, getIpMatchListStatus, sortIp,
} from '../helpers/helpers';
import { ADDRESS_TYPES, IP_MATCH_LIST_STATUS } from '../helpers/constants';
describe('getIpMatchListStatus', () => { describe('getIpMatchListStatus', () => {
describe('IPv4', () => { describe('IPv4', () => {
@ -482,3 +484,56 @@ describe('sortIp', () => {
}); });
}); });
}); });
describe('findAddressType', () => {
describe('ip', () => {
expect(findAddressType('127.0.0.1')).toStrictEqual(ADDRESS_TYPES.IP);
});
describe('cidr', () => {
expect(findAddressType('127.0.0.1/8')).toStrictEqual(ADDRESS_TYPES.CIDR);
});
describe('mac', () => {
expect(findAddressType('00:1B:44:11:3A:B7')).toStrictEqual(ADDRESS_TYPES.UNKNOWN);
});
});
describe('countClientsStatistics', () => {
test('single ip', () => {
expect(countClientsStatistics(['127.0.0.1'], {
'127.0.0.1': 1,
})).toStrictEqual(1);
});
test('multiple ip', () => {
expect(countClientsStatistics(['127.0.0.1', '127.0.0.2'], {
'127.0.0.1': 1,
'127.0.0.2': 2,
})).toStrictEqual(1 + 2);
});
test('cidr', () => {
expect(countClientsStatistics(['127.0.0.0/8'], {
'127.0.0.1': 1,
'127.0.0.2': 2,
})).toStrictEqual(1 + 2);
});
test('cidr and multiple ip', () => {
expect(countClientsStatistics(['1.1.1.1', '2.2.2.2', '3.3.3.0/24'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
})).toStrictEqual(1 + 2 + 3);
});
test('mac', () => {
expect(countClientsStatistics(['00:1B:44:11:3A:B7', '2.2.2.2', '3.3.3.0/24'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
})).toStrictEqual(2 + 3);
});
test('not found', () => {
expect(countClientsStatistics(['4.4.4.4', '5.5.5.5', '6.6.6.6'], {
'1.1.1.1': 1,
'2.2.2.2': 2,
'3.3.3.3': 3,
})).toStrictEqual(0);
});
});

View File

@ -4,9 +4,10 @@ import axios from 'axios';
import endsWith from 'lodash/endsWith'; import endsWith from 'lodash/endsWith';
import escapeRegExp from 'lodash/escapeRegExp'; import escapeRegExp from 'lodash/escapeRegExp';
import React from 'react';
import { splitByNewLine, sortClients } from '../helpers/helpers'; import { splitByNewLine, sortClients } from '../helpers/helpers';
import { import {
BLOCK_ACTIONS, CHECK_TIMEOUT, STATUS_RESPONSE, SETTINGS_NAMES, FORM_NAME, BLOCK_ACTIONS, CHECK_TIMEOUT, STATUS_RESPONSE, SETTINGS_NAMES, FORM_NAME, GETTING_STARTED_LINK,
} from '../helpers/constants'; } from '../helpers/constants';
import { areEqualVersions } from '../helpers/version'; import { areEqualVersions } from '../helpers/version';
import { getTlsStatus } from './encryption'; import { getTlsStatus } from './encryption';
@ -184,7 +185,14 @@ export const getUpdate = () => async (dispatch, getState) => {
dispatch(getUpdateRequest()); dispatch(getUpdateRequest());
const handleRequestError = () => { const handleRequestError = () => {
dispatch(addNoticeToast({ error: 'update_failed' })); const options = {
components: {
a: <a href={GETTING_STARTED_LINK} target="_blank"
rel="noopener noreferrer" />,
},
};
dispatch(addNoticeToast({ error: 'update_failed', options }));
dispatch(getUpdateFailure()); dispatch(getUpdateFailure());
}; };

View File

@ -388,3 +388,28 @@
.logs__table .loading:before { .logs__table .loading:before {
min-height: 100%; min-height: 100%;
} }
.logs__whois {
display: inline;
font-size: 12px;
white-space: nowrap;
}
.logs__whois::after {
content: "|";
padding: 0 5px;
opacity: 0.3;
}
.logs__whois:last-child::after {
content: "";
}
.logs__whois-icon.icons {
position: relative;
top: -2px;
width: 12px;
height: 12px;
margin-right: 1px;
opacity: 0.5;
}

View File

@ -4,7 +4,7 @@ import { Trans, withTranslation } from 'react-i18next';
import ReactTable from 'react-table'; import ReactTable from 'react-table';
import { MODAL_TYPE } from '../../../helpers/constants'; import { MODAL_TYPE } from '../../../helpers/constants';
import { splitByNewLine } from '../../../helpers/helpers'; import { splitByNewLine, countClientsStatistics } from '../../../helpers/helpers';
import Card from '../../ui/Card'; import Card from '../../ui/Card';
import Modal from './Modal'; import Modal from './Modal';
import CellWrap from '../../ui/CellWrap'; import CellWrap from '../../ui/CellWrap';
@ -204,7 +204,10 @@ class ClientsTable extends Component {
{ {
Header: this.props.t('requests_count'), Header: this.props.t('requests_count'),
id: 'statistics', id: 'statistics',
accessor: (row) => this.props.normalizedTopClients.configured[row.name] || 0, accessor: (row) => countClientsStatistics(
row.ids,
this.props.normalizedTopClients.auto,
),
sortMethod: (a, b) => b - a, sortMethod: (a, b) => b - a,
minWidth: 120, minWidth: 120,
Cell: (row) => { Cell: (row) => {

View File

@ -14,7 +14,7 @@ const getFormattedWhois = (value, t) => {
<div key={key} title={t(key)}> <div key={key} title={t(key)}>
{icon && ( {icon && (
<Fragment> <Fragment>
<svg className="logs__whois-icon text-muted-dark icons"> <svg className="logs__whois-icon text-muted-dark icons icon--24">
<use xlinkHref={`#${icon}`} /> <use xlinkHref={`#${icon}`} />
</svg> </svg>
&nbsp; &nbsp;

View File

@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next'; import { Trans } from 'react-i18next';
import { useDispatch } from 'react-redux'; import { useDispatch } from 'react-redux';
import { TOAST_TIMEOUTS } from '../../helpers/constants'; import { TOAST_TIMEOUTS } from '../../helpers/constants';
import { removeToast } from '../../actions'; import { removeToast } from '../../actions';
@ -9,8 +9,8 @@ const Toast = ({
id, id,
message, message,
type, type,
options,
}) => { }) => {
const { t } = useTranslation();
const dispatch = useDispatch(); const dispatch = useDispatch();
const [timerId, setTimerId] = useState(null); const [timerId, setTimerId] = useState(null);
@ -30,7 +30,12 @@ const Toast = ({
return <div className={`toast toast--${type}`} return <div className={`toast toast--${type}`}
onMouseOver={clearRemoveToastTimeout} onMouseOver={clearRemoveToastTimeout}
onMouseOut={setRemoveToastTimeout}> onMouseOut={setRemoveToastTimeout}>
<p className="toast__content">{t(message)}</p> <p className="toast__content">
<Trans
i18nKey={message}
{...options}
/>
</p>
<button className="toast__dismiss" onClick={removeCurrentToast}> <button className="toast__dismiss" onClick={removeCurrentToast}>
<svg stroke="#fff" fill="none" width="20" height="20" strokeWidth="2" <svg stroke="#fff" fill="none" width="20" height="20" strokeWidth="2"
viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@ -45,6 +50,7 @@ Toast.propTypes = {
id: PropTypes.string.isRequired, id: PropTypes.string.isRequired,
message: PropTypes.string.isRequired, message: PropTypes.string.isRequired,
type: PropTypes.string.isRequired, type: PropTypes.string.isRequired,
options: PropTypes.object,
}; };
export default Toast; export default Toast;

View File

@ -53,6 +53,8 @@ export const REPOSITORY = {
export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html'; export const PRIVACY_POLICY_LINK = 'https://adguard.com/privacy/home.html';
export const PORT_53_FAQ_LINK = 'https://github.com/AdguardTeam/AdGuardHome/wiki/FAQ#bindinuse'; export const PORT_53_FAQ_LINK = 'https://github.com/AdguardTeam/AdGuardHome/wiki/FAQ#bindinuse';
export const GETTING_STARTED_LINK = 'https://github.com/AdguardTeam/AdGuardHome/wiki/Getting-Started#update';
export const ADDRESS_IN_USE_TEXT = 'address already in use'; export const ADDRESS_IN_USE_TEXT = 'address already in use';
export const INSTALL_FIRST_STEP = 1; export const INSTALL_FIRST_STEP = 1;
@ -77,8 +79,6 @@ export const EMPTY_DATE = '0001-01-01T00:00:00Z';
export const DEBOUNCE_TIMEOUT = 300; export const DEBOUNCE_TIMEOUT = 300;
export const DEBOUNCE_FILTER_TIMEOUT = 500; export const DEBOUNCE_FILTER_TIMEOUT = 500;
export const CHECK_TIMEOUT = 1000; export const CHECK_TIMEOUT = 1000;
export const SUCCESS_TOAST_TIMEOUT = 5000;
export const FAILURE_TOAST_TIMEOUT = 30000;
export const HIDE_TOOLTIP_DELAY = 300; export const HIDE_TOOLTIP_DELAY = 300;
export const SHOW_TOOLTIP_DELAY = 200; export const SHOW_TOOLTIP_DELAY = 200;
export const MODAL_OPEN_TIMEOUT = 150; export const MODAL_OPEN_TIMEOUT = 150;
@ -541,8 +541,17 @@ export const TOAST_TYPES = {
NOTICE: 'notice', NOTICE: 'notice',
}; };
export const SUCCESS_TOAST_TIMEOUT = 5000;
export const FAILURE_TOAST_TIMEOUT = 30000;
export const TOAST_TIMEOUTS = { export const TOAST_TIMEOUTS = {
[TOAST_TYPES.SUCCESS]: 5000, [TOAST_TYPES.SUCCESS]: SUCCESS_TOAST_TIMEOUT,
[TOAST_TYPES.ERROR]: 30000, [TOAST_TYPES.ERROR]: FAILURE_TOAST_TIMEOUT,
[TOAST_TYPES.NOTICE]: 30000, [TOAST_TYPES.NOTICE]: FAILURE_TOAST_TIMEOUT,
};
export const ADDRESS_TYPES = {
IP: 'IP',
CIDR: 'CIDR',
UNKNOWN: 'UNKNOWN',
}; };

View File

@ -14,6 +14,7 @@ import queryString from 'query-string';
import { getTrackerData } from './trackers/trackers'; import { getTrackerData } from './trackers/trackers';
import { import {
ADDRESS_TYPES,
CHECK_TIMEOUT, CHECK_TIMEOUT,
CUSTOM_FILTERING_RULES_ID, CUSTOM_FILTERING_RULES_ID,
DEFAULT_DATE_FORMAT_OPTIONS, DEFAULT_DATE_FORMAT_OPTIONS,
@ -509,6 +510,18 @@ const isIpMatchCidr = (parsedIp, parsedCidr) => {
} }
}; };
export const isIpInCidr = (ip, cidr) => {
try {
const parsedIp = ipaddr.parse(ip);
const parsedCidr = ipaddr.parseCIDR(cidr);
return isIpMatchCidr(parsedIp, parsedCidr);
} catch (e) {
console.error(e);
return false;
}
};
/** /**
* The purpose of this method is to quickly check * The purpose of this method is to quickly check
* if this IP can possibly be in the specified CIDR range. * if this IP can possibly be in the specified CIDR range.
@ -578,6 +591,29 @@ const isIpQuickMatchCIDR = (ip, listItem) => {
return false; return false;
}; };
/**
*
* @param ipOrCidr
* @returns {'IP' | 'CIDR' | 'UNKNOWN'}
*
*/
export const findAddressType = (address) => {
try {
const cidrMaybe = address.includes('/');
if (!cidrMaybe && ipaddr.isValid(address)) {
return ADDRESS_TYPES.IP;
}
if (cidrMaybe && ipaddr.parseCIDR(address)) {
return ADDRESS_TYPES.CIDR;
}
return ADDRESS_TYPES.UNKNOWN;
} catch (e) {
return ADDRESS_TYPES.UNKNOWN;
}
};
/** /**
* @param ip {string} * @param ip {string}
* @param list {string} * @param list {string}
@ -622,6 +658,42 @@ export const getIpMatchListStatus = (ip, list) => {
} }
}; };
/**
* @param ids {string[]}
* @returns {Object}
*/
export const separateIpsAndCidrs = (ids) => ids.reduce((acc, curr) => {
const addressType = findAddressType(curr);
if (addressType === ADDRESS_TYPES.IP) {
acc.ips.push(curr);
}
if (addressType === ADDRESS_TYPES.CIDR) {
acc.cidrs.push(curr);
}
return acc;
}, { ips: [], cidrs: [] });
export const countClientsStatistics = (ids, autoClients) => {
const { ips, cidrs } = separateIpsAndCidrs(ids);
const ipsCount = ips.reduce((acc, curr) => {
const count = autoClients[curr] || 0;
return acc + count;
}, 0);
const cidrsCount = Object.entries(autoClients)
.reduce((acc, curr) => {
const [id, count] = curr;
if (cidrs.some((cidr) => isIpInCidr(id, cidr))) {
// eslint-disable-next-line no-param-reassign
acc += count;
}
return acc;
}, 0);
return ipsCount + cidrsCount;
};
/** /**
* @param {string} elapsedMs * @param {string} elapsedMs

View File

@ -9,7 +9,7 @@ const getFormattedWhois = (whois) => {
.map((key) => { .map((key) => {
const icon = WHOIS_ICONS[key]; const icon = WHOIS_ICONS[key];
return ( return (
<span className="logs__whois text-muted " key={key} title={whoisInfo[key]}> <span className="logs__whois text-muted" key={key} title={whoisInfo[key]}>
{icon && ( {icon && (
<> <>
<svg className="logs__whois-icon icons icon--18"> <svg className="logs__whois-icon icons icon--18">

View File

@ -15,6 +15,7 @@ const toasts = handleActions({
const errorToast = { const errorToast = {
id: nanoid(), id: nanoid(),
message, message,
options: payload.options,
type: TOAST_TYPES.ERROR, type: TOAST_TYPES.ERROR,
}; };
@ -35,6 +36,7 @@ const toasts = handleActions({
const noticeToast = { const noticeToast = {
id: nanoid(), id: nanoid(),
message: payload.error.toString(), message: payload.error.toString(),
options: payload.options,
type: TOAST_TYPES.NOTICE, type: TOAST_TYPES.NOTICE,
}; };

View File

@ -276,7 +276,11 @@ type loginJSON struct {
} }
func getSession(u *User) []byte { func getSession(u *User) []byte {
d := []byte(fmt.Sprintf("%d%s%s", rand.Uint32(), u.Name, u.PasswordHash)) // the developers don't currently believe that using a
// non-cryptographic RNG for the session hash salt is
// insecure
salt := rand.Uint32() //nolint:gosec
d := []byte(fmt.Sprintf("%d%s%s", salt, u.Name, u.PasswordHash))
hash := sha256.Sum256(d) hash := sha256.Sum256(d)
return hash[:] return hash[:]
} }

View File

@ -126,7 +126,7 @@ func Main(version string, channel string, armVer string) {
}() }()
if args.serviceControlAction != "" { if args.serviceControlAction != "" {
handleServiceControlAction(args.serviceControlAction) handleServiceControlAction(args)
return return
} }
@ -526,108 +526,25 @@ func cleanupAlways() {
log.Info("Stopped") log.Info("Stopped")
} }
// command-line arguments func exitWithError() {
type options struct { os.Exit(64)
verbose bool // is verbose logging enabled
configFilename string // path to the config file
workDir string // path to the working directory where we will store the filters data and the querylog
bindHost string // host address to bind HTTP server on
bindPort int // port to serve HTTP pages on
logFile string // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog
pidFile string // File name to save PID to
checkConfig bool // Check configuration and exit
disableUpdate bool // If set, don't check for updates
// service control action (see service.ControlAction array + "status" command)
serviceControlAction string
// runningAsService flag is set to true when options are passed from the service runner
runningAsService bool
glinetMode bool // Activate GL-Inet mode
} }
// loadOptions reads command line arguments and initializes configuration // loadOptions reads command line arguments and initializes configuration
func loadOptions() options { func loadOptions() options {
o := options{} o, f, err := parse(os.Args[0], os.Args[1:])
var printHelp func() if err != nil {
var opts = []struct { log.Error(err.Error())
longName string _ = printHelp(os.Args[0])
shortName string exitWithError()
description string } else if f != nil {
callbackWithValue func(value string) err = f()
callbackNoValue func() if err != nil {
}{ log.Error(err.Error())
{"config", "c", "Path to the config file", func(value string) { o.configFilename = value }, nil}, exitWithError()
{"work-dir", "w", "Path to the working directory", func(value string) { o.workDir = value }, nil}, } else {
{"host", "h", "Host address to bind HTTP server on", func(value string) { o.bindHost = value }, nil},
{"port", "p", "Port to serve HTTP pages on", func(value string) {
v, err := strconv.Atoi(value)
if err != nil {
panic("Got port that is not a number")
}
o.bindPort = v
}, nil},
{"service", "s", "Service control action: status, install, uninstall, start, stop, restart, reload (configuration)", func(value string) {
o.serviceControlAction = value
}, nil},
{"logfile", "l", "Path to log file. If empty: write to stdout; if 'syslog': write to system log", func(value string) {
o.logFile = value
}, nil},
{"pidfile", "", "Path to a file where PID is stored", func(value string) { o.pidFile = value }, nil},
{"check-config", "", "Check configuration and exit", nil, func() { o.checkConfig = true }},
{"no-check-update", "", "Don't check for updates", nil, func() { o.disableUpdate = true }},
{"verbose", "v", "Enable verbose output", nil, func() { o.verbose = true }},
{"glinet", "", "Run in GL-Inet compatibility mode", nil, func() { o.glinetMode = true }},
{"version", "", "Show the version and exit", nil, func() {
fmt.Println(version())
os.Exit(0) os.Exit(0)
}},
{"help", "", "Print this help", nil, func() {
printHelp()
os.Exit(64)
}},
}
printHelp = func() {
fmt.Printf("Usage:\n\n")
fmt.Printf("%s [options]\n\n", os.Args[0])
fmt.Printf("Options:\n")
for _, opt := range opts {
val := ""
if opt.callbackWithValue != nil {
val = " VALUE"
}
if opt.shortName != "" {
fmt.Printf(" -%s, %-30s %s\n", opt.shortName, "--"+opt.longName+val, opt.description)
} else {
fmt.Printf(" %-34s %s\n", "--"+opt.longName+val, opt.description)
}
}
}
for i := 1; i < len(os.Args); i++ {
v := os.Args[i]
knownParam := false
for _, opt := range opts {
if v == "--"+opt.longName || (opt.shortName != "" && v == "-"+opt.shortName) {
if opt.callbackWithValue != nil {
if i+1 >= len(os.Args) {
log.Error("Got %s without argument\n", v)
os.Exit(64)
}
i++
opt.callbackWithValue(os.Args[i])
} else if opt.callbackNoValue != nil {
opt.callbackNoValue()
}
knownParam = true
break
}
}
if !knownParam {
log.Error("unknown option %v\n", v)
printHelp()
os.Exit(64)
} }
} }

View File

@ -1,3 +1,5 @@
// +build !race
package home package home
import ( import (

313
home/options.go Normal file
View File

@ -0,0 +1,313 @@
package home
import (
"fmt"
"os"
"strconv"
)
// options passed from command-line arguments
type options struct {
verbose bool // is verbose logging enabled
configFilename string // path to the config file
workDir string // path to the working directory where we will store the filters data and the querylog
bindHost string // host address to bind HTTP server on
bindPort int // port to serve HTTP pages on
logFile string // Path to the log file. If empty, write to stdout. If "syslog", writes to syslog
pidFile string // File name to save PID to
checkConfig bool // Check configuration and exit
disableUpdate bool // If set, don't check for updates
// service control action (see service.ControlAction array + "status" command)
serviceControlAction string
// runningAsService flag is set to true when options are passed from the service runner
runningAsService bool
glinetMode bool // Activate GL-Inet mode
}
// functions used for their side-effects
type effect func() error
type arg struct {
description string // a short, English description of the argument
longName string // the name of the argument used after '--'
shortName string // the name of the argument used after '-'
// only one of updateWithValue, updateNoValue, and effect should be present
updateWithValue func(o options, v string) (options, error) // the mutator for arguments with parameters
updateNoValue func(o options) (options, error) // the mutator for arguments without parameters
effect func(o options, exec string) (f effect, err error) // the side-effect closure generator
serialize func(o options) []string // the re-serialization function back to arguments (return nil for omit)
}
// {type}SliceOrNil functions check their parameter of type {type}
// against its zero value and return nil if the parameter value is
// zero otherwise they return a string slice of the parameter
func stringSliceOrNil(s string) []string {
if s == "" {
return nil
}
return []string{s}
}
func intSliceOrNil(i int) []string {
if i == 0 {
return nil
}
return []string{strconv.Itoa(i)}
}
func boolSliceOrNil(b bool) []string {
if b {
return []string{}
}
return nil
}
var args []arg
var configArg = arg{
"Path to the config file",
"config", "c",
func(o options, v string) (options, error) { o.configFilename = v; return o, nil },
nil,
nil,
func(o options) []string { return stringSliceOrNil(o.configFilename) },
}
var workDirArg = arg{
"Path to the working directory",
"work-dir", "w",
func(o options, v string) (options, error) { o.workDir = v; return o, nil }, nil, nil,
func(o options) []string { return stringSliceOrNil(o.workDir) },
}
var hostArg = arg{
"Host address to bind HTTP server on",
"host", "h",
func(o options, v string) (options, error) { o.bindHost = v; return o, nil }, nil, nil,
func(o options) []string { return stringSliceOrNil(o.bindHost) },
}
var portArg = arg{
"Port to serve HTTP pages on",
"port", "p",
func(o options, v string) (options, error) {
var err error
var p int
minPort, maxPort := 0, 1<<16-1
if p, err = strconv.Atoi(v); err != nil {
err = fmt.Errorf("port '%s' is not a number", v)
} else if p < minPort || p > maxPort {
err = fmt.Errorf("port %d not in range %d - %d", p, minPort, maxPort)
} else {
o.bindPort = p
}
return o, err
}, nil, nil,
func(o options) []string { return intSliceOrNil(o.bindPort) },
}
var serviceArg = arg{
"Service control action: status, install, uninstall, start, stop, restart, reload (configuration)",
"service", "s",
func(o options, v string) (options, error) {
o.serviceControlAction = v
return o, nil
}, nil, nil,
func(o options) []string { return stringSliceOrNil(o.serviceControlAction) },
}
var logfileArg = arg{
"Path to log file. If empty: write to stdout; if 'syslog': write to system log",
"logfile", "l",
func(o options, v string) (options, error) { o.logFile = v; return o, nil }, nil, nil,
func(o options) []string { return stringSliceOrNil(o.logFile) },
}
var pidfileArg = arg{
"Path to a file where PID is stored",
"pidfile", "",
func(o options, v string) (options, error) { o.pidFile = v; return o, nil }, nil, nil,
func(o options) []string { return stringSliceOrNil(o.pidFile) },
}
var checkConfigArg = arg{
"Check configuration and exit",
"check-config", "",
nil, func(o options) (options, error) { o.checkConfig = true; return o, nil }, nil,
func(o options) []string { return boolSliceOrNil(o.checkConfig) },
}
var noCheckUpdateArg = arg{
"Don't check for updates",
"no-check-update", "",
nil, func(o options) (options, error) { o.disableUpdate = true; return o, nil }, nil,
func(o options) []string { return boolSliceOrNil(o.disableUpdate) },
}
var verboseArg = arg{
"Enable verbose output",
"verbose", "v",
nil, func(o options) (options, error) { o.verbose = true; return o, nil }, nil,
func(o options) []string { return boolSliceOrNil(o.verbose) },
}
var glinetArg = arg{
"Run in GL-Inet compatibility mode",
"glinet", "",
nil, func(o options) (options, error) { o.glinetMode = true; return o, nil }, nil,
func(o options) []string { return boolSliceOrNil(o.glinetMode) },
}
var versionArg = arg{
"Show the version and exit",
"version", "",
nil, nil, func(o options, exec string) (effect, error) {
return func() error { fmt.Println(version()); os.Exit(0); return nil }, nil
},
func(o options) []string { return nil },
}
var helpArg = arg{
"Print this help",
"help", "",
nil, nil, func(o options, exec string) (effect, error) {
return func() error { _ = printHelp(exec); os.Exit(64); return nil }, nil
},
func(o options) []string { return nil },
}
func init() {
args = []arg{
configArg,
workDirArg,
hostArg,
portArg,
serviceArg,
logfileArg,
pidfileArg,
checkConfigArg,
noCheckUpdateArg,
verboseArg,
glinetArg,
versionArg,
helpArg,
}
}
func getUsageLines(exec string, args []arg) []string {
usage := []string{
"Usage:",
"",
fmt.Sprintf("%s [options]", exec),
"",
"Options:",
}
for _, arg := range args {
val := ""
if arg.updateWithValue != nil {
val = " VALUE"
}
if arg.shortName != "" {
usage = append(usage, fmt.Sprintf(" -%s, %-30s %s",
arg.shortName,
"--"+arg.longName+val,
arg.description))
} else {
usage = append(usage, fmt.Sprintf(" %-34s %s",
"--"+arg.longName+val,
arg.description))
}
}
return usage
}
func printHelp(exec string) error {
for _, line := range getUsageLines(exec, args) {
_, err := fmt.Println(line)
if err != nil {
return err
}
}
return nil
}
func argMatches(a arg, v string) bool {
return v == "--"+a.longName || (a.shortName != "" && v == "-"+a.shortName)
}
func parse(exec string, ss []string) (o options, f effect, err error) {
for i := 0; i < len(ss); i++ {
v := ss[i]
knownParam := false
for _, arg := range args {
if argMatches(arg, v) {
if arg.updateWithValue != nil {
if i+1 >= len(ss) {
return o, f, fmt.Errorf("got %s without argument", v)
}
i++
o, err = arg.updateWithValue(o, ss[i])
if err != nil {
return
}
} else if arg.updateNoValue != nil {
o, err = arg.updateNoValue(o)
if err != nil {
return
}
} else if arg.effect != nil {
var eff effect
eff, err = arg.effect(o, exec)
if err != nil {
return
}
if eff != nil {
prevf := f
f = func() error {
var err error
if prevf != nil {
err = prevf()
}
if err == nil {
err = eff()
}
return err
}
}
}
knownParam = true
break
}
}
if !knownParam {
return o, f, fmt.Errorf("unknown option %v", v)
}
}
return
}
func shortestFlag(a arg) string {
if a.shortName != "" {
return "-" + a.shortName
}
return "--" + a.longName
}
func serialize(o options) []string {
ss := []string{}
for _, arg := range args {
s := arg.serialize(o)
if s != nil {
ss = append(ss, append([]string{shortestFlag(arg)}, s...)...)
}
}
return ss
}

237
home/options_test.go Normal file
View File

@ -0,0 +1,237 @@
package home
import (
"fmt"
"testing"
)
func testParseOk(t *testing.T, ss ...string) options {
o, _, err := parse("", ss)
if err != nil {
t.Fatal(err.Error())
}
return o
}
func testParseErr(t *testing.T, descr string, ss ...string) {
_, _, err := parse("", ss)
if err == nil {
t.Fatalf("expected an error because %s but no error returned", descr)
}
}
func testParseParamMissing(t *testing.T, param string) {
testParseErr(t, fmt.Sprintf("%s parameter missing", param), param)
}
func TestParseVerbose(t *testing.T) {
if testParseOk(t).verbose {
t.Fatal("empty is not verbose")
}
if !testParseOk(t, "-v").verbose {
t.Fatal("-v is verbose")
}
if !testParseOk(t, "--verbose").verbose {
t.Fatal("--verbose is verbose")
}
}
func TestParseConfigFilename(t *testing.T) {
if testParseOk(t).configFilename != "" {
t.Fatal("empty is no config filename")
}
if testParseOk(t, "-c", "path").configFilename != "path" {
t.Fatal("-c is config filename")
}
testParseParamMissing(t, "-c")
if testParseOk(t, "--config", "path").configFilename != "path" {
t.Fatal("--configFilename is config filename")
}
testParseParamMissing(t, "--config")
}
func TestParseWorkDir(t *testing.T) {
if testParseOk(t).workDir != "" {
t.Fatal("empty is no work dir")
}
if testParseOk(t, "-w", "path").workDir != "path" {
t.Fatal("-w is work dir")
}
testParseParamMissing(t, "-w")
if testParseOk(t, "--work-dir", "path").workDir != "path" {
t.Fatal("--work-dir is work dir")
}
testParseParamMissing(t, "--work-dir")
}
func TestParseBindHost(t *testing.T) {
if testParseOk(t).bindHost != "" {
t.Fatal("empty is no host")
}
if testParseOk(t, "-h", "addr").bindHost != "addr" {
t.Fatal("-h is host")
}
testParseParamMissing(t, "-h")
if testParseOk(t, "--host", "addr").bindHost != "addr" {
t.Fatal("--host is host")
}
testParseParamMissing(t, "--host")
}
func TestParseBindPort(t *testing.T) {
if testParseOk(t).bindPort != 0 {
t.Fatal("empty is port 0")
}
if testParseOk(t, "-p", "65535").bindPort != 65535 {
t.Fatal("-p is port")
}
testParseParamMissing(t, "-p")
if testParseOk(t, "--port", "65535").bindPort != 65535 {
t.Fatal("--port is port")
}
testParseParamMissing(t, "--port")
}
func TestParseBindPortBad(t *testing.T) {
testParseErr(t, "not an int", "-p", "x")
testParseErr(t, "hex not supported", "-p", "0x100")
testParseErr(t, "port negative", "-p", "-1")
testParseErr(t, "port too high", "-p", "65536")
testParseErr(t, "port too high", "-p", "4294967297") // 2^32 + 1
testParseErr(t, "port too high", "-p", "18446744073709551617") // 2^64 + 1
}
func TestParseLogfile(t *testing.T) {
if testParseOk(t).logFile != "" {
t.Fatal("empty is no log file")
}
if testParseOk(t, "-l", "path").logFile != "path" {
t.Fatal("-l is log file")
}
if testParseOk(t, "--logfile", "path").logFile != "path" {
t.Fatal("--logfile is log file")
}
}
func TestParsePidfile(t *testing.T) {
if testParseOk(t).pidFile != "" {
t.Fatal("empty is no pid file")
}
if testParseOk(t, "--pidfile", "path").pidFile != "path" {
t.Fatal("--pidfile is pid file")
}
}
func TestParseCheckConfig(t *testing.T) {
if testParseOk(t).checkConfig {
t.Fatal("empty is not check config")
}
if !testParseOk(t, "--check-config").checkConfig {
t.Fatal("--check-config is check config")
}
}
func TestParseDisableUpdate(t *testing.T) {
if testParseOk(t).disableUpdate {
t.Fatal("empty is not disable update")
}
if !testParseOk(t, "--no-check-update").disableUpdate {
t.Fatal("--no-check-update is disable update")
}
}
func TestParseService(t *testing.T) {
if testParseOk(t).serviceControlAction != "" {
t.Fatal("empty is no service command")
}
if testParseOk(t, "-s", "command").serviceControlAction != "command" {
t.Fatal("-s is service command")
}
if testParseOk(t, "--service", "command").serviceControlAction != "command" {
t.Fatal("--service is service command")
}
}
func TestParseGLInet(t *testing.T) {
if testParseOk(t).glinetMode {
t.Fatal("empty is not GL-Inet mode")
}
if !testParseOk(t, "--glinet").glinetMode {
t.Fatal("--glinet is GL-Inet mode")
}
}
func TestParseUnknown(t *testing.T) {
testParseErr(t, "unknown word", "x")
testParseErr(t, "unknown short", "-x")
testParseErr(t, "unknown long", "--x")
testParseErr(t, "unknown triple", "---x")
testParseErr(t, "unknown plus", "+x")
testParseErr(t, "unknown dash", "-")
}
func testSerialize(t *testing.T, o options, ss ...string) {
result := serialize(o)
if len(result) != len(ss) {
t.Fatalf("expected %s but got %s", ss, result)
}
for i, r := range result {
if r != ss[i] {
t.Fatalf("expected %s but got %s", ss, result)
}
}
}
func TestSerializeEmpty(t *testing.T) {
testSerialize(t, options{})
}
func TestSerializeConfigFilename(t *testing.T) {
testSerialize(t, options{configFilename: "path"}, "-c", "path")
}
func TestSerializeWorkDir(t *testing.T) {
testSerialize(t, options{workDir: "path"}, "-w", "path")
}
func TestSerializeBindHost(t *testing.T) {
testSerialize(t, options{bindHost: "addr"}, "-h", "addr")
}
func TestSerializeBindPort(t *testing.T) {
testSerialize(t, options{bindPort: 666}, "-p", "666")
}
func TestSerializeLogfile(t *testing.T) {
testSerialize(t, options{logFile: "path"}, "-l", "path")
}
func TestSerializePidfile(t *testing.T) {
testSerialize(t, options{pidFile: "path"}, "--pidfile", "path")
}
func TestSerializeCheckConfig(t *testing.T) {
testSerialize(t, options{checkConfig: true}, "--check-config")
}
func TestSerializeDisableUpdate(t *testing.T) {
testSerialize(t, options{disableUpdate: true}, "--no-check-update")
}
func TestSerializeService(t *testing.T) {
testSerialize(t, options{serviceControlAction: "run"}, "-s", "run")
}
func TestSerializeGLInet(t *testing.T) {
testSerialize(t, options{glinetMode: true}, "--glinet")
}
func TestSerializeMultiple(t *testing.T) {
testSerialize(t, options{
serviceControlAction: "run",
configFilename: "config",
workDir: "work",
pidFile: "pid",
disableUpdate: true,
}, "-c", "config", "-w", "work", "-s", "run", "--pidfile", "pid", "--no-check-update")
}

View File

@ -24,12 +24,14 @@ const (
// Represents the program that will be launched by a service or daemon // Represents the program that will be launched by a service or daemon
type program struct { type program struct {
opts options
} }
// Start should quickly start the program // Start should quickly start the program
func (p *program) Start(s service.Service) error { func (p *program) Start(s service.Service) error {
// Start should not block. Do the actual work async. // Start should not block. Do the actual work async.
args := options{runningAsService: true} args := p.opts
args.runningAsService = true
go run(args) go run(args)
return nil return nil
} }
@ -125,7 +127,8 @@ func sendSigReload() {
// run - this is a special command that is not supposed to be used directly // run - this is a special command that is not supposed to be used directly
// it is specified when we register a service, and it indicates to the app // it is specified when we register a service, and it indicates to the app
// that it is being run as a service/daemon. // that it is being run as a service/daemon.
func handleServiceControlAction(action string) { func handleServiceControlAction(opts options) {
action := opts.serviceControlAction
log.Printf("Service control action: %s", action) log.Printf("Service control action: %s", action)
if action == "reload" { if action == "reload" {
@ -137,15 +140,17 @@ func handleServiceControlAction(action string) {
if err != nil { if err != nil {
log.Fatal("Unable to find the path to the current directory") log.Fatal("Unable to find the path to the current directory")
} }
runOpts := opts
runOpts.serviceControlAction = "run"
svcConfig := &service.Config{ svcConfig := &service.Config{
Name: serviceName, Name: serviceName,
DisplayName: serviceDisplayName, DisplayName: serviceDisplayName,
Description: serviceDescription, Description: serviceDescription,
WorkingDirectory: pwd, WorkingDirectory: pwd,
Arguments: []string{"-s", "run"}, Arguments: serialize(runOpts),
} }
configureService(svcConfig) configureService(svcConfig)
prg := &program{} prg := &program{runOpts}
s, err := service.New(prg, svcConfig) s, err := service.New(prg, svcConfig)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)