diff --git a/AGHTechDoc.md b/AGHTechDoc.md index 62ea41d4..c6f615f3 100644 --- a/AGHTechDoc.md +++ b/AGHTechDoc.md @@ -19,9 +19,11 @@ Contents: * Update client * Delete client * Enable DHCP server + * "Show DHCP status" command * "Check DHCP" command * "Enable DHCP" command * Static IP check/set + * Add a static lease ## First startup @@ -302,6 +304,38 @@ Algorithm: * UI shows the status +### "Show DHCP status" command + +Request: + + GET /control/dhcp/status + +Response: + + 200 OK + + { + "config":{ + "enabled":false, + "interface_name":"...", + "gateway_ip":"...", + "subnet_mask":"...", + "range_start":"...", + "range_end":"...", + "lease_duration":60, + "icmp_timeout_msec":0 + }, + "leases":[ + {"ip":"...","mac":"...","hostname":"...","expires":"..."} + ... + ], + "static_leases":[ + {"ip":"...","mac":"...","hostname":"..."} + ... + ] + } + + ### "Check DHCP" command Request: @@ -428,6 +462,40 @@ If we would set a different IP address, we'd need to replace the IP address for ip addr replace dev eth0 192.168.0.1/24 +### Add a static lease + +Request: + + POST /control/dhcp/add_static_lease + + { + "mac":"...", + "ip":"...", + "hostname":"..." + } + +Response: + + 200 OK + + +### Remove a static lease + +Request: + + POST /control/dhcp/remove_static_lease + + { + "mac":"...", + "ip":"...", + "hostname":"..." + } + +Response: + + 200 OK + + ## Device Names and Per-client Settings When a client requests information from DNS server, he's identified by IP address. diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index bbfe2336..697dbbcd 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -15,6 +15,7 @@ "dhcp_not_found": "It is safe to enable the built-in DHCP server - we didn't find any active DHCP servers on the network. However, we encourage you to re-check it manually as our automatic test currently doesn't give 100% guarantee.", "dhcp_found": "An active DHCP server is found on the network. It is not safe to enable the built-in DHCP server.", "dhcp_leases": "DHCP leases", + "dhcp_static_leases": "DHCP static leases", "dhcp_leases_not_found": "No DHCP leases found", "dhcp_config_saved": "Saved DHCP server config", "form_error_required": "Required field", @@ -37,6 +38,13 @@ "dhcp_error": "We could not determine whether there is another DHCP server in the network.", "dhcp_static_ip_error": "In order to use DHCP server a static IP address must be set. We failed to determine if this network interface is configured using static IP address. Please set a static IP address manually.", "dhcp_dynamic_ip_found": "Your system uses dynamic IP address configuration for interface <0>{{interfaceName}}. In order to use DHCP server a static IP address must be set. Your current IP address is <0>{{ipAddress}}. We will automatically set this IP address as static if you press Enable DHCP button.", + "dhcp_lease_added": "Static lease \"{{key}}\" successfully added", + "dhcp_lease_deleted": "Static lease \"{{key}}\" successfully deleted", + "dhcp_new_static_lease": "New static lease", + "dhcp_static_leases_not_found": "No DHCP static leases found", + "dhcp_add_static_lease": "Add static lease", + "delete_confirm": "Are you sure you want to delete \"{{key}}\"?", + "form_enter_hostname": "Enter hostname", "error_details": "Error details", "back": "Back", "dashboard": "Dashboard", diff --git a/client/src/actions/index.js b/client/src/actions/index.js index 39224388..d683a3f5 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -662,41 +662,18 @@ export const setDhcpConfigRequest = createAction('SET_DHCP_CONFIG_REQUEST'); export const setDhcpConfigSuccess = createAction('SET_DHCP_CONFIG_SUCCESS'); export const setDhcpConfigFailure = createAction('SET_DHCP_CONFIG_FAILURE'); -// TODO rewrite findActiveDhcp part export const setDhcpConfig = values => async (dispatch, getState) => { const { config } = getState().dhcp; const updatedConfig = { ...config, ...values }; dispatch(setDhcpConfigRequest()); - if (values.interface_name) { - dispatch(findActiveDhcpRequest()); - try { - const activeDhcp = await apiClient.findActiveDhcp(values.interface_name); - dispatch(findActiveDhcpSuccess(activeDhcp)); - if (!activeDhcp.found) { - try { - await apiClient.setDhcpConfig(updatedConfig); - dispatch(setDhcpConfigSuccess(updatedConfig)); - dispatch(addSuccessToast('dhcp_config_saved')); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(setDhcpConfigFailure()); - } - } else { - dispatch(addErrorToast({ error: 'dhcp_found' })); - } - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(findActiveDhcpFailure()); - } - } else { - try { - await apiClient.setDhcpConfig(updatedConfig); - dispatch(setDhcpConfigSuccess(updatedConfig)); - dispatch(addSuccessToast('dhcp_config_saved')); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(setDhcpConfigFailure()); - } + dispatch(findActiveDhcp(values.interface_name)); + try { + await apiClient.setDhcpConfig(updatedConfig); + dispatch(setDhcpConfigSuccess(updatedConfig)); + dispatch(addSuccessToast('dhcp_config_saved')); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(setDhcpConfigFailure()); } }; @@ -704,40 +681,60 @@ export const toggleDhcpRequest = createAction('TOGGLE_DHCP_REQUEST'); export const toggleDhcpFailure = createAction('TOGGLE_DHCP_FAILURE'); export const toggleDhcpSuccess = createAction('TOGGLE_DHCP_SUCCESS'); -// TODO rewrite findActiveDhcp part -export const toggleDhcp = config => async (dispatch) => { +export const toggleDhcp = values => async (dispatch) => { dispatch(toggleDhcpRequest()); + let config = { ...values, enabled: false }; + let successMessage = 'disabled_dhcp'; - if (config.enabled) { - try { - await apiClient.setDhcpConfig({ ...config, enabled: false }); - dispatch(toggleDhcpSuccess()); - dispatch(addSuccessToast('disabled_dhcp')); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(toggleDhcpFailure()); - } - } else { - dispatch(findActiveDhcpRequest()); - try { - const activeDhcp = await apiClient.findActiveDhcp(config.interface_name); - dispatch(findActiveDhcpSuccess(activeDhcp)); + if (!values.enabled) { + config = { ...values, enabled: true }; + successMessage = 'enabled_dhcp'; + dispatch(findActiveDhcp(values.interface_name)); + } - if (!activeDhcp.found) { - try { - await apiClient.setDhcpConfig({ ...config, enabled: true }); - dispatch(toggleDhcpSuccess()); - dispatch(addSuccessToast('enabled_dhcp')); - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(toggleDhcpFailure()); - } - } else { - dispatch(addErrorToast({ error: 'dhcp_found' })); - } - } catch (error) { - dispatch(addErrorToast({ error })); - dispatch(findActiveDhcpFailure()); - } + try { + await apiClient.setDhcpConfig(config); + dispatch(toggleDhcpSuccess()); + dispatch(addSuccessToast(successMessage)); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(toggleDhcpFailure()); + } +}; + +export const toggleLeaseModal = createAction('TOGGLE_LEASE_MODAL'); + +export const addStaticLeaseRequest = createAction('ADD_STATIC_LEASE_REQUEST'); +export const addStaticLeaseFailure = createAction('ADD_STATIC_LEASE_FAILURE'); +export const addStaticLeaseSuccess = createAction('ADD_STATIC_LEASE_SUCCESS'); + +export const addStaticLease = config => async (dispatch) => { + dispatch(addStaticLeaseRequest()); + try { + const name = config.hostname || config.ip; + await apiClient.addStaticLease(config); + dispatch(addStaticLeaseSuccess(config)); + dispatch(addSuccessToast(t('dhcp_lease_added', { key: name }))); + dispatch(toggleLeaseModal()); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(addStaticLeaseFailure()); + } +}; + +export const removeStaticLeaseRequest = createAction('REMOVE_STATIC_LEASE_REQUEST'); +export const removeStaticLeaseFailure = createAction('REMOVE_STATIC_LEASE_FAILURE'); +export const removeStaticLeaseSuccess = createAction('REMOVE_STATIC_LEASE_SUCCESS'); + +export const removeStaticLease = config => async (dispatch) => { + dispatch(removeStaticLeaseRequest()); + try { + const name = config.hostname || config.ip; + await apiClient.removeStaticLease(config); + dispatch(removeStaticLeaseSuccess(config)); + dispatch(addSuccessToast(t('dhcp_lease_deleted', { key: name }))); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(removeStaticLeaseFailure()); } }; diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 8f34f201..81bce7cf 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -318,6 +318,8 @@ export default class Api { DHCP_SET_CONFIG = { path: 'dhcp/set_config', method: 'POST' }; DHCP_FIND_ACTIVE = { path: 'dhcp/find_active_dhcp', method: 'POST' }; DHCP_INTERFACES = { path: 'dhcp/interfaces', method: 'GET' }; + DHCP_ADD_STATIC_LEASE = { path: 'dhcp/add_static_lease', method: 'POST' }; + DHCP_REMOVE_STATIC_LEASE = { path: 'dhcp/remove_static_lease', method: 'POST' }; getDhcpStatus() { const { path, method } = this.DHCP_STATUS; @@ -347,6 +349,24 @@ export default class Api { return this.makeRequest(path, method, parameters); } + addStaticLease(config) { + const { path, method } = this.DHCP_ADD_STATIC_LEASE; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + + removeStaticLease(config) { + const { path, method } = this.DHCP_REMOVE_STATIC_LEASE; + const parameters = { + data: config, + headers: { 'Content-Type': 'application/json' }, + }; + return this.makeRequest(path, method, parameters); + } + // Installation INSTALL_GET_ADDRESSES = { path: 'install/get_addresses', method: 'GET' }; INSTALL_CONFIGURE = { path: 'install/configure', method: 'POST' }; diff --git a/client/src/components/Settings/Dhcp/Form.js b/client/src/components/Settings/Dhcp/Form.js index af7c1931..86de6340 100644 --- a/client/src/components/Settings/Dhcp/Form.js +++ b/client/src/components/Settings/Dhcp/Form.js @@ -1,22 +1,97 @@ import React from 'react'; +import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { Field, reduxForm } from 'redux-form'; -import { withNamespaces } from 'react-i18next'; +import { Field, reduxForm, formValueSelector } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; import flow from 'lodash/flow'; import { renderField, required, ipv4, isPositive, toNumber } from '../../../helpers/form'; -const Form = (props) => { +const renderInterfaces = (interfaces => ( + Object.keys(interfaces).map((item) => { + const option = interfaces[item]; + const { name } = option; + const onlyIPv6 = option.ip_addresses.every(ip => ip.includes(':')); + let interfaceIP = option.ip_addresses[0]; + + if (!onlyIPv6) { + option.ip_addresses.forEach((ip) => { + if (!ip.includes(':')) { + interfaceIP = ip; + } + }); + } + + return ( + + ); + }) +)); + +const renderInterfaceValues = (interfaceValues => ( + +)); + +let Form = (props) => { const { t, handleSubmit, submitting, invalid, + enabled, + interfaces, + interfaceValue, processingConfig, + processingInterfaces, } = props; return (
+ {!processingInterfaces && interfaces && +
+
+
+ + + + {renderInterfaces(interfaces)} + +
+
+ {interfaceValue && +
+ {interfaces[interfaceValue] && + renderInterfaceValues(interfaces[interfaceValue])} +
+ } +
+ } +
@@ -101,11 +176,24 @@ Form.propTypes = { submitting: PropTypes.bool, invalid: PropTypes.bool, interfaces: PropTypes.object, + interfaceValue: PropTypes.string, initialValues: PropTypes.object, processingConfig: PropTypes.bool, + processingInterfaces: PropTypes.bool, + enabled: PropTypes.bool, t: PropTypes.func, }; + +const selector = formValueSelector('dhcpForm'); + +Form = connect((state) => { + const interfaceValue = selector(state, 'interface_name'); + return { + interfaceValue, + }; +})(Form); + export default flow([ withNamespaces(), reduxForm({ form: 'dhcpForm' }), diff --git a/client/src/components/Settings/Dhcp/Interface.js b/client/src/components/Settings/Dhcp/Interface.js deleted file mode 100644 index 3b9d3e03..00000000 --- a/client/src/components/Settings/Dhcp/Interface.js +++ /dev/null @@ -1,114 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import { Field, reduxForm, formValueSelector } from 'redux-form'; -import { withNamespaces, Trans } from 'react-i18next'; -import flow from 'lodash/flow'; - -const renderInterfaces = (interfaces => ( - Object.keys(interfaces).map((item) => { - const option = interfaces[item]; - const { name } = option; - const onlyIPv6 = option.ip_addresses.every(ip => ip.includes(':')); - let interfaceIP = option.ip_addresses[0]; - - if (!onlyIPv6) { - option.ip_addresses.forEach((ip) => { - if (!ip.includes(':')) { - interfaceIP = ip; - } - }); - } - - return ( - - ); - }) -)); - -const renderInterfaceValues = (interfaceValues => ( -
    -
  • - MTU: - {interfaceValues.mtu} -
  • -
  • - dhcp_hardware_address: - {interfaceValues.hardware_address} -
  • -
  • - dhcp_ip_addresses: - { - interfaceValues.ip_addresses - .map(ip => {ip}) - } -
  • -
-)); - -let Interface = (props) => { - const { - t, - handleChange, - interfaces, - processing, - interfaceValue, - enabled, - } = props; - - return ( - - {!processing && interfaces && -
-
-
- - - - {renderInterfaces(interfaces)} - -
-
- {interfaceValue && -
- {interfaces[interfaceValue] && - renderInterfaceValues(interfaces[interfaceValue])} -
- } -
- } -
- - ); -}; - -Interface.propTypes = { - handleChange: PropTypes.func, - interfaces: PropTypes.object, - processing: PropTypes.bool, - interfaceValue: PropTypes.string, - initialValues: PropTypes.object, - enabled: PropTypes.bool, - t: PropTypes.func, -}; - -const selector = formValueSelector('dhcpInterface'); - -Interface = connect((state) => { - const interfaceValue = selector(state, 'interface_name'); - return { - interfaceValue, - }; -})(Interface); - -export default flow([ - withNamespaces(), - reduxForm({ form: 'dhcpInterface' }), -])(Interface); diff --git a/client/src/components/Settings/Dhcp/StaticLeases/Form.js b/client/src/components/Settings/Dhcp/StaticLeases/Form.js new file mode 100644 index 00000000..6695a6b3 --- /dev/null +++ b/client/src/components/Settings/Dhcp/StaticLeases/Form.js @@ -0,0 +1,96 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Field, reduxForm } from 'redux-form'; +import { Trans, withNamespaces } from 'react-i18next'; +import flow from 'lodash/flow'; + +import { renderField, ipv4, mac, required } from '../../../../helpers/form'; + +const Form = (props) => { + const { + t, + handleSubmit, + reset, + pristine, + submitting, + toggleLeaseModal, + processingAdding, + } = props; + + return ( +
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+ ); +}; + +Form.propTypes = { + pristine: PropTypes.bool.isRequired, + handleSubmit: PropTypes.func.isRequired, + reset: PropTypes.func.isRequired, + submitting: PropTypes.bool.isRequired, + toggleLeaseModal: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, + t: PropTypes.func.isRequired, +}; + +export default flow([ + withNamespaces(), + reduxForm({ form: 'leaseForm' }), +])(Form); diff --git a/client/src/components/Settings/Dhcp/StaticLeases/Modal.js b/client/src/components/Settings/Dhcp/StaticLeases/Modal.js new file mode 100644 index 00000000..6291f274 --- /dev/null +++ b/client/src/components/Settings/Dhcp/StaticLeases/Modal.js @@ -0,0 +1,49 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Trans, withNamespaces } from 'react-i18next'; +import ReactModal from 'react-modal'; + +import Form from './Form'; + +const Modal = (props) => { + const { + isModalOpen, + handleSubmit, + toggleLeaseModal, + processingAdding, + } = props; + + return ( + toggleLeaseModal()} + > +
+
+

+ dhcp_new_static_lease +

+ +
+
+
+
+ ); +}; + +Modal.propTypes = { + isModalOpen: PropTypes.bool.isRequired, + handleSubmit: PropTypes.func.isRequired, + toggleLeaseModal: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, +}; + +export default withNamespaces()(Modal); diff --git a/client/src/components/Settings/Dhcp/StaticLeases/index.js b/client/src/components/Settings/Dhcp/StaticLeases/index.js new file mode 100644 index 00000000..e96e806e --- /dev/null +++ b/client/src/components/Settings/Dhcp/StaticLeases/index.js @@ -0,0 +1,112 @@ +import React, { Component, Fragment } from 'react'; +import PropTypes from 'prop-types'; +import ReactTable from 'react-table'; +import { Trans, withNamespaces } from 'react-i18next'; + +import Modal from './Modal'; + +class StaticLeases extends Component { + cellWrap = ({ value }) => ( +
+ + {value} + +
+ ); + + handleSubmit = (data) => { + this.props.addStaticLease(data); + } + + handleDelete = (ip, mac, hostname = '') => { + const name = hostname || ip; + // eslint-disable-next-line no-alert + if (window.confirm(this.props.t('delete_confirm', { key: name }))) { + this.props.removeStaticLease({ ip, mac, hostname }); + } + } + + render() { + const { + isModalOpen, + toggleLeaseModal, + processingAdding, + processingDeleting, + staticLeases, + t, + } = this.props; + return ( + + dhcp_table_hostname, + accessor: 'hostname', + Cell: this.cellWrap, + }, + { + Header: actions_table_header, + accessor: 'actions', + maxWidth: 150, + Cell: (row) => { + const { ip, mac, hostname } = row.original; + + return ( +
+ +
+ ); + }, + }, + ]} + showPagination={false} + noDataText={t('dhcp_static_leases_not_found')} + className="-striped -highlight card-table-overflow" + minRows={6} + /> + +
+ ); + } +} + +StaticLeases.propTypes = { + staticLeases: PropTypes.array.isRequired, + isModalOpen: PropTypes.bool.isRequired, + toggleLeaseModal: PropTypes.func.isRequired, + removeStaticLease: PropTypes.func.isRequired, + addStaticLease: PropTypes.func.isRequired, + processingAdding: PropTypes.bool.isRequired, + processingDeleting: PropTypes.bool.isRequired, + t: PropTypes.func.isRequired, +}; + +export default withNamespaces()(StaticLeases); diff --git a/client/src/components/Settings/Dhcp/index.js b/client/src/components/Settings/Dhcp/index.js index 437f9265..d33f1bcf 100644 --- a/client/src/components/Settings/Dhcp/index.js +++ b/client/src/components/Settings/Dhcp/index.js @@ -6,13 +6,15 @@ import { Trans, withNamespaces } from 'react-i18next'; import { DHCP_STATUS_RESPONSE } from '../../../helpers/constants'; import Form from './Form'; import Leases from './Leases'; -import Interface from './Interface'; +import StaticLeases from './StaticLeases/index'; import Card from '../../ui/Card'; import Accordion from '../../ui/Accordion'; class Dhcp extends Component { handleFormSubmit = (values) => { - this.props.setDhcpConfig(values); + if (values.interface_name) { + this.props.setDhcpConfig(values); + } }; handleToggle = (config) => { @@ -168,18 +170,16 @@ class Dhcp extends Component {
{!dhcp.processing && -
@@ -188,11 +188,11 @@ class Dhcp extends Component { type="button" className={statusButtonClass} onClick={() => - this.props.findActiveDhcp(dhcp.config.interface_name) + this.props.findActiveDhcp(interface_name) } disabled={ - dhcp.config.enabled - || !dhcp.config.interface_name + enabled + || !interface_name || dhcp.processingConfig } > @@ -211,13 +211,39 @@ class Dhcp extends Component {
{!dhcp.processing && dhcp.config.enabled && - -
-
- + + +
+
+ +
-
- + + +
+
+ +
+
+ +
+
+
+ } ); @@ -231,6 +257,9 @@ Dhcp.propTypes = { setDhcpConfig: PropTypes.func, findActiveDhcp: PropTypes.func, handleSubmit: PropTypes.func, + addStaticLease: PropTypes.func, + removeStaticLease: PropTypes.func, + toggleLeaseModal: PropTypes.func, t: PropTypes.func, }; diff --git a/client/src/components/Settings/index.js b/client/src/components/Settings/index.js index 3435e610..8d36c6c4 100644 --- a/client/src/components/Settings/index.js +++ b/client/src/components/Settings/index.js @@ -128,6 +128,9 @@ class Settings extends Component { getDhcpStatus={this.props.getDhcpStatus} findActiveDhcp={this.props.findActiveDhcp} setDhcpConfig={this.props.setDhcpConfig} + addStaticLease={this.props.addStaticLease} + removeStaticLease={this.props.removeStaticLease} + toggleLeaseModal={this.props.toggleLeaseModal} />
diff --git a/client/src/containers/Settings.js b/client/src/containers/Settings.js index 95be768b..0255e044 100644 --- a/client/src/containers/Settings.js +++ b/client/src/containers/Settings.js @@ -11,6 +11,9 @@ import { getDhcpInterfaces, setDhcpConfig, findActiveDhcp, + addStaticLease, + removeStaticLease, + toggleLeaseModal, } from '../actions'; import { getTlsStatus, @@ -62,6 +65,9 @@ const mapDispatchToProps = { updateClient, deleteClient, toggleClientModal, + addStaticLease, + removeStaticLease, + toggleLeaseModal, }; export default connect( diff --git a/client/src/reducers/index.js b/client/src/reducers/index.js index 0a3b8171..e9a012f8 100644 --- a/client/src/reducers/index.js +++ b/client/src/reducers/index.js @@ -287,11 +287,18 @@ const dhcp = handleActions({ [actions.getDhcpStatusRequest]: state => ({ ...state, processing: true }), [actions.getDhcpStatusFailure]: state => ({ ...state, processing: false }), [actions.getDhcpStatusSuccess]: (state, { payload }) => { + const { + static_leases: staticLeases, + ...values + } = payload; + const newState = { ...state, - ...payload, + staticLeases, processing: false, + ...values, }; + return newState; }, @@ -344,17 +351,62 @@ const dhcp = handleActions({ const newState = { ...state, config: newConfig, processingConfig: false }; return newState; }, + + [actions.toggleLeaseModal]: (state) => { + const newState = { + ...state, + isModalOpen: !state.isModalOpen, + }; + return newState; + }, + + [actions.addStaticLeaseRequest]: state => ({ ...state, processingAdding: true }), + [actions.addStaticLeaseFailure]: state => ({ ...state, processingAdding: false }), + [actions.addStaticLeaseSuccess]: (state, { payload }) => { + const { + ip, mac, hostname, + } = payload; + const newLease = { + ip, + mac, + hostname: hostname || '', + }; + const leases = [...state.staticLeases, newLease]; + const newState = { + ...state, + staticLeases: leases, + processingAdding: false, + }; + return newState; + }, + + [actions.removeStaticLeaseRequest]: state => ({ ...state, processingDeleting: true }), + [actions.removeStaticLeaseFailure]: state => ({ ...state, processingDeleting: false }), + [actions.removeStaticLeaseSuccess]: (state, { payload }) => { + const leaseToRemove = payload.ip; + const leases = state.staticLeases.filter(item => item.ip !== leaseToRemove); + const newState = { + ...state, + staticLeases: leases, + processingDeleting: false, + }; + return newState; + }, }, { processing: true, processingStatus: false, processingInterfaces: false, processingDhcp: false, processingConfig: false, + processingAdding: false, + processingDeleting: false, config: { enabled: false, }, check: null, leases: [], + staticLeases: [], + isModalOpen: false, }); export default combineReducers({ diff --git a/control.go b/control.go index 2be9b8ba..77970de2 100644 --- a/control.go +++ b/control.go @@ -993,6 +993,8 @@ func registerControlHandlers() { http.HandleFunc("/control/dhcp/interfaces", postInstall(optionalAuth(ensureGET(handleDHCPInterfaces)))) http.HandleFunc("/control/dhcp/set_config", postInstall(optionalAuth(ensurePOST(handleDHCPSetConfig)))) http.HandleFunc("/control/dhcp/find_active_dhcp", postInstall(optionalAuth(ensurePOST(handleDHCPFindActiveServer)))) + http.HandleFunc("/control/dhcp/add_static_lease", postInstall(optionalAuth(ensurePOST(handleDHCPAddStaticLease)))) + http.HandleFunc("/control/dhcp/remove_static_lease", postInstall(optionalAuth(ensurePOST(handleDHCPRemoveStaticLease)))) RegisterTLSHandlers() RegisterClientsHandlers() diff --git a/dhcp.go b/dhcp.go index 4bd0c463..8c966ad6 100644 --- a/dhcp.go +++ b/dhcp.go @@ -20,23 +20,33 @@ import ( var dhcpServer = dhcpd.Server{} +// []dhcpd.Lease -> JSON +func convertLeases(inputLeases []dhcpd.Lease, includeExpires bool) []map[string]string { + leases := []map[string]string{} + for _, l := range inputLeases { + lease := map[string]string{ + "mac": l.HWAddr.String(), + "ip": l.IP.String(), + "hostname": l.Hostname, + } + + if includeExpires { + lease["expires"] = l.Expiry.Format(time.RFC3339) + } + + leases = append(leases, lease) + } + return leases +} + func handleDHCPStatus(w http.ResponseWriter, r *http.Request) { log.Tracef("%s %v", r.Method, r.URL) - rawLeases := dhcpServer.Leases() - leases := []map[string]string{} - for i := range rawLeases { - lease := map[string]string{ - "mac": rawLeases[i].HWAddr.String(), - "ip": rawLeases[i].IP.String(), - "hostname": rawLeases[i].Hostname, - "expires": rawLeases[i].Expiry.Format(time.RFC3339), - } - leases = append(leases, lease) - - } + leases := convertLeases(dhcpServer.Leases(), true) + staticLeases := convertLeases(dhcpServer.StaticLeases(), false) status := map[string]interface{}{ - "config": config.DHCP, - "leases": leases, + "config": config.DHCP, + "leases": leases, + "static_leases": staticLeases, } w.Header().Set("Content-Type", "application/json") @@ -47,20 +57,43 @@ func handleDHCPStatus(w http.ResponseWriter, r *http.Request) { } } +type leaseJSON struct { + HWAddr string `json:"mac"` + IP string `json:"ip"` + Hostname string `json:"hostname"` +} + +type dhcpServerConfigJSON struct { + dhcpd.ServerConfig `json:",inline"` + StaticLeases []leaseJSON `json:"static_leases"` +} + func handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) { log.Tracef("%s %v", r.Method, r.URL) - newconfig := dhcpd.ServerConfig{} + newconfig := dhcpServerConfigJSON{} err := json.NewDecoder(r.Body).Decode(&newconfig) if err != nil { httpError(w, http.StatusBadRequest, "Failed to parse new DHCP config json: %s", err) return } + err = dhcpServer.CheckConfig(newconfig.ServerConfig) + if err != nil { + httpError(w, http.StatusBadRequest, "Invalid DHCP configuration: %s", err) + return + } + err = dhcpServer.Stop() if err != nil { log.Error("failed to stop the DHCP server: %s", err) } + err = dhcpServer.Init(newconfig.ServerConfig) + if err != nil { + httpError(w, http.StatusBadRequest, "Invalid DHCP configuration: %s", err) + return + } + if newconfig.Enabled { staticIP, err := hasStaticIP(newconfig.InterfaceName) @@ -72,14 +105,14 @@ func handleDHCPSetConfig(w http.ResponseWriter, r *http.Request) { } } - err = dhcpServer.Start(&newconfig) + err = dhcpServer.Start() if err != nil { httpError(w, http.StatusBadRequest, "Failed to start DHCP server: %s", err) return } } - config.DHCP = newconfig + config.DHCP = newconfig.ServerConfig httpUpdateConfigReloadDNSReturnOK(w, r) } @@ -333,12 +366,80 @@ func setStaticIP(ifaceName string) error { return nil } +func handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + + lj := leaseJSON{} + err := json.NewDecoder(r.Body).Decode(&lj) + if err != nil { + httpError(w, http.StatusBadRequest, "json.Decode: %s", err) + return + } + + ip := parseIPv4(lj.IP) + if ip == nil { + httpError(w, http.StatusBadRequest, "invalid IP") + return + } + + mac, _ := net.ParseMAC(lj.HWAddr) + + lease := dhcpd.Lease{ + IP: ip, + HWAddr: mac, + Hostname: lj.Hostname, + } + err = dhcpServer.AddStaticLease(lease) + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + returnOK(w) +} + +func handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Request) { + log.Tracef("%s %v", r.Method, r.URL) + + lj := leaseJSON{} + err := json.NewDecoder(r.Body).Decode(&lj) + if err != nil { + httpError(w, http.StatusBadRequest, "json.Decode: %s", err) + return + } + + ip := parseIPv4(lj.IP) + if ip == nil { + httpError(w, http.StatusBadRequest, "invalid IP") + return + } + + mac, _ := net.ParseMAC(lj.HWAddr) + + lease := dhcpd.Lease{ + IP: ip, + HWAddr: mac, + Hostname: lj.Hostname, + } + err = dhcpServer.RemoveStaticLease(lease) + if err != nil { + httpError(w, http.StatusBadRequest, "%s", err) + return + } + returnOK(w) +} + func startDHCPServer() error { if !config.DHCP.Enabled { // not enabled, don't do anything return nil } - err := dhcpServer.Start(&config.DHCP) + + err := dhcpServer.Init(config.DHCP) + if err != nil { + return errorx.Decorate(err, "Couldn't init DHCP server") + } + + err = dhcpServer.Start() if err != nil { return errorx.Decorate(err, "Couldn't start DHCP server") } @@ -350,10 +451,6 @@ func stopDHCPServer() error { return nil } - if !dhcpServer.Enabled { - return nil - } - err := dhcpServer.Stop() if err != nil { return errorx.Decorate(err, "Couldn't stop DHCP server") diff --git a/dhcpd/db.go b/dhcpd/db.go index 1caa7d81..c27bb679 100644 --- a/dhcpd/db.go +++ b/dhcpd/db.go @@ -23,8 +23,20 @@ type leaseJSON struct { Expiry int64 `json:"exp"` } +// Safe version of dhcp4.IPInRange() +func ipInRange(start, stop, ip net.IP) bool { + if len(start) != len(stop) || + len(start) != len(ip) { + return false + } + return dhcp4.IPInRange(start, stop, ip) +} + // Load lease table from DB func (s *Server) dbLoad() { + s.leases = nil + s.IPpool = make(map[[4]byte]net.HardwareAddr) + data, err := ioutil.ReadFile(dbFilename) if err != nil { if !os.IsNotExist(err) { @@ -40,13 +52,12 @@ func (s *Server) dbLoad() { return } - s.leases = nil - s.IPpool = make(map[[4]byte]net.HardwareAddr) - numLeases := len(obj) for i := range obj { - if !dhcp4.IPInRange(s.leaseStart, s.leaseStop, obj[i].IP) { + if obj[i].Expiry != leaseExpireStatic && + !ipInRange(s.leaseStart, s.leaseStop, obj[i].IP) { + log.Tracef("Skipping a lease with IP %s: not within current IP range", obj[i].IP) continue } diff --git a/dhcpd/dhcpd.go b/dhcpd/dhcpd.go index fd3e807a..13089f7d 100644 --- a/dhcpd/dhcpd.go +++ b/dhcpd/dhcpd.go @@ -14,6 +14,7 @@ import ( ) const defaultDiscoverTime = time.Second * 3 +const leaseExpireStatic = 1 // Lease contains the necessary information about a DHCP lease // field ordering is important -- yaml fields will mirror ordering from here @@ -21,7 +22,10 @@ type Lease struct { HWAddr net.HardwareAddr `json:"mac" yaml:"hwaddr"` IP net.IP `json:"ip"` Hostname string `json:"hostname"` - Expiry time.Time `json:"expires"` + + // Lease expiration time + // 1: static lease + Expiry time.Time `json:"expires"` } // ServerConfig - DHCP server configuration @@ -53,6 +57,7 @@ type Server struct { // leases leases []*Lease + leasesLock sync.RWMutex leaseStart net.IP // parsed from config RangeStart leaseStop net.IP // parsed from config RangeEnd leaseTime time.Duration // parsed from config LeaseDuration @@ -61,8 +66,7 @@ type Server struct { // IP address pool -- if entry is in the pool, then it's attached to a lease IPpool map[[4]byte]net.HardwareAddr - ServerConfig - sync.RWMutex + conf ServerConfig } // Print information about the available network interfaces @@ -75,62 +79,65 @@ func printInterfaces() { log.Info("Available network interfaces: %s", buf.String()) } -// Start will listen on port 67 and serve DHCP requests. -// Even though config can be nil, it is not optional (at least for now), since there are no default values (yet). -func (s *Server) Start(config *ServerConfig) error { - if config != nil { - s.ServerConfig = *config - } +// CheckConfig checks the configuration +func (s *Server) CheckConfig(config ServerConfig) error { + tmpServer := Server{} + return tmpServer.setConfig(config) +} - iface, err := net.InterfaceByName(s.InterfaceName) +// Init checks the configuration and initializes the server +func (s *Server) Init(config ServerConfig) error { + err := s.setConfig(config) + if err != nil { + return err + } + s.dbLoad() + return nil +} + +func (s *Server) setConfig(config ServerConfig) error { + s.conf = config + + iface, err := net.InterfaceByName(config.InterfaceName) if err != nil { - s.closeConn() // in case it was already started printInterfaces() - return wrapErrPrint(err, "Couldn't find interface by name %s", s.InterfaceName) + return wrapErrPrint(err, "Couldn't find interface by name %s", config.InterfaceName) } // get ipv4 address of an interface s.ipnet = getIfaceIPv4(iface) if s.ipnet == nil { - s.closeConn() // in case it was already started - return wrapErrPrint(err, "Couldn't find IPv4 address of interface %s %+v", s.InterfaceName, iface) + return wrapErrPrint(err, "Couldn't find IPv4 address of interface %s %+v", config.InterfaceName, iface) } - if s.LeaseDuration == 0 { + if config.LeaseDuration == 0 { s.leaseTime = time.Hour * 2 - s.LeaseDuration = uint(s.leaseTime.Seconds()) } else { - s.leaseTime = time.Second * time.Duration(s.LeaseDuration) + s.leaseTime = time.Second * time.Duration(config.LeaseDuration) } - s.leaseStart, err = parseIPv4(s.RangeStart) + s.leaseStart, err = parseIPv4(config.RangeStart) if err != nil { - - s.closeConn() // in case it was already started - return wrapErrPrint(err, "Failed to parse range start address %s", s.RangeStart) + return wrapErrPrint(err, "Failed to parse range start address %s", config.RangeStart) } - s.leaseStop, err = parseIPv4(s.RangeEnd) + s.leaseStop, err = parseIPv4(config.RangeEnd) if err != nil { - s.closeConn() // in case it was already started - return wrapErrPrint(err, "Failed to parse range end address %s", s.RangeEnd) + return wrapErrPrint(err, "Failed to parse range end address %s", config.RangeEnd) } - subnet, err := parseIPv4(s.SubnetMask) + subnet, err := parseIPv4(config.SubnetMask) if err != nil { - s.closeConn() // in case it was already started - return wrapErrPrint(err, "Failed to parse subnet mask %s", s.SubnetMask) + return wrapErrPrint(err, "Failed to parse subnet mask %s", config.SubnetMask) } // if !bytes.Equal(subnet, s.ipnet.Mask) { - // s.closeConn() // in case it was already started // return wrapErrPrint(err, "specified subnet mask %s does not meatch interface %s subnet mask %s", s.SubnetMask, s.InterfaceName, s.ipnet.Mask) // } - router, err := parseIPv4(s.GatewayIP) + router, err := parseIPv4(config.GatewayIP) if err != nil { - s.closeConn() // in case it was already started - return wrapErrPrint(err, "Failed to parse gateway IP %s", s.GatewayIP) + return wrapErrPrint(err, "Failed to parse gateway IP %s", config.GatewayIP) } s.leaseOptions = dhcp4.Options{ @@ -139,12 +146,21 @@ func (s *Server) Start(config *ServerConfig) error { dhcp4.OptionDomainNameServer: s.ipnet.IP, } + return nil +} + +// Start will listen on port 67 and serve DHCP requests. +func (s *Server) Start() error { + // TODO: don't close if interface and addresses are the same if s.conn != nil { s.closeConn() } - s.dbLoad() + iface, err := net.InterfaceByName(s.conf.InterfaceName) + if err != nil { + return wrapErrPrint(err, "Couldn't find interface by name %s", s.conf.InterfaceName) + } c, err := newFilterConn(*iface, ":67") // it has to be bound to 0.0.0.0:67, otherwise it won't see DHCP discover/request packets if err != nil { @@ -229,9 +245,9 @@ func (s *Server) reserveLease(p dhcp4.Packet) (*Lease, error) { log.Tracef("Assigning IP address %s to %s (lease for %s expired at %s)", s.leases[i].IP, hwaddr, s.leases[i].HWAddr, s.leases[i].Expiry) lease.IP = s.leases[i].IP - s.Lock() + s.leasesLock.Lock() s.leases[i] = lease - s.Unlock() + s.leasesLock.Unlock() s.reserveIP(lease.IP, hwaddr) return lease, nil @@ -239,9 +255,9 @@ func (s *Server) reserveLease(p dhcp4.Packet) (*Lease, error) { log.Tracef("Assigning to %s IP address %s", hwaddr, ip.String()) lease.IP = ip - s.Lock() + s.leasesLock.Lock() s.leases = append(s.leases, lease) - s.Unlock() + s.leasesLock.Unlock() return lease, nil } @@ -261,7 +277,7 @@ func (s *Server) findLease(p dhcp4.Packet) *Lease { func (s *Server) findExpiredLease() int { now := time.Now().Unix() for i, lease := range s.leases { - if lease.Expiry.Unix() <= now { + if lease.Expiry.Unix() <= now && lease.Expiry.Unix() != leaseExpireStatic { return i } } @@ -269,11 +285,6 @@ func (s *Server) findExpiredLease() int { } func (s *Server) findFreeIP(hwaddr net.HardwareAddr) (net.IP, error) { - // if IP pool is nil, lazy initialize it - if s.IPpool == nil { - s.IPpool = make(map[[4]byte]net.HardwareAddr) - } - // go from start to end, find unreserved IP var foundIP net.IP for i := 0; i < dhcp4.IPRange(s.leaseStart, s.leaseStop); i++ { @@ -361,7 +372,7 @@ func (s *Server) ServeDHCP(p dhcp4.Packet, msgType dhcp4.MessageType, options dh // Return TRUE if it doesn't reply, which probably means that the IP is available func (s *Server) addrAvailable(target net.IP) bool { - if s.ICMPTimeout == 0 { + if s.conf.ICMPTimeout == 0 { return true } @@ -372,7 +383,7 @@ func (s *Server) addrAvailable(target net.IP) bool { } pinger.SetPrivileged(true) - pinger.Timeout = time.Duration(s.ICMPTimeout) * time.Millisecond + pinger.Timeout = time.Duration(s.conf.ICMPTimeout) * time.Millisecond pinger.Count = 1 reply := false pinger.OnRecv = func(pkt *ping.Packet) { @@ -395,11 +406,11 @@ func (s *Server) addrAvailable(target net.IP) bool { func (s *Server) blacklistLease(lease *Lease) { hw := make(net.HardwareAddr, 6) s.reserveIP(lease.IP, hw) - s.Lock() + s.leasesLock.Lock() lease.HWAddr = hw lease.Hostname = "" lease.Expiry = time.Now().Add(s.leaseTime) - s.Unlock() + s.leasesLock.Unlock() } // Return TRUE if DHCP packet is correct @@ -516,21 +527,103 @@ func (s *Server) handleDecline(p dhcp4.Packet, options dhcp4.Options) dhcp4.Pack return nil } +// AddStaticLease adds a static lease (thread-safe) +func (s *Server) AddStaticLease(l Lease) error { + if s.IPpool == nil { + return fmt.Errorf("DHCP server isn't started") + } + + if len(l.IP) != 4 { + return fmt.Errorf("Invalid IP") + } + if len(l.HWAddr) != 6 { + return fmt.Errorf("Invalid MAC") + } + l.Expiry = time.Unix(leaseExpireStatic, 0) + + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + if s.findReservedHWaddr(l.IP) != nil { + return fmt.Errorf("IP is already used") + } + s.leases = append(s.leases, &l) + s.reserveIP(l.IP, l.HWAddr) + s.dbStore() + return nil +} + +// RemoveStaticLease removes a static lease (thread-safe) +func (s *Server) RemoveStaticLease(l Lease) error { + if s.IPpool == nil { + return fmt.Errorf("DHCP server isn't started") + } + + if len(l.IP) != 4 { + return fmt.Errorf("Invalid IP") + } + if len(l.HWAddr) != 6 { + return fmt.Errorf("Invalid MAC") + } + + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + if s.findReservedHWaddr(l.IP) == nil { + return fmt.Errorf("Lease not found") + } + + var newLeases []*Lease + for _, lease := range s.leases { + if bytes.Equal(lease.IP.To4(), l.IP) { + if !bytes.Equal(lease.HWAddr, l.HWAddr) || + lease.Hostname != l.Hostname { + return fmt.Errorf("Lease not found") + } + continue + } + newLeases = append(newLeases, lease) + } + s.leases = newLeases + s.unreserveIP(l.IP) + s.dbStore() + return nil +} + // Leases returns the list of current DHCP leases (thread-safe) func (s *Server) Leases() []Lease { var result []Lease now := time.Now().Unix() - s.RLock() + s.leasesLock.RLock() for _, lease := range s.leases { if lease.Expiry.Unix() > now { result = append(result, *lease) } } - s.RUnlock() + s.leasesLock.RUnlock() return result } +// StaticLeases returns the list of statically-configured DHCP leases (thread-safe) +func (s *Server) StaticLeases() []Lease { + s.leasesLock.Lock() + if s.IPpool == nil { + s.dbLoad() + } + s.leasesLock.Unlock() + + var result []Lease + s.leasesLock.RLock() + for _, lease := range s.leases { + if lease.Expiry.Unix() == 1 { + result = append(result, *lease) + } + } + s.leasesLock.RUnlock() + return result +} + // Print information about the current leases func (s *Server) printLeases() { log.Tracef("Leases:") @@ -543,8 +636,8 @@ func (s *Server) printLeases() { // FindIPbyMAC finds an IP address by MAC address in the currently active DHCP leases func (s *Server) FindIPbyMAC(mac net.HardwareAddr) net.IP { now := time.Now().Unix() - s.RLock() - defer s.RUnlock() + s.leasesLock.RLock() + defer s.leasesLock.RUnlock() for _, l := range s.leases { if l.Expiry.Unix() > now && bytes.Equal(mac, l.HWAddr) { return l.IP @@ -555,8 +648,8 @@ func (s *Server) FindIPbyMAC(mac net.HardwareAddr) net.IP { // Reset internal state func (s *Server) reset() { - s.Lock() + s.leasesLock.Lock() s.leases = nil - s.Unlock() + s.leasesLock.Unlock() s.IPpool = make(map[[4]byte]net.HardwareAddr) } diff --git a/helpers.go b/helpers.go index 6f35caba..4d4d0b3f 100644 --- a/helpers.go +++ b/helpers.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "bytes" "context" "errors" "fmt" @@ -387,3 +388,18 @@ func _Func() string { f := runtime.FuncForPC(pc[0]) return path.Base(f.Name()) } + +// Parse input string and return IPv4 address +func parseIPv4(s string) net.IP { + ip := net.ParseIP(s) + if ip == nil { + return nil + } + + v4InV6Prefix := []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff} + if !bytes.Equal(ip[0:12], v4InV6Prefix) { + return nil + } + + return ip.To4() +} diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 83856cbe..318849b0 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -387,6 +387,42 @@ paths: schema: $ref: "#/definitions/DhcpSearchResult" + /dhcp/add_static_lease: + post: + tags: + - dhcp + operationId: dhcpAddStaticLease + summary: "Adds a static lease" + consumes: + - application/json + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/DhcpStaticLease" + responses: + 200: + description: OK + + /dhcp/remove_static_lease: + post: + tags: + - dhcp + operationId: dhcpRemoveStaticLease + summary: "Removes a static lease" + consumes: + - application/json + parameters: + - in: "body" + name: "body" + required: true + schema: + $ref: "#/definitions/DhcpStaticLease" + responses: + 200: + description: OK + # -------------------------------------------------- # Filtering status methods # -------------------------------------------------- @@ -1152,7 +1188,7 @@ definitions: properties: mac: type: "string" - example: "001109b3b3b8" + example: "00:11:09:b3:b3:b8" ip: type: "string" example: "192.168.1.22" @@ -1163,6 +1199,24 @@ definitions: type: "string" format: "date-time" example: "2017-07-21T17:32:28Z" + DhcpStaticLease: + type: "object" + description: "DHCP static lease information" + required: + - "mac" + - "ip" + - "hostname" + - "expires" + properties: + mac: + type: "string" + example: "00:11:09:b3:b3:b8" + ip: + type: "string" + example: "192.168.1.22" + hostname: + type: "string" + example: "dell" DhcpStatus: type: "object" description: "Built-in DHCP server configuration and status" @@ -1176,6 +1230,10 @@ definitions: type: "array" items: $ref: "#/definitions/DhcpLease" + static_leases : + type: "array" + items: + $ref: "#/definitions/DhcpStaticLease" DhcpSearchResult: type: "object" description: "Information about a DHCP server discovered in the current network"