diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dd9dbf..3cf83ecf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,9 +25,11 @@ NOTE: Add new changes BELOW THIS COMMENT. ### Added +- Ability to edit static leases on *DHCP settings* page ([#1700]). - Ability to specify for how long clients should cache a filtered response, using the *Blocked response TTL* field on the *DNS settings* page ([#4569]). +[#1700]: https://github.com/AdguardTeam/AdGuardHome/issues/1700 [#4569]: https://github.com/AdguardTeam/AdGuardHome/issues/4569 ### Fixed diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json index 7ff6e612..c7def44e 100644 --- a/client/src/__locales/en.json +++ b/client/src/__locales/en.json @@ -73,7 +73,9 @@ "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}}. AdGuard Home will automatically set this IP address as static if you press the \"Enable DHCP server\" button.", "dhcp_lease_added": "Static lease \"{{key}}\" successfully added", "dhcp_lease_deleted": "Static lease \"{{key}}\" successfully deleted", + "dhcp_lease_updated": "Static lease \"{{key}}\" successfully updated", "dhcp_new_static_lease": "New static lease", + "dhcp_edit_static_lease": "Edit static lease", "dhcp_static_leases_not_found": "No DHCP static leases found", "dhcp_add_static_lease": "Add static lease", "dhcp_reset_leases": "Reset all leases", diff --git a/client/src/actions/index.js b/client/src/actions/index.js index d403a77e..c577011f 100644 --- a/client/src/actions/index.js +++ b/client/src/actions/index.js @@ -660,6 +660,24 @@ export const removeStaticLease = (config) => async (dispatch) => { } }; +export const updateStaticLeaseRequest = createAction('UPDATE_STATIC_LEASE_REQUEST'); +export const updateStaticLeaseFailure = createAction('UPDATE_STATIC_LEASE_FAILURE'); +export const updateStaticLeaseSuccess = createAction('UPDATE_STATIC_LEASE_SUCCESS'); + +export const updateStaticLease = (config) => async (dispatch) => { + dispatch(updateStaticLeaseRequest()); + try { + await apiClient.updateStaticLease(config); + dispatch(updateStaticLeaseSuccess(config)); + dispatch(addSuccessToast(i18next.t('dhcp_lease_updated', { key: config.hostname || config.ip }))); + dispatch(toggleLeaseModal()); + dispatch(getDhcpStatus()); + } catch (error) { + dispatch(addErrorToast({ error })); + dispatch(updateStaticLeaseFailure()); + } +}; + export const removeToast = createAction('REMOVE_TOAST'); export const toggleBlocking = ( diff --git a/client/src/api/Api.js b/client/src/api/Api.js index 077c794e..ab3ddc3d 100644 --- a/client/src/api/Api.js +++ b/client/src/api/Api.js @@ -274,6 +274,8 @@ class Api { DHCP_REMOVE_STATIC_LEASE = { path: 'dhcp/remove_static_lease', method: 'POST' }; + DHCP_UPDATE_STATIC_LEASE = { path: 'dhcp/update_static_lease', method: 'POST' }; + DHCP_RESET = { path: 'dhcp/reset', method: 'POST' }; DHCP_LEASES_RESET = { path: 'dhcp/reset_leases', method: 'POST' }; @@ -320,6 +322,14 @@ class Api { return this.makeRequest(path, method, parameters); } + updateStaticLease(config) { + const { path, method } = this.DHCP_UPDATE_STATIC_LEASE; + const parameters = { + data: config, + }; + return this.makeRequest(path, method, parameters); + } + resetDhcp() { const { path, method } = this.DHCP_RESET; return this.makeRequest(path, method); diff --git a/client/src/components/Settings/Dhcp/StaticLeases/Form.js b/client/src/components/Settings/Dhcp/StaticLeases/Form.js index e26b4da5..fd2f8e4f 100644 --- a/client/src/components/Settings/Dhcp/StaticLeases/Form.js +++ b/client/src/components/Settings/Dhcp/StaticLeases/Form.js @@ -22,6 +22,7 @@ const Form = ({ submitting, processingAdding, cidr, + isEdit, }) => { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -45,6 +46,7 @@ const Form = ({ placeholder={t('form_enter_mac')} normalize={normalizeMac} validate={[validateRequiredValue, validateMac]} + disabled={isEdit} />
@@ -112,6 +114,7 @@ Form.propTypes = { submitting: PropTypes.bool.isRequired, processingAdding: PropTypes.bool.isRequired, cidr: PropTypes.string.isRequired, + isEdit: PropTypes.bool, }; export default reduxForm({ form: FORM_NAME.LEASE })(Form); diff --git a/client/src/components/Settings/Dhcp/StaticLeases/Modal.js b/client/src/components/Settings/Dhcp/StaticLeases/Modal.js index 7a11cfce..af9f5984 100644 --- a/client/src/components/Settings/Dhcp/StaticLeases/Modal.js +++ b/client/src/components/Settings/Dhcp/StaticLeases/Modal.js @@ -5,9 +5,11 @@ import ReactModal from 'react-modal'; import { shallowEqual, useDispatch, useSelector } from 'react-redux'; import Form from './Form'; import { toggleLeaseModal } from '../../../../actions'; +import { MODAL_TYPE } from '../../../../helpers/constants'; const Modal = ({ isModalOpen, + modalType, handleSubmit, processingAdding, cidr, @@ -32,7 +34,11 @@ const Modal = ({

- dhcp_new_static_lease + {modalType === MODAL_TYPE.EDIT_LEASE ? ( + dhcp_edit_static_lease + ) : ( + dhcp_new_static_lease + )}

@@ -61,6 +68,7 @@ const Modal = ({ Modal.propTypes = { isModalOpen: PropTypes.bool.isRequired, + modalType: PropTypes.string.isRequired, handleSubmit: PropTypes.func.isRequired, processingAdding: PropTypes.bool.isRequired, cidr: PropTypes.string.isRequired, diff --git a/client/src/components/Settings/Dhcp/StaticLeases/index.js b/client/src/components/Settings/Dhcp/StaticLeases/index.js index 849ca664..d359a9e3 100644 --- a/client/src/components/Settings/Dhcp/StaticLeases/index.js +++ b/client/src/components/Settings/Dhcp/StaticLeases/index.js @@ -3,10 +3,15 @@ import PropTypes from 'prop-types'; import ReactTable from 'react-table'; import { Trans, useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; -import { LEASES_TABLE_DEFAULT_PAGE_SIZE } from '../../../../helpers/constants'; +import { LEASES_TABLE_DEFAULT_PAGE_SIZE, MODAL_TYPE } from '../../../../helpers/constants'; import { sortIp } from '../../../../helpers/helpers'; import Modal from './Modal'; -import { addStaticLease, removeStaticLease } from '../../../../actions'; +import { + addStaticLease, + removeStaticLease, + toggleLeaseModal, + updateStaticLease, +} from '../../../../actions'; const cellWrap = ({ value }) => (
@@ -18,8 +23,10 @@ const cellWrap = ({ value }) => ( const StaticLeases = ({ isModalOpen, + modalType, processingAdding, processingDeleting, + processingUpdating, staticLeases, cidr, rangeStart, @@ -31,7 +38,12 @@ const StaticLeases = ({ const handleSubmit = (data) => { const { mac, ip, hostname } = data; - dispatch(addStaticLease({ mac, ip, hostname })); + + if (modalType === MODAL_TYPE.EDIT_LEASE) { + dispatch(updateStaticLease({ mac, ip, hostname })); + } else { + dispatch(addStaticLease({ mac, ip, hostname })); + } }; const handleDelete = (ip, mac, hostname = '') => { @@ -80,19 +92,35 @@ const StaticLeases = ({ Cell: (row) => { const { ip, mac, hostname } = row.original; - return
- + -
; + disabled={processingDeleting} + title={t('delete_table_action')} + > + + + + +
+ ); }, }, ]} @@ -105,6 +133,7 @@ const StaticLeases = ({ /> { isModalOpen, processingAdding, processingDeleting, + processingUpdating, processingDhcp, v4, v6, @@ -56,6 +57,7 @@ const Dhcp = () => { enabled, dhcp_available, interfaces, + modalType, } = useSelector((state) => state.dhcp, shallowEqual); const interface_name = useSelector( @@ -273,8 +275,11 @@ const Dhcp = () => { ({ ...state, processingUpdating: true }), + [actions.updateStaticLeaseFailure]: (state) => ({ ...state, processingUpdating: false }), + [actions.updateStaticLeaseSuccess]: (state) => { + const newState = { + ...state, + processingUpdating: false, + }; + return newState; + }, }, { processing: true, @@ -184,6 +195,7 @@ const dhcp = handleActions( processingConfig: false, processingAdding: false, processingDeleting: false, + processingUpdating: false, enabled: false, interface_name: '', check: null, @@ -202,6 +214,7 @@ const dhcp = handleActions( staticLeases: [], isModalOpen: false, leaseModalConfig: undefined, + modalType: '', dhcp_available: false, }, ); diff --git a/internal/dhcpd/config.go b/internal/dhcpd/config.go index 0721ecd1..92179a59 100644 --- a/internal/dhcpd/config.go +++ b/internal/dhcpd/config.go @@ -57,6 +57,9 @@ type DHCPServer interface { // RemoveStaticLease - remove a static lease RemoveStaticLease(l *Lease) (err error) + // UpdateStaticLease updates IP, hostname of the lease. + UpdateStaticLease(l *Lease) (err error) + // FindMACbyIP returns a MAC address by the IP address of its lease, if // there is one. FindMACbyIP(ip netip.Addr) (mac net.HardwareAddr) diff --git a/internal/dhcpd/http_unix.go b/internal/dhcpd/http_unix.go index ecc0c9e3..622bf1e7 100644 --- a/internal/dhcpd/http_unix.go +++ b/internal/dhcpd/http_unix.go @@ -5,6 +5,7 @@ package dhcpd import ( "encoding/json" "fmt" + "io" "net" "net/http" "net/netip" @@ -290,12 +291,12 @@ func (s *server) handleDHCPSetConfigV6( func (s *server) createServers(conf *dhcpServerConfigJSON) (srv4, srv6 DHCPServer, err error) { srv4, v4Enabled, err := s.handleDHCPSetConfigV4(conf) if err != nil { - return nil, nil, fmt.Errorf("bad dhcpv4 configuration: %s", err) + return nil, nil, fmt.Errorf("bad dhcpv4 configuration: %w", err) } srv6, v6Enabled, err := s.handleDHCPSetConfigV6(conf) if err != nil { - return nil, nil, fmt.Errorf("bad dhcpv6 configuration: %s", err) + return nil, nil, fmt.Errorf("bad dhcpv6 configuration: %w", err) } if conf.Enabled == aghalg.NBTrue && !v4Enabled && !v6Enabled { @@ -424,7 +425,7 @@ func newNetInterfaceJSON(iface net.Interface) (out *netInterfaceJSON, err error) addrs, err := iface.Addrs() if err != nil { return nil, fmt.Errorf( - "failed to get addresses for interface %s: %s", + "failed to get addresses for interface %s: %w", iface.Name, err, ) @@ -590,82 +591,78 @@ func setOtherDHCPResult(ifaceName string, result *dhcpSearchResult) { } } -func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) { +// parseLease parses a lease from r. If there is no error returns DHCPServer +// and *Lease. r must be non-nil. +func (s *server) parseLease(r io.Reader) (srv DHCPServer, lease *Lease, err error) { l := &leaseStatic{} - err := json.NewDecoder(r.Body).Decode(l) + err = json.NewDecoder(r).Decode(l) if err != nil { - aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err) - - return + return nil, nil, fmt.Errorf("decoding json: %w", err) } if !l.IP.IsValid() { - aghhttp.Error(r, w, http.StatusBadRequest, "invalid IP") - - return + return nil, nil, errors.Error("invalid ip") } l.IP = l.IP.Unmap() - var srv DHCPServer - if l.IP.Is4() { + lease, err = l.toLease() + if err != nil { + return nil, nil, fmt.Errorf("parsing: %w", err) + } + + if lease.IP.Is4() { srv = s.srv4 } else { srv = s.srv6 } - lease, err := l.toLease() - if err != nil { - aghhttp.Error(r, w, http.StatusBadRequest, "parsing: %s", err) + return srv, lease, nil +} - return - } - - err = srv.AddStaticLease(lease) +// handleDHCPAddStaticLease is the handler for the POST +// /control/dhcp/add_static_lease HTTP API. +func (s *server) handleDHCPAddStaticLease(w http.ResponseWriter, r *http.Request) { + srv, lease, err := s.parseLease(r.Body) if err != nil { aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) return } + + if err = srv.AddStaticLease(lease); err != nil { + aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) + } } +// handleDHCPRemoveStaticLease is the handler for the POST +// /control/dhcp/remove_static_lease HTTP API. func (s *server) handleDHCPRemoveStaticLease(w http.ResponseWriter, r *http.Request) { - l := &leaseStatic{} - err := json.NewDecoder(r.Body).Decode(l) - if err != nil { - aghhttp.Error(r, w, http.StatusBadRequest, "json.Decode: %s", err) - - return - } - - if !l.IP.IsValid() { - aghhttp.Error(r, w, http.StatusBadRequest, "invalid IP") - - return - } - - l.IP = l.IP.Unmap() - - var srv DHCPServer - if l.IP.Is4() { - srv = s.srv4 - } else { - srv = s.srv6 - } - - lease, err := l.toLease() - if err != nil { - aghhttp.Error(r, w, http.StatusBadRequest, "parsing: %s", err) - - return - } - - err = srv.RemoveStaticLease(lease) + srv, lease, err := s.parseLease(r.Body) if err != nil { aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) return } + + if err = srv.RemoveStaticLease(lease); err != nil { + aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) + } +} + +// handleDHCPUpdateStaticLease is the handler for the POST +// /control/dhcp/update_static_lease HTTP API. +func (s *server) handleDHCPUpdateStaticLease(w http.ResponseWriter, r *http.Request) { + srv, lease, err := s.parseLease(r.Body) + if err != nil { + aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) + + return + } + + if err = srv.UpdateStaticLease(lease); err != nil { + aghhttp.Error(r, w, http.StatusBadRequest, "%s", err) + } } func (s *server) handleReset(w http.ResponseWriter, r *http.Request) { @@ -729,6 +726,7 @@ func (s *server) registerHandlers() { s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/find_active_dhcp", s.handleDHCPFindActiveServer) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/add_static_lease", s.handleDHCPAddStaticLease) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/remove_static_lease", s.handleDHCPRemoveStaticLease) + s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/update_static_lease", s.handleDHCPUpdateStaticLease) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset", s.handleReset) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset_leases", s.handleResetLeases) } diff --git a/internal/dhcpd/http_unix_internal_test.go b/internal/dhcpd/http_unix_internal_test.go new file mode 100644 index 00000000..80d37050 --- /dev/null +++ b/internal/dhcpd/http_unix_internal_test.go @@ -0,0 +1,319 @@ +//go:build darwin || freebsd || linux || openbsd + +package dhcpd + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "net/netip" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// defaultResponse is a helper that returns the response with default +// configuration. +func defaultResponse() *dhcpStatusResponse { + conf4 := defaultV4ServerConf() + conf4.LeaseDuration = 86400 + + resp := &dhcpStatusResponse{ + V4: *conf4, + V6: V6ServerConf{}, + Leases: []*leaseDynamic{}, + StaticLeases: []*leaseStatic{}, + Enabled: true, + } + + return resp +} + +// handleLease is the helper function that calls handler with provided static +// lease as body and returns modified response recorder. +func handleLease(t *testing.T, lease *leaseStatic, handler http.HandlerFunc) (w *httptest.ResponseRecorder) { + t.Helper() + + w = httptest.NewRecorder() + + b := &bytes.Buffer{} + err := json.NewEncoder(b).Encode(lease) + require.NoError(t, err) + + var r *http.Request + r, err = http.NewRequest(http.MethodPost, "", b) + require.NoError(t, err) + + handler(w, r) + + return w +} + +// checkStatus is a helper that asserts the response of +// [*server.handleDHCPStatus]. +func checkStatus(t *testing.T, s *server, want *dhcpStatusResponse) { + w := httptest.NewRecorder() + + b := &bytes.Buffer{} + err := json.NewEncoder(b).Encode(&want) + require.NoError(t, err) + + r, err := http.NewRequest(http.MethodPost, "", b) + require.NoError(t, err) + + s.handleDHCPStatus(w, r) + assert.Equal(t, http.StatusOK, w.Code) + + assert.JSONEq(t, b.String(), w.Body.String()) +} + +func TestServer_handleDHCPStatus(t *testing.T) { + const ( + staticName = "static-client" + staticMAC = "aa:aa:aa:aa:aa:aa" + ) + + staticIP := netip.MustParseAddr("192.168.10.10") + + staticLease := &leaseStatic{ + HWAddr: staticMAC, + IP: staticIP, + Hostname: staticName, + } + + s, err := Create(&ServerConfig{ + Enabled: true, + Conf4: *defaultV4ServerConf(), + DataDir: t.TempDir(), + ConfigModified: func() {}, + }) + require.NoError(t, err) + + ok := t.Run("status", func(t *testing.T) { + resp := defaultResponse() + + checkStatus(t, s, resp) + }) + require.True(t, ok) + + ok = t.Run("add_static_lease", func(t *testing.T) { + w := handleLease(t, staticLease, s.handleDHCPAddStaticLease) + assert.Equal(t, http.StatusOK, w.Code) + + resp := defaultResponse() + resp.StaticLeases = []*leaseStatic{staticLease} + + checkStatus(t, s, resp) + }) + require.True(t, ok) + + ok = t.Run("add_invalid_lease", func(t *testing.T) { + w := handleLease(t, staticLease, s.handleDHCPAddStaticLease) + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + require.True(t, ok) + + ok = t.Run("remove_static_lease", func(t *testing.T) { + w := handleLease(t, staticLease, s.handleDHCPRemoveStaticLease) + assert.Equal(t, http.StatusOK, w.Code) + + resp := defaultResponse() + + checkStatus(t, s, resp) + }) + require.True(t, ok) + + ok = t.Run("set_config", func(t *testing.T) { + w := httptest.NewRecorder() + + resp := defaultResponse() + resp.Enabled = false + + b := &bytes.Buffer{} + err = json.NewEncoder(b).Encode(&resp) + require.NoError(t, err) + + var r *http.Request + r, err = http.NewRequest(http.MethodPost, "", b) + require.NoError(t, err) + + s.handleDHCPSetConfig(w, r) + assert.Equal(t, http.StatusOK, w.Code) + + checkStatus(t, s, resp) + }) + require.True(t, ok) +} + +func TestServer_HandleUpdateStaticLease(t *testing.T) { + const ( + leaseV4Name = "static-client-v4" + leaseV4MAC = "44:44:44:44:44:44" + + leaseV6Name = "static-client-v6" + leaseV6MAC = "66:66:66:66:66:66" + ) + + leaseV4IP := netip.MustParseAddr("192.168.10.10") + leaseV6IP := netip.MustParseAddr("2001::6") + + const ( + leaseV4Pos = iota + leaseV6Pos + ) + + leases := []*leaseStatic{ + leaseV4Pos: { + HWAddr: leaseV4MAC, + IP: leaseV4IP, + Hostname: leaseV4Name, + }, + leaseV6Pos: { + HWAddr: leaseV6MAC, + IP: leaseV6IP, + Hostname: leaseV6Name, + }, + } + + s, err := Create(&ServerConfig{ + Enabled: true, + Conf4: *defaultV4ServerConf(), + Conf6: V6ServerConf{}, + DataDir: t.TempDir(), + ConfigModified: func() {}, + }) + require.NoError(t, err) + + for _, l := range leases { + w := handleLease(t, l, s.handleDHCPAddStaticLease) + assert.Equal(t, http.StatusOK, w.Code) + } + + testCases := []struct { + name string + pos int + lease *leaseStatic + }{{ + name: "update_v4_name", + pos: leaseV4Pos, + lease: &leaseStatic{ + HWAddr: leaseV4MAC, + IP: leaseV4IP, + Hostname: "updated-client-v4", + }, + }, { + name: "update_v4_ip", + pos: leaseV4Pos, + lease: &leaseStatic{ + HWAddr: leaseV4MAC, + IP: netip.MustParseAddr("192.168.10.200"), + Hostname: "updated-client-v4", + }, + }, { + name: "update_v6_name", + pos: leaseV6Pos, + lease: &leaseStatic{ + HWAddr: leaseV6MAC, + IP: leaseV6IP, + Hostname: "updated-client-v6", + }, + }, { + name: "update_v6_ip", + pos: leaseV6Pos, + lease: &leaseStatic{ + HWAddr: leaseV6MAC, + IP: netip.MustParseAddr("2001::666"), + Hostname: "updated-client-v6", + }, + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w := handleLease(t, tc.lease, s.handleDHCPUpdateStaticLease) + assert.Equal(t, http.StatusOK, w.Code) + + resp := defaultResponse() + leases[tc.pos] = tc.lease + resp.StaticLeases = leases + + checkStatus(t, s, resp) + }) + } +} + +func TestServer_HandleUpdateStaticLease_validation(t *testing.T) { + const ( + leaseV4Name = "static-client-v4" + leaseV4MAC = "44:44:44:44:44:44" + + anotherV4Name = "another-client-v4" + anotherV4MAC = "55:55:55:55:55:55" + ) + + leaseV4IP := netip.MustParseAddr("192.168.10.10") + anotherV4IP := netip.MustParseAddr("192.168.10.20") + + leases := []*leaseStatic{{ + HWAddr: leaseV4MAC, + IP: leaseV4IP, + Hostname: leaseV4Name, + }, { + HWAddr: anotherV4MAC, + IP: anotherV4IP, + Hostname: anotherV4Name, + }} + + s, err := Create(&ServerConfig{ + Enabled: true, + Conf4: *defaultV4ServerConf(), + Conf6: V6ServerConf{}, + DataDir: t.TempDir(), + ConfigModified: func() {}, + }) + require.NoError(t, err) + + for _, l := range leases { + w := handleLease(t, l, s.handleDHCPAddStaticLease) + assert.Equal(t, http.StatusOK, w.Code) + } + + testCases := []struct { + lease *leaseStatic + name string + want string + }{{ + name: "v4_unknown_mac", + lease: &leaseStatic{ + HWAddr: "aa:aa:aa:aa:aa:aa", + IP: leaseV4IP, + Hostname: leaseV4Name, + }, + want: "dhcpv4: updating static lease: can't find lease aa:aa:aa:aa:aa:aa\n", + }, { + name: "update_v4_same_ip", + lease: &leaseStatic{ + HWAddr: leaseV4MAC, + IP: anotherV4IP, + Hostname: leaseV4Name, + }, + want: "dhcpv4: updating static lease: ip address is not unique\n", + }, { + name: "update_v4_same_name", + lease: &leaseStatic{ + HWAddr: leaseV4MAC, + IP: leaseV4IP, + Hostname: anotherV4Name, + }, + want: "dhcpv4: updating static lease: hostname is not unique\n", + }} + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + w := handleLease(t, tc.lease, s.handleDHCPUpdateStaticLease) + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Equal(t, tc.want, w.Body.String()) + }) + } +} diff --git a/internal/dhcpd/http_unix_test.go b/internal/dhcpd/http_unix_test.go deleted file mode 100644 index 07dd0169..00000000 --- a/internal/dhcpd/http_unix_test.go +++ /dev/null @@ -1,159 +0,0 @@ -//go:build darwin || freebsd || linux || openbsd - -package dhcpd - -import ( - "bytes" - "encoding/json" - "net/http" - "net/http/httptest" - "net/netip" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestServer_handleDHCPStatus(t *testing.T) { - const ( - staticName = "static-client" - staticMAC = "aa:aa:aa:aa:aa:aa" - ) - - staticIP := netip.MustParseAddr("192.168.10.10") - - staticLease := &leaseStatic{ - HWAddr: staticMAC, - IP: staticIP, - Hostname: staticName, - } - - s, err := Create(&ServerConfig{ - Enabled: true, - Conf4: *defaultV4ServerConf(), - DataDir: t.TempDir(), - ConfigModified: func() {}, - }) - require.NoError(t, err) - - // checkStatus is a helper that asserts the response of - // [*server.handleDHCPStatus]. - checkStatus := func(t *testing.T, want *dhcpStatusResponse) { - w := httptest.NewRecorder() - var req *http.Request - req, err = http.NewRequest(http.MethodGet, "", nil) - require.NoError(t, err) - - b := &bytes.Buffer{} - err = json.NewEncoder(b).Encode(&want) - require.NoError(t, err) - - s.handleDHCPStatus(w, req) - assert.Equal(t, http.StatusOK, w.Code) - - assert.JSONEq(t, b.String(), w.Body.String()) - } - - // defaultResponse is a helper that returns the response with default - // configuration. - defaultResponse := func() *dhcpStatusResponse { - conf4 := defaultV4ServerConf() - conf4.LeaseDuration = 86400 - - resp := &dhcpStatusResponse{ - V4: *conf4, - V6: V6ServerConf{}, - Leases: []*leaseDynamic{}, - StaticLeases: []*leaseStatic{}, - Enabled: true, - } - - return resp - } - - ok := t.Run("status", func(t *testing.T) { - resp := defaultResponse() - - checkStatus(t, resp) - }) - require.True(t, ok) - - ok = t.Run("add_static_lease", func(t *testing.T) { - w := httptest.NewRecorder() - - b := &bytes.Buffer{} - err = json.NewEncoder(b).Encode(staticLease) - require.NoError(t, err) - - var r *http.Request - r, err = http.NewRequest(http.MethodPost, "", b) - require.NoError(t, err) - - s.handleDHCPAddStaticLease(w, r) - assert.Equal(t, http.StatusOK, w.Code) - - resp := defaultResponse() - resp.StaticLeases = []*leaseStatic{staticLease} - - checkStatus(t, resp) - }) - require.True(t, ok) - - ok = t.Run("add_invalid_lease", func(t *testing.T) { - w := httptest.NewRecorder() - - b := &bytes.Buffer{} - - err = json.NewEncoder(b).Encode(&leaseStatic{}) - require.NoError(t, err) - - var r *http.Request - r, err = http.NewRequest(http.MethodPost, "", b) - require.NoError(t, err) - - s.handleDHCPAddStaticLease(w, r) - assert.Equal(t, http.StatusBadRequest, w.Code) - }) - require.True(t, ok) - - ok = t.Run("remove_static_lease", func(t *testing.T) { - w := httptest.NewRecorder() - - b := &bytes.Buffer{} - err = json.NewEncoder(b).Encode(staticLease) - require.NoError(t, err) - - var r *http.Request - r, err = http.NewRequest(http.MethodPost, "", b) - require.NoError(t, err) - - s.handleDHCPRemoveStaticLease(w, r) - assert.Equal(t, http.StatusOK, w.Code) - - resp := defaultResponse() - - checkStatus(t, resp) - }) - require.True(t, ok) - - ok = t.Run("set_config", func(t *testing.T) { - w := httptest.NewRecorder() - - resp := defaultResponse() - resp.Enabled = false - - b := &bytes.Buffer{} - err = json.NewEncoder(b).Encode(&resp) - require.NoError(t, err) - - var r *http.Request - r, err = http.NewRequest(http.MethodPost, "", b) - require.NoError(t, err) - - s.handleDHCPSetConfig(w, r) - assert.Equal(t, http.StatusOK, w.Code) - - checkStatus(t, resp) - }) - require.True(t, ok) -} diff --git a/internal/dhcpd/http_windows.go b/internal/dhcpd/http_windows.go index eb82b861..71374920 100644 --- a/internal/dhcpd/http_windows.go +++ b/internal/dhcpd/http_windows.go @@ -43,6 +43,7 @@ func (s *server) registerHandlers() { s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/find_active_dhcp", s.notImplemented) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/add_static_lease", s.notImplemented) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/remove_static_lease", s.notImplemented) + s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/update_static_lease", s.notImplemented) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset", s.notImplemented) s.conf.HTTPRegister(http.MethodPost, "/control/dhcp/reset_leases", s.notImplemented) } diff --git a/internal/dhcpd/v46_windows.go b/internal/dhcpd/v46_windows.go index dbe22055..2dbe302e 100644 --- a/internal/dhcpd/v46_windows.go +++ b/internal/dhcpd/v46_windows.go @@ -19,6 +19,7 @@ func (winServer) GetLeases(_ GetLeasesFlags) (leases []*Lease) { return nil } func (winServer) getLeasesRef() []*Lease { return nil } func (winServer) AddStaticLease(_ *Lease) (err error) { return nil } func (winServer) RemoveStaticLease(_ *Lease) (err error) { return nil } +func (winServer) UpdateStaticLease(_ *Lease) (err error) { return nil } func (winServer) FindMACbyIP(_ netip.Addr) (mac net.HardwareAddr) { return nil } func (winServer) WriteDiskConfig4(_ *V4ServerConf) {} func (winServer) WriteDiskConfig6(_ *V6ServerConf) {} diff --git a/internal/dhcpd/v4_unix.go b/internal/dhcpd/v4_unix.go index 70e34974..2b78e1cf 100644 --- a/internal/dhcpd/v4_unix.go +++ b/internal/dhcpd/v4_unix.go @@ -309,9 +309,15 @@ func (s *v4Server) rmDynamicLease(lease *Lease) (err error) { return nil } -// ErrDupHostname is returned by addLease when the added lease has a not empty -// non-unique hostname. -const ErrDupHostname = errors.Error("hostname is not unique") +const ( + // ErrDupHostname is returned by addLease, validateStaticLease when the + // modified lease has a not empty non-unique hostname. + ErrDupHostname = errors.Error("hostname is not unique") + + // ErrDupIP is returned by addLease, validateStaticLease when the modified + // lease has a non-unique IP address. + ErrDupIP = errors.Error("ip address is not unique") +) // addLease adds a dynamic or static lease. func (s *v4Server) addLease(l *Lease) (err error) { @@ -428,6 +434,81 @@ func (s *v4Server) AddStaticLease(l *Lease) (err error) { return nil } +// UpdateStaticLease updates IP, hostname of the static lease. +func (s *v4Server) UpdateStaticLease(l *Lease) (err error) { + defer func() { + if err != nil { + err = errors.Annotate(err, "dhcpv4: updating static lease: %w") + + return + } + + s.conf.notify(LeaseChangedDBStore) + s.conf.notify(LeaseChangedRemovedStatic) + }() + + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + found := s.findLease(l.HWAddr) + if found == nil { + return fmt.Errorf("can't find lease %s", l.HWAddr) + } + + err = s.validateStaticLease(l) + if err != nil { + return err + } + + err = s.rmLease(found) + if err != nil { + return fmt.Errorf("removing previous lease for %s (%s): %w", l.IP, l.HWAddr, err) + } + + err = s.addLease(l) + if err != nil { + return fmt.Errorf("adding updated static lease for %s (%s): %w", l.IP, l.HWAddr, err) + } + + return nil +} + +// validateStaticLease returns an error if the static lease is invalid. +func (s *v4Server) validateStaticLease(l *Lease) (err error) { + hostname, err := normalizeHostname(l.Hostname) + if err != nil { + // Don't wrap the error, because it's informative enough as is. + return err + } + + err = netutil.ValidateHostname(hostname) + if err != nil { + return fmt.Errorf("validating hostname: %w", err) + } + + dup, ok := s.hostsIndex[hostname] + if ok && !bytes.Equal(dup.HWAddr, l.HWAddr) { + return ErrDupHostname + } + + dup, ok = s.ipIndex[l.IP] + if ok && !bytes.Equal(dup.HWAddr, l.HWAddr) { + return ErrDupIP + } + + l.Hostname = hostname + + if gwIP := s.conf.GatewayIP; gwIP == l.IP { + return fmt.Errorf("can't assign the gateway IP %q to the lease", gwIP) + } + + if sn := s.conf.subnet; !sn.Contains(l.IP) { + return fmt.Errorf("subnet %s does not contain the ip %q", sn, l.IP) + } + + return nil +} + // updateStaticLease safe removes dynamic lease with the same properties and // then adds a static lease l. func (s *v4Server) updateStaticLease(l *Lease) (err error) { diff --git a/internal/dhcpd/v6_unix.go b/internal/dhcpd/v6_unix.go index 6a01e553..4101830e 100644 --- a/internal/dhcpd/v6_unix.go +++ b/internal/dhcpd/v6_unix.go @@ -235,6 +235,37 @@ func (s *v6Server) AddStaticLease(l *Lease) (err error) { return nil } +// UpdateStaticLease updates IP, hostname of the static lease. +func (s *v6Server) UpdateStaticLease(l *Lease) (err error) { + defer func() { + if err != nil { + err = errors.Annotate(err, "dhcpv6: updating static lease: %w") + + return + } + + s.conf.notify(LeaseChangedDBStore) + s.conf.notify(LeaseChangedRemovedStatic) + }() + + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + found := s.findLease(l.HWAddr) + if found == nil { + return fmt.Errorf("can't find lease %s", l.HWAddr) + } + + err = s.rmLease(found) + if err != nil { + return fmt.Errorf("removing previous lease for %s (%s): %w", l.IP, l.HWAddr, err) + } + + s.addLease(l) + + return nil +} + // RemoveStaticLease removes a static lease. It is safe for concurrent use. func (s *v6Server) RemoveStaticLease(l *Lease) (err error) { defer func() { err = errors.Annotate(err, "dhcpv6: %w") }() @@ -286,16 +317,14 @@ func (s *v6Server) rmLease(lease *Lease) (err error) { return fmt.Errorf("lease not found") } -// Find lease by MAC -func (s *v6Server) findLease(mac net.HardwareAddr) *Lease { - s.leasesLock.Lock() - defer s.leasesLock.Unlock() - +// Find lease by MAC. +func (s *v6Server) findLease(mac net.HardwareAddr) (lease *Lease) { for i := range s.leases { if bytes.Equal(mac, s.leases[i].HWAddr) { return s.leases[i] } } + return nil } @@ -477,7 +506,14 @@ func (s *v6Server) process(msg *dhcpv6.Message, req, resp dhcpv6.DHCPv6) bool { return false } - lease := s.findLease(mac) + var lease *Lease + func() { + s.leasesLock.Lock() + defer s.leasesLock.Unlock() + + lease = s.findLease(mac) + }() + if lease == nil { log.Debug("dhcpv6: no lease for: %s", mac) diff --git a/openapi/CHANGELOG.md b/openapi/CHANGELOG.md index 439c598d..cb28b43f 100644 --- a/openapi/CHANGELOG.md +++ b/openapi/CHANGELOG.md @@ -6,6 +6,12 @@ ## v0.107.39: API changes +### New HTTP API 'POST /control/dhcp/update_static_lease' + +* The new `POST /control/dhcp/update_static_lease` HTTP API allows modifying IP + address, hostname of the static DHCP lease. IP version must be the same as + previous. + ### The new field `"blocked_response_ttl"` in `DNSConfig` object * The new field `"blocked_response_ttl"` in `GET /control/dns_info` and `POST diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index b9e14efe..2085bfd8 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -566,6 +566,26 @@ 'schema': '$ref': '#/components/schemas/Error' 'description': 'Not implemented (for example, on Windows).' + '/dhcp/update_static_lease': + 'post': + 'tags': + - 'dhcp' + 'operationId': 'dhcpUpdateStaticLease' + 'description': > + Updates IP address, hostname of the static lease. IP version must be + the same as previous. + 'summary': 'Updates a static lease' + 'requestBody': + '$ref': '#/components/requestBodies/DhcpStaticLease' + 'responses': + '200': + 'description': 'OK.' + '501': + 'content': + 'application/json': + 'schema': + '$ref': '#/components/schemas/Error' + 'description': 'Not implemented (for example, on Windows).' '/dhcp/reset': 'post': 'tags':