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}}0>. In order to use DHCP server, a static IP address must be set. Your current IP address is <0>{{ipAddress}}0>. 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':